From b854d041aeaf95b153371d557cac4d41d422d6fc Mon Sep 17 00:00:00 2001 From: Phant Date: Sat, 7 Nov 2026 14:04:53 -0600 Subject: [PATCH 1/6] =?UTF-8?q?fix(providers/transport):=20413=20=E4=B8=80?= =?UTF-8?q?=E5=BE=8B=E7=AE=97=E4=BD=9C=E4=B8=8A=E4=B8=8B=E6=96=87=E8=B6=85?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:isContextOverflow 只认正文里 context length 一类字样。网关回的 413(openresty 的 HTML 页、 带自定义错误码的 JSON)都不匹配,Core 把它当普通失败结束本批,下一批从同一份记录渲染出同样的 请求体,再次被拒,会话停在这里直到手动交接。 - 413 是请求体超过服务的大小上限,与正文怎么写无关;算作超限后由 Core 交接。 - errors.test 的 413 用例在改动前失败。 Co-Authored-By: Claude Opus 5.5 --- docs/sessions.md | 2 +- src/providers/transport/errors.ts | 11 ++++++----- tests/providers/transport/errors.test.ts | 13 ++++++++++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index f869e9984..7fe6d1cf9 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -36,7 +36,7 @@ Persona 声明,Core 只把 `role` 当分类标签。 ## 上下文与交接 Core 的输入上限为 `hardTokens = max(0, 生效窗口 − (spec.maxTokens ?? 0))`。生效窗口取服务探测值与配置的 -`contextWindow` 中的较小者;两者都缺失时不设置此上限。超过上限或服务拒绝超长输入时, +`contextWindow` 中的较小者;两者都缺失时不设置此上限。超过上限或服务拒绝超长输入时(413 请求体过大也算), Core 调用 `Persona.onHandoff(snapshot, { hardTokens })`。Persona 决定保留的上下文和交接笔记, Core 重建 system 前缀、按容量截断保留内容并重置 session。 上一批已按计数强制交接、其间没有一次计数检查回到上限内,计数又越过同一上限时(窗口不大于最大输出,或交接后的 diff --git a/src/providers/transport/errors.ts b/src/providers/transport/errors.ts index d7068a69d..b3a6b6a79 100644 --- a/src/providers/transport/errors.ts +++ b/src/providers/transport/errors.ts @@ -29,13 +29,14 @@ export function upstreamText(body: string): string { } /** - * Whether an upstream rejection says the input exceeded the model's context. Status 0 is an - * in-stream failure event; 400/413 are the request-rejection codes. The body patterns cover - * the OpenAI `context_length_exceeded` code, DeepSeek's "maximum context length" message - * and llama-server's "exceeds the available context size". + * Whether an upstream rejection says the input is too large for the model. A 413 always is: the + * request body exceeds the service's size limit, whatever the body says. Status 0 (an in-stream + * failure event) and 400 count when the body matches: the OpenAI `context_length_exceeded` code, + * DeepSeek's "maximum context length" message and llama-server's "exceeds the available context size". */ export function isContextOverflow(error: { status: number; body: string }): boolean { - if (error.status !== 0 && error.status !== 400 && error.status !== 413) return false; + if (error.status === 413) return true; + if (error.status !== 0 && error.status !== 400) return false; return /context_length_exceeded|context length|context size|context window|maximum context|prompt is too long|input is too long|too many tokens/i.test(error.body); } diff --git a/tests/providers/transport/errors.test.ts b/tests/providers/transport/errors.test.ts index 3bec09a7f..0373ed10a 100644 --- a/tests/providers/transport/errors.test.ts +++ b/tests/providers/transport/errors.test.ts @@ -1,8 +1,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { parseRetryAfter } from '../../../src/providers/transport/errors.ts'; +import { isContextOverflow, parseRetryAfter } from '../../../src/providers/transport/errors.ts'; afterEach(() => vi.useRealTimers()); +describe('isContextOverflow', () => { + it('counts every 413, whatever its body says', () => { + expect(isContextOverflow({ status: 413, body: '413 Request Entity Too Large' })).toBe(true); + expect(isContextOverflow({ status: 413, body: '{"code":41301,"message":"request body over the size limit"}' })).toBe(true); + }); + it('counts a 400 only when its body says the context is exceeded', () => { + expect(isContextOverflow({ status: 400, body: '{"error":{"code":"context_length_exceeded"}}' })).toBe(true); + expect(isContextOverflow({ status: 400, body: '{"error":{"message":"unsupported image"}}' })).toBe(false); + }); +}); + describe('parseRetryAfter', () => { it('数字按秒,HTTP 日期按距今,缺失与不成形为 null', () => { vi.useFakeTimers(); From e7aded69c60ee90f5911aaee7b154bc20c5acdfa Mon Sep 17 00:00:00 2001 From: Phant Date: Sat, 7 Nov 2026 14:08:17 -0600 Subject: [PATCH 2/6] =?UTF-8?q?fix(providers):=20=E5=B8=A6=E5=9B=BE?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E8=A2=AB=E6=8B=92=E6=97=B6=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E9=87=8D=E5=8F=91=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:带图请求被 400/413/422 拒收时,transport 和 Core 都不重试,本批结束;下一批从同一份记录 渲染出同样的图片,次次被拒。单张不合规的图(边长超限、解码不了)和累积超过请求体上限的历史 图片都会这样卡住。 - transport 加 ImageFallback,两个内建模块的 client 都套上:去掉这次请求的图片重发一次;被接受就 按句柄记下(只在内存里),之后不带,并在正文后补一行说明图没发出去;重发也被拒就带上两次尝试 交还,什么都不记。正常请求的字节不变,前缀缓存不受影响。 - 413 先去图再交给 Core 判超限:附件字节先撞请求体上限,token 计数几乎不动。去图后仍 413 由 Core 交接。 - 没选:另设请求体字节上限,网关的上限配置不出来;只去掉上次成功之后的新图,请求里图片数量变化 会改变旧图的边长上限,那时旧图才是原因,只去新图会卡在同一个请求上。 - image-fallback.test 经两个内建模块和本机假服务验证;改动前前三个用例失败。 Co-Authored-By: Claude Opus 5.5 --- docs/sessions.md | 3 +- src/providers/README.md | 7 +- src/providers/llamacpp/index.ts | 8 +- .../openai-responses-compat/index.ts | 6 +- src/providers/transport/image-fallback.ts | 101 ++++++++++++++++ tests/core/llm.test.ts | 3 +- .../transport/image-fallback.test.ts | 109 ++++++++++++++++++ 7 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 src/providers/transport/image-fallback.ts create mode 100644 tests/providers/transport/image-fallback.test.ts diff --git a/docs/sessions.md b/docs/sessions.md index 7fe6d1cf9..a13a67b68 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -36,7 +36,8 @@ Persona 声明,Core 只把 `role` 当分类标签。 ## 上下文与交接 Core 的输入上限为 `hardTokens = max(0, 生效窗口 − (spec.maxTokens ?? 0))`。生效窗口取服务探测值与配置的 -`contextWindow` 中的较小者;两者都缺失时不设置此上限。超过上限或服务拒绝超长输入时(413 请求体过大也算), +`contextWindow` 中的较小者;两者都缺失时不设置此上限。超过上限或服务拒绝超长输入时(413 请求体过大也算; +带图的请求先由 provider 去掉图片重发一次,见 `src/providers/README.md`), Core 调用 `Persona.onHandoff(snapshot, { hardTokens })`。Persona 决定保留的上下文和交接笔记, Core 重建 system 前缀、按容量截断保留内容并重置 session。 上一批已按计数强制交接、其间没有一次计数检查回到上限内,计数又越过同一上限时(窗口不大于最大输出,或交接后的 diff --git a/src/providers/README.md b/src/providers/README.md index d272f0ef2..be0940e3d 100644 --- a/src/providers/README.md +++ b/src/providers/README.md @@ -1,4 +1,4 @@ - + # src/providers @@ -91,6 +91,11 @@ function_call / function_call_output 出线时去掉可选的 `id`,`call_id` 不 `finish_reason` 的 `length` / `content_filter` 落成 `incomplete_details.reason`; `response-meters.ts` 把两种 usage 归一成 `TokenMeters`,缺项保持 null。 +`image-fallback.ts` 的 `ImageFallback` 是带图请求被拒时的回落,两个内建模块的 client 都套着它:请求带图、上游回 413,或回 400 / 422 +且模块的 `contextOverflow` 不认,就去掉这次请求的图片重发一次。重发被接受,按句柄记下这些图(只在内存里),之后经这个 client +的请求都不带,每条失去图片的记录在正文后补一行 `imageLeftOut`;重发也被拒,错误带上两次尝试,什么都不记。 +`attached` 交回 client 实际附图的那些记录,不给就按全部记录算。`isContextOverflow` 把任何 413 算作超限,所以去掉图片后仍 413 时由 Core 交接。 + 线协议类型在 `src/protocol/open-responses/`(规范 2026-04-24 生成),`ResponseAccumulator` 逐事件强校验:序号递增、终态自洽、已关闭的 Item 不得再变。 diff --git a/src/providers/llamacpp/index.ts b/src/providers/llamacpp/index.ts index 4668b9186..6f1016bf7 100644 --- a/src/providers/llamacpp/index.ts +++ b/src/providers/llamacpp/index.ts @@ -2,6 +2,7 @@ import type { ProviderModule } from '../base.ts'; import type { ReasoningTier } from '../../core/types.ts'; import type { Language } from '../../core/language.ts'; import { isContextOverflow } from '../transport/errors.ts'; +import { ImageFallback } from '../transport/image-fallback.ts'; import { modelsRoot, runtimesRoot } from '../../paths.ts'; import { RouterCatalog } from './catalog.ts'; import { runtimeConfig } from './config.ts'; @@ -76,6 +77,7 @@ export default { roots: { runtimes: runtimesRoot(), models: modelsRoot() }, }); const runtime = host.resource?.('runtime', createRuntime) ?? createRuntime(); + const media = { enabled: () => entry.multimodal === true, read: host.readBlob }; return { control: { runtime, catalog }, listModels: () => catalog.listModels(), @@ -86,13 +88,13 @@ export default { catalog.stop(); await runtime.stop(); }, - client: new LlamaCppProvider({ + client: new ImageFallback(new LlamaCppProvider({ baseUrl: entry.baseUrl, apiKey, log: host.log, - media: { enabled: () => entry.multimodal === true, read: host.readBlob }, + media, keepThinking: host.keepThinking, - }), + }), { media, contextOverflow: isContextOverflow, log: host.log }), }; }, } satisfies ProviderModule; diff --git a/src/providers/openai-responses-compat/index.ts b/src/providers/openai-responses-compat/index.ts index 4533067f8..f85d282c7 100644 --- a/src/providers/openai-responses-compat/index.ts +++ b/src/providers/openai-responses-compat/index.ts @@ -2,6 +2,7 @@ import type { ProviderModule } from '../base.ts'; import type { LLMProviderEntry } from '../../core/types.ts'; import type { ResponseClient } from '../../core/generation.ts'; import { isContextOverflow } from '../transport/errors.ts'; +import { ImageFallback } from '../transport/image-fallback.ts'; import { REASONING_REPLAYS, type ReasoningReplay } from '../transport/responses-input.ts'; import { ModelCatalog, ResponsesProvider, type ResponsesProviderOptions } from './native.ts'; import { text } from './strings.ts'; @@ -94,6 +95,7 @@ export default { ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), }); const catalog = new ModelCatalog(() => ({ baseUrl: entry.baseUrl, headers: headers() })); + const media = { enabled: () => entry.multimodal === true, read: host.readBlob }; const clientOptions = (reasoningReplay: ReasoningReplay | undefined): ResponsesProviderOptions => ({ baseUrl: entry.baseUrl, apiKey, @@ -101,7 +103,7 @@ export default { extraHeaders: options.extraHeaders, extraBody: options.extraBody, log: host.log, - media: { enabled: () => entry.multimodal === true, read: host.readBlob }, + media, keepThinking: host.keepThinking, reasoningReplay, syntheticReasoningText: options.syntheticReasoningText, @@ -112,7 +114,7 @@ export default { contextWindow: (model) => catalog.contextWindow(model), compatibilityKey: () => [options.endpointPath ?? '/responses', name], control: { probeClient: (replay) => new ResponsesProvider({ ...clientOptions(replay), sendSyntheticReasoning: true }) } satisfies CompatControl, - client: new ResponsesProvider(clientOptions(options.reasoningReplay)), + client: new ImageFallback(new ResponsesProvider(clientOptions(options.reasoningReplay)), { media, contextOverflow: isContextOverflow, log: host.log }), }; }, } satisfies ProviderModule; diff --git a/src/providers/transport/image-fallback.ts b/src/providers/transport/image-fallback.ts new file mode 100644 index 000000000..e2b709848 --- /dev/null +++ b/src/providers/transport/image-fallback.ts @@ -0,0 +1,101 @@ +/** + * Fallback for a request whose images the model service refuses. A request that carries images and + * is refused with 413, or with 400 or 422 that is not a context overflow, is sent once more without + * them. When that one is accepted, the images were the cause: their handles are remembered, every + * later request through this client leaves them out, and each record that lost one gets the + * `imageLeftOut` line after its text. The handles live in memory only, so after a restart the first + * request carrying one of them is refused once more. + * + * A 413 is retried without images even though `isContextOverflow` counts it: attachment bytes reach a + * service's body size limit long before they move the token count. A 413 that survives the retry + * reaches Core as a context overflow. + */ +import { BaseProvider } from '../base.ts'; +import { GenerationError, type GenerateOptions, type Generation } from '../../core/generation.ts'; +import type { ContextRecord, Item } from '../../protocol/open-responses/context.ts'; +import type { Request } from '../../protocol/open-responses/index.ts'; +import type { Logger } from '../../core/types.ts'; +import { imageBlobs, type CompatMediaOptions } from './history.ts'; + +/** Statuses of a request the service will not take as written. */ +const REFUSED: ReadonlySet = new Set([400, 413, 422]); + +export interface ImageFallbackOptions { + media: CompatMediaOptions; + /** The module's `contextOverflow`. A refusal it matches is not retried, except a 413. */ + contextOverflow(error: { status: number; body: string }): boolean; + /** The records whose images the client attaches to a request; every record when absent. */ + attached?(context: readonly ContextRecord[]): readonly ContextRecord[]; + log: Logger; +} + +/** Follows the `[blob …]` line of an image a request goes out without. */ +export const imageLeftOut = (handle: string): string => + `[${handle} left out: the model service rejected the request that carried this image]`; + +export class ImageFallback extends BaseProvider { + /** Images whose request was accepted only without them. */ + private readonly refused = new Set(); + + constructor(private readonly inner: BaseProvider, private readonly options: ImageFallbackOptions) { + super(); + } + + async respond(request: Request, options: GenerateOptions = {}): Promise { + const context = options.context && withoutImages(options.context, this.refused); + try { + return await this.inner.respond(request, context ? { ...options, context } : options); + } catch (error) { + if (!context || options.diagnostic || options.signal?.aborted || !(error instanceof GenerationError) || !this.retried(error)) throw error; + const sent = this.sentImages(context); + if (!sent.size) throw error; + let generation: Generation; + try { + generation = await this.inner.respond(request, { ...options, context: withoutImages(context, sent) }); + } catch (retry) { + if (!(retry instanceof GenerationError)) throw retry; + throw new GenerationError(retry.message, [...error.attempts, ...retry.attempts], retry.partial, retry.origin, retry.status, retry.body, { cause: retry.cause }); + } + for (const handle of sent) this.refused.add(handle); + this.options.log.warn('Model service rejected a request with images; sent again without them', { status: error.status, images: [...sent] }); + return { ...generation, attempts: [...error.attempts, ...generation.attempts] }; + } + } + + private retried(error: GenerationError): boolean { + return REFUSED.has(error.status) && (error.status === 413 || !this.options.contextOverflow(error)); + } + + /** The images the client attaches to a request built from `context`. */ + private sentImages(context: readonly ContextRecord[]): Set { + const { media, attached } = this.options; + if (!media.enabled()) return new Set(); + return new Set((attached?.(context) ?? context).flatMap((entry) => imageBlobs(entry.context.blobs) + .filter((ref) => media.read(ref.handle)) + .map((ref) => ref.handle))); + } +} + +/** The context without the images in `handles`; each record that lost one gets an `imageLeftOut` line per image. */ +export function withoutImages(context: readonly ContextRecord[], handles: ReadonlySet): readonly ContextRecord[] { + if (!handles.size) return context; + return context.map((entry) => { + const blobs = entry.context.blobs ?? []; + const out = imageBlobs(blobs).filter((ref) => handles.has(ref.handle)); + if (!out.length) return entry; + return { + ...entry, + item: withLine(entry.item, out.map((ref) => imageLeftOut(ref.handle)).join('\n')), + context: { ...entry.context, blobs: blobs.filter((ref) => !out.includes(ref)) }, + }; + }); +} + +/** The item with `line` after its text; content parts other than text stay in place. */ +function withLine(item: Item, line: string): Item { + if (item.type === 'message') + return { ...item, content: typeof item.content === 'string' ? `${item.content}\n${line}` : [...item.content, { type: 'input_text', text: line }] } as Item; + if (item.type === 'function_call_output') + return { ...item, output: typeof item.output === 'string' ? `${item.output}\n${line}` : [...item.output, { type: 'input_text', text: line }] } as Item; + return item; +} diff --git a/tests/core/llm.test.ts b/tests/core/llm.test.ts index fba549838..90247c4ac 100644 --- a/tests/core/llm.test.ts +++ b/tests/core/llm.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import module, { compatOptions } from '../../src/providers/openai-responses-compat/index.ts'; import { ResponsesProvider, buildResponsesBody } from '../../src/providers/openai-responses-compat/native.ts'; import { validateEntry, validateSpec } from '../../src/providers/configuration.ts'; +import { ImageFallback } from '../../src/providers/transport/image-fallback.ts'; import { responseRequest } from '../../src/protocol/open-responses/context-helpers.ts'; import { message, record } from '../../src/protocol/open-responses/context.ts'; import { GenerationError } from '../../src/core/generation.ts'; @@ -167,7 +168,7 @@ describe('模块声明与条目校验', () => { stateDir: 'unused', secret: (name) => (name === 'K' ? 'sk' : ''), readBlob: () => null, keepThinking: () => true, log: { info() {}, warn() {}, error() {}, debug() {}, child() { return this; } } as never, }); - expect(instance.client).toBeInstanceOf(ResponsesProvider); + expect(instance.client).toBeInstanceOf(ImageFallback); expect(typeof instance.listModels).toBe('function'); expect(instance.contextWindow!('anything')).toBeUndefined(); expect(instance.compatibilityKey!()).toEqual(['/x', 'cloud']); diff --git a/tests/providers/transport/image-fallback.test.ts b/tests/providers/transport/image-fallback.test.ts new file mode 100644 index 000000000..6e2fcc01b --- /dev/null +++ b/tests/providers/transport/image-fallback.test.ts @@ -0,0 +1,109 @@ +/** + * Requests the model service refuses for their images, through both built-in modules against a + * local fake service that refuses a request by its body. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { GenerationError } from '../../../src/core/generation.ts'; +import { nullLogger } from '../../../src/core/util.ts'; +import { blobLine } from '../../../src/core/blobs.ts'; +import type { BlobRef, LLMProviderEntry } from '../../../src/core/types.ts'; +import type { ProviderModule } from '../../../src/providers/base.ts'; +import type { Request } from '../../../src/protocol/open-responses/index.ts'; +import { message } from '../../../src/protocol/open-responses/context.ts'; +import { isContextOverflow } from '../../../src/providers/transport/errors.ts'; +import { imageLeftOut } from '../../../src/providers/transport/image-fallback.ts'; +import COMPAT from '../../../src/providers/openai-responses-compat/index.ts'; +import LLAMACPP from '../../../src/providers/llamacpp/index.ts'; + +const IMAGE = Buffer.from('an image the service will not take'); +const IMAGE_DATA = IMAGE.toString('base64'); +const picture: BlobRef = { handle: 'log:0123456789abcdef0123.png', mime: 'image/png', name: 'big.png', fallbackText: 'Image' }; +const context = [message('user', `look\n${blobLine(picture)}`, { blobs: [picture] })]; +const OPENRESTY_413 = '413 Request Entity Too Large
openresty
'; + +const REPLIES: Record = { + '/responses': { id: 'r1', object: 'response', model: 'm', status: 'completed', created_at: 1, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + output: [{ type: 'message', id: 'm1', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'ok', annotations: [] }] }] }, + '/chat/completions': { id: 'c1', model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }, +}; + +let server: Server | null = null; +afterEach(() => { server?.close(); server = null; }); + +/** A fake service that answers `status` with `error` to the requests `refuse` picks. */ +async function service(refuse: (body: string) => boolean, status = 400, error = JSON.stringify({ error: { message: 'unsupported image' } })) { + const bodies: string[] = []; + server = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk; + bodies.push(body); + if (refuse(body)) return res.writeHead(status, { 'content-type': error.startsWith('<') ? 'text/html' : 'application/json' }).end(error); + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(REPLIES[(req.url ?? '').split('?')[0]!] ?? {})); + }); + await new Promise((resolve) => server!.listen(0, '127.0.0.1', () => resolve())); + return { url: `http://127.0.0.1:${(server!.address() as { port: number }).port}`, bodies }; +} + +const carriesImage = (body: string) => body.includes(IMAGE_DATA); + +function client(module: ProviderModule, url: string, patch: Partial = {}) { + const entry: LLMProviderEntry = { kind: module.id, baseUrl: url, multimodal: true, spec: { model: 'm', thinking: false }, ...patch }; + return module.create('x', entry, { + stateDir: mkdtempSync(join(tmpdir(), 'image-fallback-')), secret: () => '', keepThinking: () => true, log: nullLogger(), + readBlob: (handle: string) => (handle === picture.handle ? IMAGE : null), + }).client; +} + +const ask = (target: ReturnType) => target.respond({ model: 'm', input: [] } as unknown as Request, { context }); + +describe('a request the model service refuses for its images', () => { + it('is sent once more without them on both modules, and later requests leave them out: one request each', async () => { + const { url, bodies } = await service(carriesImage); + for (const module of [COMPAT, LLAMACPP]) { + bodies.length = 0; + const target = client(module, url); + const first = await ask(target); + expect(first.attempts.map((a) => a.status), module.id).toEqual([400, 200]); + expect(bodies.map(carriesImage), module.id).toEqual([true, false]); + expect(bodies[1], module.id).toContain(imageLeftOut(picture.handle)); + await ask(target); + expect(bodies.map(carriesImage), module.id).toEqual([true, false, false]); + expect(bodies[2], module.id).toBe(bodies[1]); + } + }); + + it('is sent again without them on a 413, which otherwise counts as a context overflow', async () => { + const { url, bodies } = await service(carriesImage, 413, OPENRESTY_413); + const first = await ask(client(COMPAT, url)); + expect(first.attempts.map((a) => a.status)).toEqual([413, 200]); + expect(bodies.map(carriesImage)).toEqual([true, false]); + }); + + it('keeps both attempts on the error when the request without them is refused too, and sends them next time', async () => { + const { url, bodies } = await service(() => true, 413, OPENRESTY_413); + const target = client(COMPAT, url); + const error = await ask(target).catch((e: unknown) => e); + expect(error).toBeInstanceOf(GenerationError); + expect((error as GenerationError).attempts.map((a) => a.status)).toEqual([413, 413]); + expect(isContextOverflow(error as GenerationError)).toBe(true); + await ask(target).catch(() => {}); + expect(bodies.map(carriesImage)).toEqual([true, false, true, false]); + }); + + it('is not sent again when the refusal is a token overflow: one request', async () => { + const { url, bodies } = await service(carriesImage, 400, JSON.stringify({ error: { code: 'context_length_exceeded', message: 'too long' } })); + await expect(ask(client(COMPAT, url))).rejects.toMatchObject({ status: 400 }); + expect(bodies).toHaveLength(1); + }); + + it('is not sent again when the endpoint sends no images: one request', async () => { + const { url, bodies } = await service(() => true); + await expect(ask(client(COMPAT, url, { multimodal: false }))).rejects.toMatchObject({ status: 400 }); + expect(bodies).toHaveLength(1); + }); +}); From 4d2b0a116d223bce4ec24bafc827868d1e824ef9 Mon Sep 17 00:00:00 2001 From: Phant Date: Sat, 7 Nov 2026 14:54:13 -0600 Subject: [PATCH 3/6] =?UTF-8?q?fix(providers/transport):=20=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E6=B2=A1=E5=8F=91=E5=87=BA=E5=8E=BB=E7=9A=84=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E5=8F=A6=E8=B5=B7=E4=B8=80=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:message() 建的用户消息正文是只有一段的数组,补说明时追加的是新的一段;Chat 把各段文字 直接拼接,说明就粘在 [blob …] 行末尾。 - 追加的段以换行开头。新用例经 llamacpp 走 Chat,改动前失败。 Co-Authored-By: Claude Opus 5.5 --- src/providers/transport/image-fallback.ts | 9 ++++++--- tests/providers/transport/image-fallback.test.ts | 6 ++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/providers/transport/image-fallback.ts b/src/providers/transport/image-fallback.ts index e2b709848..9c889f663 100644 --- a/src/providers/transport/image-fallback.ts +++ b/src/providers/transport/image-fallback.ts @@ -91,11 +91,14 @@ export function withoutImages(context: readonly ContextRecord[], handles: Readon }); } -/** The item with `line` after its text; content parts other than text stay in place. */ +/** + * The item with `line` on its own line after its text; content parts other than text stay in place. + * An appended part starts with a newline because Chat joins text parts without a separator. + */ function withLine(item: Item, line: string): Item { if (item.type === 'message') - return { ...item, content: typeof item.content === 'string' ? `${item.content}\n${line}` : [...item.content, { type: 'input_text', text: line }] } as Item; + return { ...item, content: typeof item.content === 'string' ? `${item.content}\n${line}` : [...item.content, { type: 'input_text', text: `\n${line}` }] } as Item; if (item.type === 'function_call_output') - return { ...item, output: typeof item.output === 'string' ? `${item.output}\n${line}` : [...item.output, { type: 'input_text', text: line }] } as Item; + return { ...item, output: typeof item.output === 'string' ? `${item.output}\n${line}` : [...item.output, { type: 'input_text', text: `\n${line}` }] } as Item; return item; } diff --git a/tests/providers/transport/image-fallback.test.ts b/tests/providers/transport/image-fallback.test.ts index 6e2fcc01b..326cf0c44 100644 --- a/tests/providers/transport/image-fallback.test.ts +++ b/tests/providers/transport/image-fallback.test.ts @@ -77,6 +77,12 @@ describe('a request the model service refuses for its images', () => { } }); + it('puts the left-out line on its own line on Chat, which joins text parts without a separator', async () => { + const { url, bodies } = await service(carriesImage); + await ask(client(LLAMACPP, url)); + expect(JSON.parse(bodies[1]!).messages.at(-1).content).toBe(`look\n${blobLine(picture)}\n${imageLeftOut(picture.handle)}`); + }); + it('is sent again without them on a 413, which otherwise counts as a context overflow', async () => { const { url, bodies } = await service(carriesImage, 413, OPENRESTY_413); const first = await ask(client(COMPAT, url)); From c2250e5a392624a74aee584442389d5d1e50c5c0 Mon Sep 17 00:00:00 2001 From: Phant Date: Thu, 12 Nov 2026 12:27:12 -0600 Subject: [PATCH 4/6] =?UTF-8?q?fix(providers):=20bind=20=E7=BB=99=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E8=87=AA=E5=B7=B1=E5=81=9A=E5=9B=BE=E7=89=87=E5=9B=9E?= =?UTF-8?q?=E8=90=BD=E7=9A=84=E5=AE=9E=E4=BE=8B=E5=A5=97=E4=B8=80=E5=B1=82?= =?UTF-8?q?=20ImageFallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展 provider 的 client 没有套 ImageFallback:带图请求被 413 拒收时直接进交接,坏图的 400 每批原样再被拒。ProviderRegistry.bind 现在给每个实例套一层,图像开关取端点的 multimodal,contextOverflow 取模块的。 - bind 每次请求都跑,回落要记住被拒的句柄,所以按实例存一份(WeakMap),随实例重建。 - ProviderInstance.handlesRefusedImages:client 自己已经套了的实例声明它,bind 不再套。 两个内建模块声明;只给一部分记录附图的 client 要自己套并用 attached 交回那些记录。 - ImageFallbackOptions.contextOverflow 的参数改成 GenerationError,和 ProviderModule 的一致。 - 测试:经 bind 的带图请求被拒后去图重发一次,下一次 bind 不再带图(共一次请求); 声明了 handlesRefusedImages 的实例只发一次,拒收原样交回。 Co-Authored-By: Claude Fable 5.2 --- docs/providers.md | 8 ++- src/providers/README.md | 10 ++-- src/providers/base.ts | 5 ++ src/providers/llamacpp/index.ts | 1 + .../openai-responses-compat/index.ts | 1 + src/providers/registry.ts | 23 ++++++++- src/providers/transport/image-fallback.ts | 2 +- tests/providers/registry.test.ts | 49 ++++++++++++++++++- 8 files changed, 91 insertions(+), 8 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 143e564db..201c65498 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,4 +1,4 @@ - + # Provider @@ -130,6 +130,12 @@ router 自动加载。 `incomplete_details.reason`,被截断的工具调用由 Core 标成未执行。输出字符数超过 `max_output_tokens × 12` 时终止请求并报告输出字符数超限。 +带图的请求被上游以 413 拒收,或以 400 / 422 拒收且模块的 `contextOverflow` 不认时,去掉这次请求的图片重发一次。重发被接受: +这些图之后不再发给这个端点(按句柄记在内存里,实例重建或进程重启后清空),每条失去图片的记录在正文后补一行说明。重发也被拒:错误带上两次尝试交给 Core, +去掉图片后仍是 413 的按输入超限交接(见 [sessions.md](sessions.md))。每个端点都有这层回落(`ImageFallback`):内建模块的 client 自己套, +扩展 provider 由 `ProviderRegistry.bind` 套。扩展的 client 自己套了的,在 `create()` 返回的实例上写 `handlesRefusedImages: true`, +`bind` 就不再套;只给一部分记录附图的 client 要自己套,用 `attached` 交回附了图的记录。 + ## 计价 每次请求尝试按价目中声明的计量项计费,包括输入、输出、缓存命中与推理用量等,币种默认 USD,写入 diff --git a/src/providers/README.md b/src/providers/README.md index be0940e3d..e7c7efdf7 100644 --- a/src/providers/README.md +++ b/src/providers/README.md @@ -36,7 +36,8 @@ effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可 声明仍参与服务端校验。启停与模型操作这类动作走面板 invoke。 `create()` 返回 `ProviderInstance`:`client`(实现 `respond`)、`listModels?`、`control?`、 -`compatibilityKey?`、`start?` / `stop?`、`contextWindow?(model)`。`listModels()` 返回 `ListedModel[]`: +`compatibilityKey?`、`start?` / `stop?`、`contextWindow?(model)`、`handlesRefusedImages?`(client 自己已经做了带图请求被拒时的回落, +见下文 `image-fallback.ts`)。`listModels()` 返回 `ListedModel[]`: `id` 必有,`displayName`、`contextWindow`、`maxOutputTokens`、`inputImages` 只在上游给出时才有。 `ProviderHost` 给实例:`stateDir`(`<部署根>/providers/<端点名>/`,归实例独占)、`repoRoot`、 @@ -54,7 +55,8 @@ effort 收任意非空串)、`serviceTiers`、`create(name, entry, host)`。可 `spec` 的条目 JSON 加端点 `.env` 的内容指纹为缓存键(改模型或价格不重建实例,密钥文件变更后 下一次解析重建),填 `stateDir` 与 `secret`。 `bind(name)` 在实例外包一层:注入 `quote`(报价快照)与 `origin`(实例、模块、模型、 -`compatibilityDomain` = sha256(kind + baseUrl + `compatibilityKey()`))。Responses 历史推理仅在实例、 +`compatibilityDomain` = sha256(kind + baseUrl + `compatibilityKey()`));实例没有声明 `handlesRefusedImages` 时, +client 外再套一层 `ImageFallback`,每个实例一份,随实例重建。Responses 历史推理仅在实例、 模块、兼容域与模型均匹配时回传。`invalidate()` 清除缓存,WebUI 保存端点后调用;`.env` 变更经缓存键在下一次解析生效。 ## 配置形状 @@ -91,10 +93,12 @@ function_call / function_call_output 出线时去掉可选的 `id`,`call_id` 不 `finish_reason` 的 `length` / `content_filter` 落成 `incomplete_details.reason`; `response-meters.ts` 把两种 usage 归一成 `TokenMeters`,缺项保持 null。 -`image-fallback.ts` 的 `ImageFallback` 是带图请求被拒时的回落,两个内建模块的 client 都套着它:请求带图、上游回 413,或回 400 / 422 +`image-fallback.ts` 的 `ImageFallback` 是带图请求被拒时的回落:请求带图、上游回 413,或回 400 / 422 且模块的 `contextOverflow` 不认,就去掉这次请求的图片重发一次。重发被接受,按句柄记下这些图(只在内存里),之后经这个 client 的请求都不带,每条失去图片的记录在正文后补一行 `imageLeftOut`;重发也被拒,错误带上两次尝试,什么都不记。 `attached` 交回 client 实际附图的那些记录,不给就按全部记录算。`isContextOverflow` 把任何 413 算作超限,所以去掉图片后仍 413 时由 Core 交接。 +两个内建模块的 client 自己套着它,实例声明 `handlesRefusedImages`;其余实例由 `ProviderRegistry.bind` 套,图像开关取端点的 `multimodal`, +`contextOverflow` 取模块的,不给 `attached`。只给一部分记录附图的 client 自己套并声明 `handlesRefusedImages`。 线协议类型在 `src/protocol/open-responses/`(规范 2026-04-24 生成),`ResponseAccumulator` 逐事件强校验:序号递增、终态自洽、已关闭的 Item 不得再变。 diff --git a/src/providers/base.ts b/src/providers/base.ts index 2649d218a..34b362ac1 100644 --- a/src/providers/base.ts +++ b/src/providers/base.ts @@ -57,6 +57,11 @@ export interface ListedModel { export interface ProviderInstance { client: BaseProvider; + /** + * The client already sends a request refused for its images once more without them: it is an + * `ImageFallback`, or does the same itself. Absent: `ProviderRegistry.bind` puts one around the client. + */ + handlesRefusedImages?: boolean; /** Models the endpoint advertises (`GET /models`). */ listModels?(): Promise; /** Module-owned control object; interpreted by that module's WebUI contribution. */ diff --git a/src/providers/llamacpp/index.ts b/src/providers/llamacpp/index.ts index 6f1016bf7..f70df9d12 100644 --- a/src/providers/llamacpp/index.ts +++ b/src/providers/llamacpp/index.ts @@ -95,6 +95,7 @@ export default { media, keepThinking: host.keepThinking, }), { media, contextOverflow: isContextOverflow, log: host.log }), + handlesRefusedImages: true, }; }, } satisfies ProviderModule; diff --git a/src/providers/openai-responses-compat/index.ts b/src/providers/openai-responses-compat/index.ts index f85d282c7..08e7361df 100644 --- a/src/providers/openai-responses-compat/index.ts +++ b/src/providers/openai-responses-compat/index.ts @@ -115,6 +115,7 @@ export default { compatibilityKey: () => [options.endpointPath ?? '/responses', name], control: { probeClient: (replay) => new ResponsesProvider({ ...clientOptions(replay), sendSyntheticReasoning: true }) } satisfies CompatControl, client: new ImageFallback(new ResponsesProvider(clientOptions(options.reasoningReplay)), { media, contextOverflow: isContextOverflow, log: host.log }), + handlesRefusedImages: true, }; }, } satisfies ProviderModule; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 1131d757f..0ae20b3d5 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2,7 +2,8 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { URL, fileURLToPath, pathToFileURL } from 'node:url'; import type { LLMProviderEntry } from '../core/types.ts'; -import type { ProviderHostBase, ProviderInstance, ProviderModule } from './base.ts'; +import type { BaseProvider, ProviderHostBase, ProviderInstance, ProviderModule } from './base.ts'; +import { ImageFallback } from './transport/image-fallback.ts'; import { quotePrices } from './pricebook.ts'; import { createHash } from 'node:crypto'; import { secretReader } from '../core/secrets.ts'; @@ -66,6 +67,8 @@ export function providerModule(kind: string): ProviderModule { export class ProviderRegistry { private readonly instances = new Map(); private readonly resources = new Map(); + /** One per instance: `bind` runs on every request, and the fallback holds the handles the service refused. */ + private readonly imageFallbacks = new WeakMap(); constructor( private readonly entries: () => Record, private readonly host: ProviderHostBase, @@ -130,6 +133,7 @@ export class ProviderRegistry { const module = this.module(raw.kind); const entry = module.normalize?.(structuredClone(raw)) ?? structuredClone(raw); const instance = this.resolve(name); + const target = this.imageFallback(instance, module, entry); const domain = () => createHash('sha256') .update(JSON.stringify([entry.kind, entry.baseUrl, instance.compatibilityKey?.() ?? null])) @@ -137,7 +141,7 @@ export class ProviderRegistry { const client: ResponseClient = { bind: () => client, respond: (request, options) => - instance.client.respond(request, { + target.respond(request, { ...options, quote: (at) => quotePrices(entry, request, at, module.prices?.(entry, request, at) ?? []), origin: { @@ -151,6 +155,21 @@ export class ProviderRegistry { return client; } + /** The instance's client behind an `ImageFallback`; the client itself when the instance declares `handlesRefusedImages`. */ + private imageFallback(instance: ProviderInstance, module: ProviderModule, entry: LLMProviderEntry): BaseProvider { + if (instance.handlesRefusedImages) return instance.client; + let fallback = this.imageFallbacks.get(instance); + if (!fallback) { + fallback = new ImageFallback(instance.client, { + media: { enabled: () => entry.multimodal === true, read: this.host.readBlob }, + contextOverflow: (error) => module.contextOverflow?.(error) ?? false, + log: this.host.log, + }); + this.imageFallbacks.set(instance, fallback); + } + return fallback; + } + /** Drop the cached instance so the next resolve rebuilds it; `host.resource` objects survive. */ invalidate(name: string): void { this.instances.delete(name); diff --git a/src/providers/transport/image-fallback.ts b/src/providers/transport/image-fallback.ts index 9c889f663..d78dc3772 100644 --- a/src/providers/transport/image-fallback.ts +++ b/src/providers/transport/image-fallback.ts @@ -23,7 +23,7 @@ const REFUSED: ReadonlySet = new Set([400, 413, 422]); export interface ImageFallbackOptions { media: CompatMediaOptions; /** The module's `contextOverflow`. A refusal it matches is not retried, except a 413. */ - contextOverflow(error: { status: number; body: string }): boolean; + contextOverflow(error: GenerationError): boolean; /** The records whose images the client attaches to a request; every record when absent. */ attached?(context: readonly ContextRecord[]): readonly ContextRecord[]; log: Logger; diff --git a/tests/providers/registry.test.ts b/tests/providers/registry.test.ts index a989da5ae..6cfe71b3a 100644 --- a/tests/providers/registry.test.ts +++ b/tests/providers/registry.test.ts @@ -1,4 +1,7 @@ -/** 实例缓存键感知端点 `.env` 的内容:文件变更后下一次解析重建实例。 */ +/** + * 实例缓存键感知端点 `.env` 的内容:文件变更后下一次解析重建实例。 + * `bind` 给没有声明 `handlesRefusedImages` 的实例套一层 `ImageFallback`,每个实例一份。 + */ import { afterEach, expect, it } from 'vitest'; import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -6,6 +9,10 @@ import { makeCfg, makeTmpDir } from '../core/helpers.ts'; import { nullLogger } from '../../src/core/util.ts'; import { ProviderRegistry } from '../../src/providers/registry.ts'; import { BaseProvider, type ProviderModule } from '../../src/providers/base.ts'; +import { GenerationError, type GenerateOptions, type Generation } from '../../src/core/generation.ts'; +import type { BlobRef } from '../../src/core/types.ts'; +import { createResponse, type Request } from '../../src/protocol/open-responses/index.ts'; +import { message } from '../../src/protocol/open-responses/context.ts'; class StubClient extends BaseProvider { async respond(): Promise { throw new Error('unused'); } @@ -48,3 +55,43 @@ it('keeps the cached instance while the .env content is unchanged', () => { expect(registry.resolve('Alpha')).toBe(first); expect(seenKeys).toHaveLength(1); }); + +const picture: BlobRef = { handle: 'log:0123456789abcdef0123.png', mime: 'image/png', name: 'big.png', fallbackText: 'Image' }; + +/** A client whose service refuses every request that carries an image; `carried` is one entry per request. */ +class RefusesImages extends BaseProvider { + readonly carried: boolean[] = []; + async respond(request: Request, options: GenerateOptions = {}): Promise { + const carries = (options.context ?? []).some((entry) => entry.context.blobs?.length); + this.carried.push(carries); + if (carries) throw new GenerationError('refused', [], null, options.origin!, 400, 'unsupported image'); + return { response: createResponse('r1', request), origin: options.origin!, attempts: [] }; + } +} + +function imageFixture(handlesRefusedImages?: true) { + const temp = makeTmpDir(); cleanups.push(temp.cleanup); + const client = new RefusesImages(); + const module: ProviderModule = { + id: 'stub', title: 'Stub', reasoningTiers: [], serviceTiers: [], + create: () => ({ client, ...(handlesRefusedImages ? { handlesRefusedImages } : {}) }), + }; + const cfg = makeCfg({ providers: { Alpha: { kind: 'stub', baseUrl: 'https://model.test', multimodal: true } }, activeProvider: 'Alpha' }); + const registry = new ProviderRegistry(() => cfg.providers, { stateRoot: temp.dir, readBlob: () => Buffer.from('image'), keepThinking: () => true, log: nullLogger() }, [module]); + const ask = () => registry.bind('Alpha').respond({ model: 'm', input: [] }, { context: [message('user', 'look', { blobs: [picture] })] }); + return { client, ask }; +} + +it('sends a request refused for its images once more without them, and the next bind leaves them out: one request', async () => { + const { client, ask } = imageFixture(); + await ask(); + expect(client.carried).toEqual([true, false]); + await ask(); + expect(client.carried).toEqual([true, false, false]); +}); + +it('adds no fallback around an instance that declares handlesRefusedImages: one request, and the refusal reaches the caller', async () => { + const { client, ask } = imageFixture(true); + await expect(ask()).rejects.toMatchObject({ status: 400 }); + expect(client.carried).toEqual([true]); +}); From ad4a93b926d6227fb0d5b04c158236a62f8244e1 Mon Sep 17 00:00:00 2001 From: Phant Date: Thu, 12 Nov 2026 12:43:29 -0600 Subject: [PATCH 5/6] =?UTF-8?q?fix(providers/transport):=20=E5=8E=BB?= =?UTF-8?q?=E5=9B=BE=E9=87=8D=E5=8F=91=E5=8F=AA=E8=AE=A4=20400=20=E5=92=8C?= =?UTF-8?q?=20413?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 422 没有被拒的现场:400 有超过单边上限的 JPEG,413 有网关的请求体上限。集合里只留 这两个,注释和两份文档同改。 Co-Authored-By: Claude Fable 5.2 --- docs/providers.md | 2 +- src/providers/README.md | 2 +- src/providers/transport/image-fallback.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 201c65498..46aa78bad 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -130,7 +130,7 @@ router 自动加载。 `incomplete_details.reason`,被截断的工具调用由 Core 标成未执行。输出字符数超过 `max_output_tokens × 12` 时终止请求并报告输出字符数超限。 -带图的请求被上游以 413 拒收,或以 400 / 422 拒收且模块的 `contextOverflow` 不认时,去掉这次请求的图片重发一次。重发被接受: +带图的请求被上游以 413 拒收,或以 400 拒收且模块的 `contextOverflow` 不认时,去掉这次请求的图片重发一次。重发被接受: 这些图之后不再发给这个端点(按句柄记在内存里,实例重建或进程重启后清空),每条失去图片的记录在正文后补一行说明。重发也被拒:错误带上两次尝试交给 Core, 去掉图片后仍是 413 的按输入超限交接(见 [sessions.md](sessions.md))。每个端点都有这层回落(`ImageFallback`):内建模块的 client 自己套, 扩展 provider 由 `ProviderRegistry.bind` 套。扩展的 client 自己套了的,在 `create()` 返回的实例上写 `handlesRefusedImages: true`, diff --git a/src/providers/README.md b/src/providers/README.md index e7c7efdf7..43fda7f9a 100644 --- a/src/providers/README.md +++ b/src/providers/README.md @@ -93,7 +93,7 @@ function_call / function_call_output 出线时去掉可选的 `id`,`call_id` 不 `finish_reason` 的 `length` / `content_filter` 落成 `incomplete_details.reason`; `response-meters.ts` 把两种 usage 归一成 `TokenMeters`,缺项保持 null。 -`image-fallback.ts` 的 `ImageFallback` 是带图请求被拒时的回落:请求带图、上游回 413,或回 400 / 422 +`image-fallback.ts` 的 `ImageFallback` 是带图请求被拒时的回落:请求带图、上游回 413,或回 400 且模块的 `contextOverflow` 不认,就去掉这次请求的图片重发一次。重发被接受,按句柄记下这些图(只在内存里),之后经这个 client 的请求都不带,每条失去图片的记录在正文后补一行 `imageLeftOut`;重发也被拒,错误带上两次尝试,什么都不记。 `attached` 交回 client 实际附图的那些记录,不给就按全部记录算。`isContextOverflow` 把任何 413 算作超限,所以去掉图片后仍 413 时由 Core 交接。 diff --git a/src/providers/transport/image-fallback.ts b/src/providers/transport/image-fallback.ts index d78dc3772..c0ca5fdbf 100644 --- a/src/providers/transport/image-fallback.ts +++ b/src/providers/transport/image-fallback.ts @@ -1,6 +1,6 @@ /** * Fallback for a request whose images the model service refuses. A request that carries images and - * is refused with 413, or with 400 or 422 that is not a context overflow, is sent once more without + * is refused with 413, or with a 400 that is not a context overflow, is sent once more without * them. When that one is accepted, the images were the cause: their handles are remembered, every * later request through this client leaves them out, and each record that lost one gets the * `imageLeftOut` line after its text. The handles live in memory only, so after a restart the first @@ -18,7 +18,7 @@ import type { Logger } from '../../core/types.ts'; import { imageBlobs, type CompatMediaOptions } from './history.ts'; /** Statuses of a request the service will not take as written. */ -const REFUSED: ReadonlySet = new Set([400, 413, 422]); +const REFUSED: ReadonlySet = new Set([400, 413]); export interface ImageFallbackOptions { media: CompatMediaOptions; From 3a0987354d34c79c7f3c197823be6189dcf29f53 Mon Sep 17 00:00:00 2001 From: Phant Date: Thu, 12 Nov 2026 12:43:29 -0600 Subject: [PATCH 6/6] =?UTF-8?q?docs(core):=20sessions.md=20=E4=B8=8D?= =?UTF-8?q?=E5=A4=8D=E8=BF=B0=20provider=20=E7=9A=84=E5=8E=BB=E5=9B=BE?= =?UTF-8?q?=E9=87=8D=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 去图重发写在 docs/providers.md「传输」和 src/providers/README.md;交接页只留「413 也算 服务拒绝超长输入」。 Co-Authored-By: Claude Fable 5.2 --- docs/sessions.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index a13a67b68..7fe6d1cf9 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -36,8 +36,7 @@ Persona 声明,Core 只把 `role` 当分类标签。 ## 上下文与交接 Core 的输入上限为 `hardTokens = max(0, 生效窗口 − (spec.maxTokens ?? 0))`。生效窗口取服务探测值与配置的 -`contextWindow` 中的较小者;两者都缺失时不设置此上限。超过上限或服务拒绝超长输入时(413 请求体过大也算; -带图的请求先由 provider 去掉图片重发一次,见 `src/providers/README.md`), +`contextWindow` 中的较小者;两者都缺失时不设置此上限。超过上限或服务拒绝超长输入时(413 请求体过大也算), Core 调用 `Persona.onHandoff(snapshot, { hardTokens })`。Persona 决定保留的上下文和交接笔记, Core 重建 system 前缀、按容量截断保留内容并重置 session。 上一批已按计数强制交接、其间没有一次计数检查回到上限内,计数又越过同一上限时(窗口不大于最大输出,或交接后的