From 733f231f1cda12f20c90f2c16ca16fb4cbae9bc2 Mon Sep 17 00:00:00 2001 From: Phant Date: Thu, 12 Nov 2026 12:19:37 -0600 Subject: [PATCH] =?UTF-8?q?fix(providers/transport):=20=E5=9B=9E=E6=94=BE?= =?UTF-8?q?=E7=9A=84=E8=B0=83=E7=94=A8=E5=90=8D=E9=87=8C=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E4=B8=8D=E6=94=B6=E7=9A=84=E5=AD=97=E7=AC=A6=E6=8D=A2=E6=88=90?= =?UTF-8?q?=E4=B8=8B=E5=88=92=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:function_call 的 name 是模型写出的字符串,原样存进 session;模型写过一个带冒号的 名字(app::pet_say,回执 [unknown tool])之后,Responses 端点对每次重放都回 400 「Invalid 'input[N].name': string does not match pattern '^[a-zA-Z0-9_.-]+$'」,整个会话卡住。 - 只改出线副本,不看来源、不认前缀:[a-zA-Z0-9_.-] 以外的每个字符换成 _。call_id、回执、 session 记录不动,合法的名字逐字节不变。 - 字符集取上游拒收时报出的那一个。Open Responses 规范给 FunctionCallItemParam.name 的是 ^[a-zA-Z0-9_-]+$ 且最长 64,点号和长度没有被拒的现场,不处理。 - Chat Completions 出口不动:接口文档只给工具声明的名字定了字符集,没有给历史里 tool_calls 的名字定,也没有被拒的现场。 - 测试:历史里有 app::pet_say 的调用和它的 [unknown tool] 回执时,出线的名字合法、call_id 不变、回执仍对得上、记录里的名字没改;修复前这条失败。 Refs Pal-AI-Lab/Coopanion#703 Co-Authored-By: Claude Fable 5.2 --- docs/providers.md | 4 +++- src/providers/README.md | 3 ++- src/providers/transport/responses-input.ts | 10 ++++++++++ .../transport/responses-input.test.ts | 19 ++++++++++++++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index dfc259bb2..143e564db 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -80,7 +80,9 @@ WebUI 保存的 `secret` 遵循环境变量名格式 `[A-Za-z_][A-Za-z0-9_]*`。 `include` 取回,所以只有 `encrypted` 形态的请求带 `include: ['reasoning.encrypted_content']`; 不认 `include` 的端点因此只在这一形态上会拒收整条请求。项的 `id` 是签发它的服务端的资源 id:来源实例、 模块或兼容域与本次请求不同的 message、function_call、function_call_output 出线时不带 `id`, -`call_id` 照带;同一来源和没有记录来源的项(Core 合成的 `msg_` / `fc_` / `fco_`)保留 `id`。模块的 `detect` +`call_id` 照带;同一来源和没有记录来源的项(Core 合成的 `msg_` / `fc_` / `fco_`)保留 `id`。function_call 的 `name` +是模型写出的字符串,其中 `[a-zA-Z0-9_.-]` 以外的字符出线时换成 `_`(端点遇到一个不收的字符就拒收整条请求); +session 记录不改,调用和回执仍按 `call_id` 配对。模块的 `detect` 探测发两条诊断请求(合成调用不带 / 带明文推理),按上游接受哪种写回 `reasoningReplay`。 `options.endpointPath`、 `options.extraHeaders`、`options.extraBody` 分别改路径、加头、并进请求体(`extraBody` 最后 diff --git a/src/providers/README.md b/src/providers/README.md index a801b5132..d272f0ef2 100644 --- a/src/providers/README.md +++ b/src/providers/README.md @@ -83,7 +83,8 @@ Core 侧:`activeProviderEntry()` / `activeSpec()` 每次现读;`contextWindowOf( `responses-input.ts` 是原生 Responses 的无状态重放(system / developer 上提为 `instructions`;思维链按 `reasoningReplay` 以签名块或 `reasoning_text` 回传,明文形态下没有记录来源 的调用前补一项合成推理,`sendSyntheticReasoning` 为 false 时不补;来源实例、模块或兼容域与本次请求不同的 message / -function_call / function_call_output 出线时去掉可选的 `id`,`call_id` 不动,没有来源的项照带 `id`);`chat.ts` + `native-input.ts` / `history.ts` 转换 Chat Completions 请求(历史 +function_call / function_call_output 出线时去掉可选的 `id`,`call_id` 不动,没有来源的项照带 `id`;function_call 的 `name` 里 +`[a-zA-Z0-9_.-]` 以外的字符换成 `_`);`chat.ts` + `native-input.ts` / `history.ts` 转换 Chat Completions 请求(历史 思维链回不回传由模块定:`renderMessagesWithMedia` 的 `keepReasoning` 决定出不出线,`dropPastThinking` 把合成开头之外的每条抹空); `response-assembly.ts` 把两种流归一成 Open Responses 的 Item 流, diff --git a/src/providers/transport/responses-input.ts b/src/providers/transport/responses-input.ts index 836580c27..e930f6d42 100644 --- a/src/providers/transport/responses-input.ts +++ b/src/providers/transport/responses-input.ts @@ -27,6 +27,12 @@ export const SYNTHETIC_REASONING_TEXT = '-'; /** Item types whose `id` is optional on input; the id is the issuing server's resource id. */ const OPTIONAL_ID_TYPES: ReadonlySet = new Set(['message', 'function_call', 'function_call_output']); +/** + * Characters outside the set a Responses endpoint accepts in a function call's `name`. The hosted + * service states the set in its rejection: `string does not match pattern '^[a-zA-Z0-9_.-]+$'`. + */ +const INVALID_NAME_CHARS = /[^a-zA-Z0-9_.-]/g; + export interface ResponsesInputOptions { media?: CompatMediaOptions; /** Whether past reasoning re-enters the context; the first synthetic turn is exempt. */ @@ -48,6 +54,9 @@ export interface ResponsesInputOptions { * An item recorded from another instance, module or compatibility domain goes out without its * optional `id`, since another server rejects ids it did not issue; `call_id` stays, and items * without a recorded origin keep theirs. + * A function call's `name` is whatever string the model wrote, and one character the endpoint does not + * accept fails the whole request; each such character goes out as `_`. The call still pairs with its + * output through `call_id`. */ export function responsesInput( request: Request, @@ -92,6 +101,7 @@ export function responsesInput( }); const wire = inputItem(entry) as Item; if (owner && current && !sameService && OPTIONAL_ID_TYPES.has(item.type)) delete wire.id; + if (item.type === 'function_call') wire.name = item.name.replace(INVALID_NAME_CHARS, '_'); const images = imageBlobs(entry.context.blobs); if (opts.media?.enabled() && images.length && (item.type === 'message' || item.type === 'function_call_output')) { const field = item.type === 'message' ? 'content' : 'output'; diff --git a/tests/providers/transport/responses-input.test.ts b/tests/providers/transport/responses-input.test.ts index c43841dee..55354300c 100644 --- a/tests/providers/transport/responses-input.test.ts +++ b/tests/providers/transport/responses-input.test.ts @@ -1,7 +1,7 @@ /** * Responses 无状态重放的思维链形态。加密形态的规则由 tests/core/session-head.test.ts 覆盖; * 这里是明文形态:推理项以 reasoning_text 出线,当前轮不受 keepThinking 约束,本地合成的调用前补一项; - * 另有别的端点记下的项出线时 id 的去留。 + * 另有别的端点记下的项出线时 id 的去留,以及调用名里端点不收的字符。 */ import { describe, expect, it } from 'vitest'; import { REASONING_REPLAYS, SYNTHETIC_REASONING_TEXT, responsesInput } from '../../../src/providers/transport/responses-input.ts'; @@ -134,3 +134,20 @@ describe('responsesInput 跨端点的项 id', () => { expect(input[3].id).toBe(context[3].item.id); }); }); + +describe('responsesInput 历史调用的名字', () => { + it('名字里端点不收的字符换成下划线出线;call_id 和回执不变,记录不改', () => { + const context = [ + message('user', 'hi'), + functionCall('call_a', 'app::pet_say', '{"text":"hi"}', { origin: { ...origin, instance: 'other' } }), + functionResult('call_a', '[unknown tool]'), + functionCall('call_b', 'pet.say-2', '{}', { origin }), + functionResult('call_b', 'ok'), + ]; + const input = replay(context, true, 'encrypted'); + expect(input[1]).toMatchObject({ type: 'function_call', name: 'app__pet_say', call_id: 'call_a', arguments: '{"text":"hi"}' }); + expect(input[2]).toMatchObject({ type: 'function_call_output', call_id: 'call_a', output: '[unknown tool]' }); + expect(input[3]).toMatchObject({ name: 'pet.say-2', call_id: 'call_b' }); + expect(context[1].item).toMatchObject({ name: 'app::pet_say' }); + }); +});