fix: address ds-review-bot v8 findings
- llm-deepseek: the uncatalogued resolveModel fallback declares text-only modalities — the wire route is text-only regardless of catalog membership, so "unknown" must not let the host persist-then-fail images. - session.selectModel also consults the pending-inbox mirror: a queued image prompt enters the log only when claimed, after a switch would land. - attachment store: ensureDurableDirectory syncs every ancestor entry up to a caller-vouched boundary regardless of what mkdir reports — a raced "already existed" is not "already durable". - One image walker (imageBlockIn/imageInEvent) now serves both attachment authorization and the selection gate; referencedImage therefore also authorizes references inside wrapped message content. - InputHub: the scope disposer resolves the conversation service optionally (teardown/HMR must reach quiescence), and a send failing after its scope died releases the in-flight drafts instead of restoring them onto a disposed shell. - http-bridge destroys declared-oversize requests with connection: close instead of draining a body the client can trickle indefinitely. - LlmService validates AND detaches modality arrays identically on the advisory and exact routes; READMEs record the fourth INVALID_MODEL_INFO rejection reason. - CLI provider docs (JSDoc, README pair, Agent Note pair) describe the reuse behavior; llm-route.spec now parses the SHIPPED cordis.yml through the production extraction, pinning the row coupling. - image-display lane pins gallery/rail shape in inline snapshots and the object-URL scheme this environment must take; stale host.schema comment dropped.
This commit is contained in:
@@ -79,21 +79,28 @@ async function syncDirectory(path: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one private directory tree and persist every newly published ancestor.
|
||||
* Create one private directory tree and persist every ancestor entry up to a
|
||||
* caller-vouched durable boundary. The walk deliberately ignores what mkdir
|
||||
* reports as newly created: a concurrent first save can create a level this
|
||||
* process then merely observes, so "already existed" is not "already durable"
|
||||
* — the entry may still be unsynced in the creator, and a crash would drop a
|
||||
* directory the session checkpoint already references. Re-syncing a durable
|
||||
* entry is harmless; skipping an unsynced one is not.
|
||||
* @param path - absolute directory to create.
|
||||
* @param boundary - absolute ancestor the caller vouches is already durable.
|
||||
*/
|
||||
async function ensureDurableDirectory(path: string): Promise<void> {
|
||||
async function ensureDurableDirectory(path: string, boundary: string): Promise<void> {
|
||||
const target = resolve(path)
|
||||
const firstCreated = await mkdir(target, { recursive: true, mode: 0o700 })
|
||||
const stop = resolve(boundary)
|
||||
await mkdir(target, { recursive: true, mode: 0o700 })
|
||||
await chmod(target, 0o700)
|
||||
if (firstCreated === undefined) return
|
||||
|
||||
const highestCreated = resolve(firstCreated)
|
||||
let created = target
|
||||
while (true) {
|
||||
await syncDirectory(dirname(created))
|
||||
if (created === highestCreated) return
|
||||
created = dirname(created)
|
||||
let level = target
|
||||
while (level !== stop) {
|
||||
const parent = dirname(level)
|
||||
await syncDirectory(parent)
|
||||
/* v8 ignore next -- filesystem-root guard: callers pass a boundary that is an ancestor of path, so the walk reaches it first. */
|
||||
if (parent === level) return
|
||||
level = parent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +117,12 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
||||
const sha256 = digest(input.data)
|
||||
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
||||
const staging = join(root, 'tmp')
|
||||
await ensureDurableDirectory(bucket)
|
||||
await ensureDurableDirectory(staging)
|
||||
// The durable boundary is the root's grandparent (DSH_HOME for the
|
||||
// documented `DSH_HOME/attachments/v1` layout): `attachments`/`v1` may be
|
||||
// first-created by a concurrent save, so their entries sync on every path.
|
||||
const boundary = dirname(dirname(resolve(root)))
|
||||
await ensureDurableDirectory(bucket, boundary)
|
||||
await ensureDurableDirectory(staging, boundary)
|
||||
const temporary = join(staging, randomUUID())
|
||||
const target = objectPath(root, sha256)
|
||||
let handle
|
||||
|
||||
@@ -47,7 +47,7 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('local attachment store', () => {
|
||||
it.skipIf(process.platform === 'win32')('syncs every newly created object ancestor before returning', async () => {
|
||||
it.skipIf(process.platform === 'win32')('syncs every object ancestor up to the durable boundary before returning', async () => {
|
||||
const storageRoot = await root()
|
||||
const base = join(storageRoot, '..', '..')
|
||||
const sha256 = createHash('sha256').update(PNG).digest('hex')
|
||||
@@ -57,12 +57,20 @@ describe('local attachment store', () => {
|
||||
|
||||
await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
|
||||
// Every level between each created directory and the vouched boundary
|
||||
// syncs unconditionally — "already existed" is not "already durable"
|
||||
// when a concurrent first save may have created but not yet synced it.
|
||||
expect(fsControl.syncedDirectories).toEqual([
|
||||
// bucket chain: every parent entry between the bucket and the boundary.
|
||||
objects,
|
||||
storageRoot,
|
||||
join(storageRoot, '..'),
|
||||
base,
|
||||
// staging chain re-walks the shared ancestors after creating tmp.
|
||||
storageRoot,
|
||||
join(storageRoot, '..'),
|
||||
base,
|
||||
// publication: the settled object's bucket and its parent for the rename.
|
||||
bucket,
|
||||
objects,
|
||||
])
|
||||
|
||||
@@ -30,9 +30,13 @@ export async function bridge(
|
||||
})
|
||||
const declaredLength = req.headers['content-length']
|
||||
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
|
||||
res.writeHead(413)
|
||||
// Same discipline as the chunked-overrun path below: destroy, never
|
||||
// drain. resume() would keep the socket open while the client trickles
|
||||
// its declared length — an already-rejected request holding a server
|
||||
// socket for as long as it likes.
|
||||
res.writeHead(413, { connection: 'close' })
|
||||
res.end()
|
||||
req.resume()
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
@@ -5,6 +5,34 @@ import { describe, expect, it } from 'vitest'
|
||||
import { bridge } from '../src/http-bridge.ts'
|
||||
|
||||
describe('HTTP bridge abort', () => {
|
||||
it('destroys a declared-oversize request instead of draining it', async () => {
|
||||
const destroyed: true[] = []
|
||||
const request = Readable.from([]) as unknown as IncomingMessage
|
||||
Object.assign(request, {
|
||||
url: '/api/session.prompt',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'content-length': '999999' },
|
||||
destroy: () => { destroyed.push(true) },
|
||||
})
|
||||
let status: number | undefined
|
||||
let headers: unknown
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
|
||||
write() { return true },
|
||||
end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
|
||||
}) as unknown as ServerResponse
|
||||
|
||||
await bridge(request, response, {
|
||||
fetch: () => { throw new Error('a rejected request must never reach the handler') },
|
||||
}, 1000)
|
||||
// The socket must not stay parked draining a body the client can trickle
|
||||
// at will after the rejection — same discipline as the chunked overrun.
|
||||
expect(status).toBe(413)
|
||||
expect(headers).toMatchObject({ connection: 'close' })
|
||||
expect(destroyed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('aborts a pending native picker request when the browser disconnects', async () => {
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
|
||||
|
||||
@@ -87,11 +87,17 @@ export class InputHub implements InputService {
|
||||
for (const off of offs) off()
|
||||
// Draft attachments die with the scope: the shell only holds ids, so
|
||||
// the service-owned File objects and object URLs must be released
|
||||
// here or they leak for the page lifetime.
|
||||
// here or they leak for the page lifetime. The lookup is optional —
|
||||
// during application teardown or HMR of this plugin the root
|
||||
// `conversation` service can already be unregistered while session
|
||||
// scopes are still alive; a throwing disposer would abort teardown
|
||||
// quiescence, and the service's own disposal effect revokes every
|
||||
// remaining URL in that case anyway.
|
||||
const drafts = shell.snapshot.imageIds
|
||||
shell.dispose()
|
||||
this.shells.delete(id)
|
||||
for (const imageId of drafts) this.conversation().releaseDraftImage(imageId)
|
||||
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
|
||||
for (const imageId of drafts) conversation?.releaseDraftImage(imageId)
|
||||
}
|
||||
}, 'conversation.input: session shell')
|
||||
return shell
|
||||
@@ -139,8 +145,18 @@ export class InputHub implements InputService {
|
||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||
shell?.commitSend(imageIds)
|
||||
void this.conversation().sendSession(session, text, mode, imageIds).catch(() => {
|
||||
shell?.restoreImages(imageIds)
|
||||
if (shell?.snapshot.draft === '') shell.setDraft(text)
|
||||
// Restore only into the shell that still owns the session: if the scope
|
||||
// died while the send was in flight, `commitSend` already removed the
|
||||
// ids from the (now disposed) shell, so the teardown release could not
|
||||
// see them — release the drafts here instead of resurrecting them onto
|
||||
// a dead instance where they would leak for the page lifetime.
|
||||
if (this.shells.get(session.sessionId) === shell) {
|
||||
shell?.restoreImages(imageIds)
|
||||
if (shell?.snapshot.draft === '') shell.setDraft(text)
|
||||
return
|
||||
}
|
||||
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
|
||||
for (const id of imageIds) conversation?.releaseDraftImage(id)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,34 @@ describe('ConversationService', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('releases in-flight send images when the scope dies before the failure lands', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:inflight-1')
|
||||
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
|
||||
try {
|
||||
const [attachment] = b.root.createDraftImages([new File([new Uint8Array(4)], 'b.png', { type: 'image/png' })])
|
||||
if (attachment === undefined) throw new Error('draft attachment missing')
|
||||
const shell = b.hub.shell(b.runtime.sessions.behavior('s1').sessionId)
|
||||
shell.addImages([attachment.id])
|
||||
let reject!: (error: Error) => void
|
||||
b.prompt.mockReturnValueOnce(new Promise((_resolve, rej) => { reject = rej }) as never)
|
||||
shell.setDraft('x')
|
||||
shell.submit('queue')
|
||||
// commitSend already removed the ids from the shell; kill the scope
|
||||
// while the RPC is still pending, then land the failure.
|
||||
await b.runtime.sessions.remove('s1')
|
||||
reject(new Error('transport died'))
|
||||
await vi.waitFor(() => {
|
||||
expect(revoked).toHaveBeenCalledWith('blob:inflight-1')
|
||||
})
|
||||
expect(b.root.draftImages([attachment.id])).toEqual([])
|
||||
} finally {
|
||||
created.mockRestore()
|
||||
revoked.mockRestore()
|
||||
}
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
|
||||
const b = await bench()
|
||||
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
||||
|
||||
@@ -110,33 +110,48 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten
|
||||
}))
|
||||
}
|
||||
|
||||
function imageInContent(content: unknown, attachmentId: string): ImageAttachmentRef | undefined {
|
||||
/**
|
||||
* The ONE recursive block walk shared by attachment authorization and the
|
||||
* model-selection gate (nested tool-result content included). Both consumers
|
||||
* must agree on what counts as replayed image content — a route added to one
|
||||
* walker but not the other would silently skip authorization or stranding
|
||||
* protection — so there is exactly one walker, parameterized by match.
|
||||
*/
|
||||
function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
for (const value of content) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
||||
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
|
||||
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
|
||||
const ref = block.attachment as ImageAttachmentRef
|
||||
if (String(ref.attachmentId) === attachmentId) return ref
|
||||
if (match(ref)) return ref
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
const nested = imageInContent(block.content, attachmentId)
|
||||
const nested = imageBlockIn(block.content, match)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Every replayed content route of one event: direct content, wrapped message content, streamed block-end. */
|
||||
function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
|
||||
const data = event.data as { content?: unknown; message?: { content?: unknown }; chunk?: { type?: unknown; block?: unknown } }
|
||||
const direct = imageBlockIn(data.content, match)
|
||||
if (direct !== undefined) return direct
|
||||
if (data.message !== undefined) {
|
||||
const wrapped = imageBlockIn(data.message.content, match)
|
||||
if (wrapped !== undefined) return wrapped
|
||||
}
|
||||
if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
|
||||
return imageBlockIn([data.chunk.block], match)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** True when any block (nested tool-result content included) is an image block. */
|
||||
function contentHasImage(content: unknown): boolean {
|
||||
if (!Array.isArray(content)) return false
|
||||
for (const value of content) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
||||
const block = value as { type?: unknown; content?: unknown }
|
||||
if (block.type === 'image') return true
|
||||
if (block.type === 'tool-result' && contentHasImage(block.content)) return true
|
||||
}
|
||||
return false
|
||||
return imageBlockIn(content, () => true) !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,23 +160,13 @@ function contentHasImage(content: unknown): boolean {
|
||||
* The log is immutable, so a true here is permanent for the session's life.
|
||||
*/
|
||||
function sessionHasImage(events: readonly SessionEvent[]): boolean {
|
||||
return events.some((event) => {
|
||||
const data = event.data as { content?: unknown; message?: { content?: unknown }; chunk?: { type?: unknown; block?: unknown } }
|
||||
if (contentHasImage(data.content)) return true
|
||||
if (data.message !== undefined && contentHasImage(data.message.content)) return true
|
||||
return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end' && contentHasImage([data.chunk.block])
|
||||
})
|
||||
return events.some(event => imageInEvent(event, () => true) !== undefined)
|
||||
}
|
||||
|
||||
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
|
||||
for (const event of events) {
|
||||
const data = event.data as { content?: unknown; chunk?: { type?: unknown; block?: unknown } }
|
||||
const direct = imageInContent(data.content, attachmentId)
|
||||
if (direct !== undefined) return direct
|
||||
if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
|
||||
const streamed = imageInContent([data.chunk.block], attachmentId)
|
||||
if (streamed !== undefined) return streamed
|
||||
}
|
||||
const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -1141,7 +1146,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// wire routes reject image content on text-only models — accepting
|
||||
// this selection would strand the session (every turn fails, no
|
||||
// in-product recovery). Refuse at the selection boundary instead.
|
||||
if (sessionHasImage(found.agent.session.events)) {
|
||||
// The pending inbox counts too: a queued image prompt enters the log
|
||||
// only when claimed, which would happen AFTER this switch landed.
|
||||
const queuedImage = (queuedMirror.get(sessionId) ?? [])
|
||||
.some(entry => contentHasImage(entry.message.content))
|
||||
if (queuedImage || sessionHasImage(found.agent.session.events)) {
|
||||
const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
|
||||
if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
|
||||
return err(request, {
|
||||
|
||||
@@ -37,8 +37,6 @@ export const hostDescribeValueSchema = z.object({
|
||||
mediaTypes: z.array(imageMediaTypeSchema),
|
||||
}).optional(),
|
||||
attachedSessions: z.number().int().nonnegative(),
|
||||
// Open string, not a literal union: unknown kinds must survive the wire so
|
||||
// a merge-added capability can advertise (the client hides the affordance).
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
|
||||
|
||||
/** host.pickDirectory request payload (empty object literal). */
|
||||
|
||||
@@ -274,6 +274,52 @@ describe('Web session model selection', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a text-only selection while an image prompt is still queued (not yet logged)', async () => {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] })
|
||||
}
|
||||
}('Text Only', []))
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
// The queued message enters the session log only when claimed — after a
|
||||
// model switch would already have landed. The pending-inbox mirror must
|
||||
// therefore gate the switch too.
|
||||
ctx.emit('agent/inbox/enqueue', agent, {
|
||||
id: 'q-1', role: 'user', source: { kind: 'user' },
|
||||
content: [{ type: 'image', attachment: { attachmentId: 'att-q', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }],
|
||||
} as never, 'queued')
|
||||
const stranded = await api.sessions.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
|
||||
expect(stranded.result.ok).toBe(false)
|
||||
// Claiming the message drains the mirror; the log now owns the decision.
|
||||
ctx.emit('agent/inbox/dequeue', agent, { id: 'q-1' } as never, 'queued')
|
||||
expect(expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'text-only', model: 'plain',
|
||||
}))).selected).toEqual({ provider: 'text-only', model: 'plain' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('authorizes an attachment read referenced only from wrapped message content', async () => {
|
||||
const { ctx, sessionId, agent } = await harness()
|
||||
const ref = { attachmentId: 'att-w', mediaType: 'image/png' as const, bytes: 4, width: 1, height: 1 }
|
||||
ctx.provide('attachments', {
|
||||
readImage: () => Promise.resolve({ ref, data: new Uint8Array([1, 2, 3, 4]) }),
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
// The only reference lives inside an assistant/message wrapper — the same
|
||||
// walk that gates model selection must authorize the read, or a real host
|
||||
// denies galleries the fixture (with its own authorization mirror) serves.
|
||||
agent.session.append('assistant/message', {
|
||||
turn: 1, step: 0,
|
||||
message: { id: 'a-1', role: 'assistant', source: { kind: 'model', provider: 'p', model: 'm' }, content: [{ type: 'image', attachment: ref }] },
|
||||
} as never, { surfaceOp: 'append' })
|
||||
const got = await api.sessions.attachment(request({ sessionId, attachmentId: 'att-w' as never }))
|
||||
expect(got.result).toMatchObject({ ok: true, value: { attachment: ref } })
|
||||
const denied = await api.sessions.attachment(request({ sessionId, attachmentId: 'att-other' as never }))
|
||||
expect(denied.result).toMatchObject({ ok: false, error: { details: { reason: 'ATTACHMENT_NOT_REFERENCED' } } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('detects images on every replayed route: wrapped messages, streamed blocks, nested tool results', async () => {
|
||||
const image = { type: 'image', attachment: { attachmentId: 'att-x', mediaType: 'image/png', bytes: 8, width: 1, height: 1 } }
|
||||
const cases: { label: string; append: (agent: Agent) => void }[] = [
|
||||
|
||||
@@ -166,8 +166,12 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
const contextWindow = configured?.contextWindow
|
||||
?? this.options.defaultContextWindow
|
||||
return Promise.resolve({
|
||||
// The chat-completions wire route is text-only regardless of catalog
|
||||
// membership, so the uncatalogued fallback declares the same negative
|
||||
// capability — "unknown" here would let the host accept and persist
|
||||
// images the serializer must then reject.
|
||||
...configured === undefined
|
||||
? { provider, id: model, name: model }
|
||||
? { provider, id: model, name: model, inputModalities: ['text' as const], outputModalities: ['text' as const] }
|
||||
: modelInfo(provider, configured),
|
||||
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
||||
...this.options.defaults?.thinking === 'disabled'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
|
||||
README.md: d343449d1530bf70a3a8c57f883894e29c42d18f
|
||||
README.zh.md: 6ac57b1e6010b58c45b516f13ec6361d47ca8d12
|
||||
README.md: ecd6da304a894a687153608f5b468f867ad32e6b
|
||||
README.zh.md: 2812f2d74ec3dbab521f8e3148e9efe2640c6929
|
||||
|
||||
@@ -23,7 +23,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
|
||||
|
||||
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`.
|
||||
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity or modality metadata fails with `INVALID_MODEL_INFO`, and invalid context or reasoning metadata with `INVALID_MODEL_CONTEXT` or `INVALID_MODEL_REASONING`.
|
||||
|
||||
Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
提供方与模型元数据是发现表层,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
|
||||
|
||||
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份、上下文或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。
|
||||
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份或模态元数据会以 `INVALID_MODEL_INFO` 失败,无效的上下文或推理元数据则以 `INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。
|
||||
|
||||
推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速完成结算。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
LlmModelInfo,
|
||||
LlmResolvedModelInfo,
|
||||
LlmProviderInfo,
|
||||
ModelModality,
|
||||
StreamChunk,
|
||||
} from './types.ts'
|
||||
import { freezeMessage, type Message } from './message.ts'
|
||||
@@ -253,6 +254,28 @@ export class LlmService extends Service {
|
||||
return this.registration(provider).retryPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate adapter-owned modality arrays and detach them. One rule for the
|
||||
* advisory catalog and exact resolution: both validate, both copy — two
|
||||
* readings of the same adapter field with different trust or detachment
|
||||
* would be an unexplained asymmetry.
|
||||
* @param provider - provider route (diagnostic context).
|
||||
* @param code - error code matching the calling surface.
|
||||
* @param modalities - adapter-owned array, or undefined for unknown.
|
||||
* @returns a detached copy, or undefined when absent.
|
||||
*/
|
||||
private detachedModalities(
|
||||
provider: string,
|
||||
code: 'INVALID_CATALOG' | 'INVALID_MODEL_INFO',
|
||||
modalities: readonly unknown[] | undefined,
|
||||
): ModelModality[] | undefined {
|
||||
if (modalities === undefined) return undefined
|
||||
if (!Array.isArray(modalities) || modalities.some(entry => typeof entry !== 'string')) {
|
||||
throw new LlmError(`adapter returned invalid modality metadata for provider "${provider}"`, code)
|
||||
}
|
||||
return [...(modalities as readonly ModelModality[])]
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover models advertised by one registered provider. Catalog membership
|
||||
* is advisory and never changes routing or request validation.
|
||||
@@ -277,13 +300,15 @@ export class LlmService extends Service {
|
||||
throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG')
|
||||
}
|
||||
seen.add(model.id)
|
||||
const inputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.inputModalities)
|
||||
const outputModalities = this.detachedModalities(provider, 'INVALID_CATALOG', model.outputModalities)
|
||||
return {
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
|
||||
...model.outputModalities === undefined ? {} : { outputModalities: [...model.outputModalities] },
|
||||
...inputModalities === undefined ? {} : { inputModalities },
|
||||
...outputModalities === undefined ? {} : { outputModalities },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -333,23 +358,17 @@ export class LlmService extends Service {
|
||||
'INVALID_MODEL_CONTEXT',
|
||||
)
|
||||
}
|
||||
for (const modalities of [resolved.inputModalities, resolved.outputModalities]) {
|
||||
if (modalities !== undefined && (!Array.isArray(modalities) || modalities.some(m => typeof m !== 'string'))) {
|
||||
throw new LlmError(
|
||||
`adapter returned invalid modality metadata for provider "${provider}" model "${model}"`,
|
||||
'INVALID_MODEL_INFO',
|
||||
)
|
||||
}
|
||||
}
|
||||
// Capability metadata rides through: an explicit modality omission is
|
||||
// negative capability downstream preflights act on (image admission).
|
||||
const inputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.inputModalities)
|
||||
const outputModalities = this.detachedModalities(provider, 'INVALID_MODEL_INFO', resolved.outputModalities)
|
||||
const info: LlmResolvedModelInfo = {
|
||||
provider,
|
||||
id: model,
|
||||
name: resolved.name,
|
||||
...resolved.description === undefined ? {} : { description: resolved.description },
|
||||
// Capability metadata rides through: an explicit modality omission is
|
||||
// negative capability downstream preflights act on (image admission).
|
||||
...resolved.inputModalities === undefined ? {} : { inputModalities: resolved.inputModalities },
|
||||
...resolved.outputModalities === undefined ? {} : { outputModalities: resolved.outputModalities },
|
||||
...inputModalities === undefined ? {} : { inputModalities },
|
||||
...outputModalities === undefined ? {} : { outputModalities },
|
||||
...context === undefined ? {} : { context: { contextWindow: context.contextWindow } },
|
||||
}
|
||||
const reasoning = resolved.reasoning
|
||||
|
||||
@@ -1177,6 +1177,8 @@ describe('LlmService', () => {
|
||||
[{ provider: 'route', id: 'm', name: 1 }, 'non-string name'],
|
||||
[{ provider: 'route', id: 'm', name: '' }, 'empty name'],
|
||||
[{ provider: 'route', id: 'm', name: 'M', description: 1 }, 'non-string description'],
|
||||
[{ provider: 'route', id: 'm', name: 'M', inputModalities: 'text' }, 'non-array input modalities'],
|
||||
[{ provider: 'route', id: 'm', name: 'M', outputModalities: [1] }, 'non-string output modality'],
|
||||
] as const)('rejects invalid model metadata (%s: %s)', async (metadata, _label) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
Reference in New Issue
Block a user