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' }); + }); +});