diff --git a/CLAVIA_PATCHES.md b/CLAVIA_PATCHES.md index 5a35771a9c9..c5bdf060228 100644 --- a/CLAVIA_PATCHES.md +++ b/CLAVIA_PATCHES.md @@ -16,6 +16,7 @@ This fork carries provider changes for Tardigrade. Consumers use upstream `effec | Completion evidence | OpenAI compatible | Reject a final sentinel without a provider finish reason. | | Reasoning replay | OpenAI compatible | Preserve the observed reasoning field and combine assistant reasoning, text, and calls during replay. | | Shared metadata types | OpenAI compatible | Align approval metadata with the OpenAI provider declarations. | +| Function call signatures | OpenAI, OpenAI compatible | Replay `encrypted_content` from function call items with reasoning items in received order when `store` is false. The compatible provider declares the type. | ## Response format wrapper @@ -144,3 +145,9 @@ Thinking configuration accepts `display` (`"summarized"` or `"omitted"`) for ada Responses report `usage.output_tokens_details.thinking_tokens`. The adapter maps it to `outputTokens.reasoning` for generated and streamed responses. A later `message_delta` without the detail keeps the earlier count. The fields are added to the generated schemas and recorded as a spec patch in `codegen.yml`. `packages/ai/anthropic/test/AnthropicLanguageModel.test.ts` checks configuration decoding, the request body for both thinking types, and reasoning usage with and without the detail. + +## Function call signatures + +Some providers that serve reasoning models over the Responses protocol return opaque reasoning state as `encrypted_content` on `function_call` items. A stream can send it only on `response.output_item.done`, after `response.function_call_arguments.done`. Streamed function calls are therefore emitted on item completion, with the completed item's `encrypted_content` in `metadata.openai.encryptedContent`. A call whose arguments completed without an item completion is emitted at the terminal response event, before the finish part, in output order. Replay sends the value back on the `function_call` input item, after the preceding reasoning items. + +`packages/ai/openai/test/OpenAiLanguageModel.test.ts` covers generated responses and unchanged OpenAI requests. `packages/ai/clavia/test/ProviderStreams.test.ts` covers a signature only on the completed item, interleaved reasoning replay, and calls without item completion. diff --git a/packages/ai/clavia/test/ProviderStreams.test.ts b/packages/ai/clavia/test/ProviderStreams.test.ts index f42fffe36ab..41a6d4071d9 100644 --- a/packages/ai/clavia/test/ProviderStreams.test.ts +++ b/packages/ai/clavia/test/ProviderStreams.test.ts @@ -245,6 +245,177 @@ for (const store of [false, true]) { })) } +it.effect("replays streamed OpenAI function call encrypted content in received order", () => + Effect.gen(function*() { + const reasoning = (id: string, encrypted_content: string) => ({ + type: "reasoning", + id, + encrypted_content, + summary: [{ type: "summary_text", text: "Check" }] + }) + const call = (id: string, encrypted_content?: string) => ({ + type: "function_call", + id: `fc_${id}`, + call_id: id, + name: "read", + arguments: JSON.stringify({ path: id }), + ...(encrypted_content === undefined ? {} : { encrypted_content }) + }) + const output = [ + reasoning("rs_a", "opaque-a"), + call("a", "signed-a"), + reasoning("rs_b", "opaque-b"), + call("b", "signed-b") + ] + const events = [ + { type: "response.output_item.added", output_index: 0, item: output[0] }, + { + type: "response.reasoning_summary_text.delta", + item_id: "rs_a", + output_index: 0, + summary_index: 0, + delta: "Check" + }, + { type: "response.output_item.done", output_index: 0, item: output[0] }, + // Signature on both the added and the completed item. + { type: "response.output_item.added", output_index: 1, item: output[1] }, + { + type: "response.function_call_arguments.done", + output_index: 1, + item_id: "fc_a", + arguments: "{\"path\":\"a\"}" + }, + { type: "response.output_item.done", output_index: 1, item: output[1] }, + { type: "response.output_item.added", output_index: 2, item: output[2] }, + { + type: "response.reasoning_summary_text.delta", + item_id: "rs_b", + output_index: 2, + summary_index: 0, + delta: "Check" + }, + { type: "response.output_item.done", output_index: 2, item: output[2] }, + // Signature only on the completed item, after argument completion. + { type: "response.output_item.added", output_index: 3, item: call("b") }, + { + type: "response.function_call_arguments.done", + output_index: 3, + item_id: "fc_b", + arguments: "{\"path\":\"b\"}" + }, + { type: "response.output_item.done", output_index: 3, item: output[3] }, + { + type: "response.completed", + response: { + id: "response", + created_at: 1, + model: "fixture", + status: "completed", + output, + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } + } + } + ] + const { stream, requests } = fixture("openai", "valid", false, "native", { + events, + config: { store: false, include: ["reasoning.encrypted_content"] } + }) + const parts = yield* Stream.runCollect(stream()) + assert.deepStrictEqual( + parts.flatMap((part) => part.type === "tool-call" ? [part.metadata.openai?.encryptedContent] : []), + ["signed-a", "signed-b"] + ) + const restored = Schema.decodeUnknownSync(Prompt.Prompt)( + JSON.parse(JSON.stringify(Prompt.fromResponseParts(parts))) + ) + const results = Prompt.make([{ + role: "tool", + content: ["a", "b"].map((id) => ({ type: "tool-result", id, name: "read", result: "result", isFailure: false })) + }]) + yield* Stream.runDrain(stream(Prompt.concat(restored, results))) + const replay = Schema.decodeUnknownSync(Schema.Struct({ input: Schema.Array(Schema.Unknown) }))(requests[1]).input + assert.deepStrictEqual(replay.slice(0, 4), [ + { + type: "reasoning", + id: "rs_a", + summary: [{ type: "summary_text", text: "Check" }], + encrypted_content: "opaque-a" + }, + { + type: "function_call", + name: "read", + call_id: "a", + arguments: "{\"path\":\"a\"}", + id: "fc_a", + encrypted_content: "signed-a" + }, + { + type: "reasoning", + id: "rs_b", + summary: [{ type: "summary_text", text: "Check" }], + encrypted_content: "opaque-b" + }, + { + type: "function_call", + name: "read", + call_id: "b", + arguments: "{\"path\":\"b\"}", + id: "fc_b", + encrypted_content: "signed-b" + } + ]) + })) + +it.effect("flushes streamed OpenAI function calls without item completion at the terminal event", () => + Effect.gen(function*() { + const call = (id: string, encrypted_content?: string) => ({ + type: "function_call", + id: `fc_${id}`, + call_id: id, + name: "read", + arguments: "", + ...(encrypted_content === undefined ? {} : { encrypted_content }) + }) + const events = [ + ...[call("a", "signed-a"), call("b")].flatMap((item, output_index) => [ + { type: "response.output_item.added", output_index, item }, + { + type: "response.function_call_arguments.done", + output_index, + item_id: item.id, + arguments: JSON.stringify({ path: item.call_id }) + } + ]), + { + type: "response.completed", + response: { + id: "response", + created_at: 1, + model: "fixture", + status: "completed", + output: [], + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } + } + } + ] + const { stream } = fixture("openai", "valid", false, "native", { events }) + const parts = yield* Stream.runCollect(stream()) + assert.deepStrictEqual( + parts.flatMap((part): Array => + part.type === "tool-call" + ? [{ id: part.id, params: part.params, metadata: part.metadata.openai }] + : part.type === "finish" + ? [part.reason] + : [] + ), + [ + { id: "a", params: { path: "a" }, metadata: { itemId: "fc_a", encryptedContent: "signed-a" } }, + { id: "b", params: { path: "b" }, metadata: { itemId: "fc_b" } }, + "tool-calls" + ] + ) + })) + it.effect("rejects multiple compatible choices before sending a request", () => Effect.gen(function*() { const { stream, requests } = fixture("compat", "valid", false, "reasoning", { config: { n: 2 } }) diff --git a/packages/ai/openai-compat/src/OpenAiLanguageModel.ts b/packages/ai/openai-compat/src/OpenAiLanguageModel.ts index 3913048e3fc..5f5c9d49d9e 100644 --- a/packages/ai/openai-compat/src/OpenAiLanguageModel.ts +++ b/packages/ai/openai-compat/src/OpenAiLanguageModel.ts @@ -209,6 +209,11 @@ declare module "effect/unstable/ai/Prompt" { * The status to send for the tool-call item. */ readonly status?: MessageStatus | null + /** + * Opaque reasoning state returned on a Responses function call item. + * Declared to match the OpenAI provider declarations. + */ + readonly encryptedContent?: string | null } | null } @@ -436,6 +441,11 @@ declare module "effect/unstable/ai/Response" { * The OpenAI item ID associated with the tool call. */ readonly itemId?: string | null + /** + * Opaque reasoning state returned on a Responses function call item. + * Declared to match the OpenAI provider declarations. + */ + readonly encryptedContent?: string | null } | null } diff --git a/packages/ai/openai/src/OpenAiLanguageModel.ts b/packages/ai/openai/src/OpenAiLanguageModel.ts index 5764261cb32..778e5d863e3 100644 --- a/packages/ai/openai/src/OpenAiLanguageModel.ts +++ b/packages/ai/openai/src/OpenAiLanguageModel.ts @@ -201,6 +201,11 @@ declare module "effect/unstable/ai/Prompt" { * The ID of the approval request. */ readonly approvalRequestId?: string | null + /** + * Opaque reasoning state returned on the function call item by some + * Responses-compatible providers. It is replayed unchanged. + */ + readonly encryptedContent?: string | null } | null } @@ -436,6 +441,11 @@ declare module "effect/unstable/ai/Response" { * The OpenAI item ID associated with the tool call. */ readonly itemId?: string | null + /** + * Opaque reasoning state returned on the function call item, to be sent + * back in later requests. + */ + readonly encryptedContent?: string | null } | null } @@ -1074,13 +1084,16 @@ const prepareMessages = Effect.fnUntraced( break } + const encryptedContent = getEncryptedContent(part) + messages.push({ type: "function_call", name: toolName, call_id: part.id, arguments: JSON.stringify(part.params), ...(Predicate.isNotNull(id) ? { id } : {}), - ...(Predicate.isNotNull(status) ? { status } : {}) + ...(Predicate.isNotNull(status) ? { status } : {}), + ...(Predicate.isNotNull(encryptedContent) ? { encrypted_content: encryptedContent } : {}) }) break @@ -1423,7 +1436,12 @@ const makeResponse = Effect.fnUntraced( id: part.call_id, name: toolName, params, - metadata: { openai: makeItemIdMetadata(part.id) } + metadata: { + openai: { + ...makeItemIdMetadata(part.id), + ...makeEncryptedContentMetadata(part.encrypted_content) + } + } }) break } @@ -1771,7 +1789,11 @@ const makeStreamResponse = Effect.fnUntraced( readonly id: string readonly name: string readonly functionCall?: { - emitted: boolean + readonly encryptedContent: string | null | undefined + // Set by argument completion; the call is emitted on item completion + // or flushed at the terminal response event. + arguments?: string + itemId?: string } readonly applyPatch?: { hasDiff: boolean @@ -1819,6 +1841,73 @@ const makeStreamResponse = Effect.fnUntraced( ) as ReturnType | ReturnType | undefined let toolParseError: AiError.AiError | undefined + + const emitFunctionCall = Effect.fnUntraced(function*(parts: Array, call: { + readonly id: string + readonly name: string + readonly itemId: string | undefined + readonly arguments: string + readonly encryptedContent: string | null | undefined + }) { + hasToolCalls = true + + const parsed = yield* Effect.try({ + try: () => Tool.unsafeSecureJsonParse(call.arguments), + catch: (cause) => + AiError.make({ + module: "OpenAiLanguageModel", + method: "makeStreamResponse", + reason: new AiError.ToolParameterValidationError({ + toolName: call.name, + description: `Failed securely JSON parse tool parameters: ${cause}` + }) + }) + }).pipe(Effect.result) + if (parsed._tag === "Failure") { + toolParseError ??= parsed.failure + parts.push({ type: "tool-params-end", id: call.id }) + return + } + const params = yield* transformToolCallParams(options.tools, call.name, parsed.success) + + parts.push({ + type: "tool-params-end", + id: call.id + }) + + parts.push({ + type: "tool-call", + id: call.id, + name: call.name, + params, + metadata: { + openai: { + ...makeItemIdMetadata(call.itemId), + ...makeEncryptedContentMetadata(call.encryptedContent) + } + } + }) + }) + + // Emit calls whose arguments completed without a matching item completion, + // in output order, before the terminal finish part. + const flushFunctionCalls = Effect.fnUntraced(function*(parts: Array) { + const indexes = Object.keys(activeToolCalls).map(Number).sort((a, b) => a - b) + for (const index of indexes) { + const toolCall = activeToolCalls[index] + const functionCall = toolCall?.functionCall + if (Predicate.isUndefined(functionCall) || Predicate.isUndefined(functionCall.arguments)) continue + delete activeToolCalls[index] + yield* emitFunctionCall(parts, { + id: toolCall.id, + name: toolCall.name, + itemId: functionCall.itemId, + arguments: functionCall.arguments, + encryptedContent: functionCall.encryptedContent + }) + } + }) + return stream.pipe( Stream.mapEffect(Effect.fnUntraced(function*(event) { const parts: Array = [] @@ -1847,6 +1936,7 @@ const makeStreamResponse = Effect.fnUntraced( case "response.completed": case "response.incomplete": { + yield* flushFunctionCalls(parts) parts.push({ type: "finish", reason: InternalUtilities.resolveFinishReason( @@ -1861,6 +1951,7 @@ const makeStreamResponse = Effect.fnUntraced( } case "response.failed": { + yield* flushFunctionCalls(parts) if (event.response.error) { parts.push({ type: "error", error: event.response.error }) } @@ -1971,7 +2062,7 @@ const makeStreamResponse = Effect.fnUntraced( activeToolCalls[event.output_index] = { id: event.item.call_id, name: event.item.name, - functionCall: { emitted: false } + functionCall: { encryptedContent: event.item.encrypted_content } } parts.push({ type: "tool-params-start", @@ -2176,49 +2267,16 @@ const makeStreamResponse = Effect.fnUntraced( case "function_call": { const toolCall = activeToolCalls[event.output_index] - if (Predicate.isNotUndefined(toolCall?.functionCall?.emitted) && toolCall.functionCall.emitted) { - delete activeToolCalls[event.output_index] - break - } delete activeToolCalls[event.output_index] - hasToolCalls = true - - const toolName = event.item.name - const toolArgs = event.item.arguments - - const parsed = yield* Effect.try({ - try: () => Tool.unsafeSecureJsonParse(toolArgs), - catch: (cause) => - AiError.make({ - module: "OpenAiLanguageModel", - method: "makeStreamResponse", - reason: new AiError.ToolParameterValidationError({ - toolName, - description: `Failed securely JSON parse tool parameters: ${cause}` - }) - }) - }).pipe(Effect.result) - if (parsed._tag === "Failure") { - toolParseError ??= parsed.failure - parts.push({ type: "tool-params-end", id: event.item.call_id }) - - break - } - const toolParams = parsed.success - const params = yield* transformToolCallParams(options.tools, toolName, toolParams) - - parts.push({ - type: "tool-params-end", - id: event.item.call_id - }) - - parts.push({ - type: "tool-call", + // Some providers attach the reasoning signature only to the + // completed item, so the call is emitted here. + yield* emitFunctionCall(parts, { id: event.item.call_id, - name: toolName, - params, - metadata: { openai: makeItemIdMetadata(event.item.id) } + name: event.item.name, + itemId: event.item.id, + arguments: event.item.arguments, + encryptedContent: event.item.encrypted_content ?? toolCall?.functionCall?.encryptedContent }) break @@ -2491,48 +2549,10 @@ const makeStreamResponse = Effect.fnUntraced( } case "response.function_call_arguments.done": { - const toolCall = activeToolCalls[event.output_index] - if ( - Predicate.isNotUndefined(toolCall?.functionCall) && - !toolCall.functionCall.emitted - ) { - hasToolCalls = true - - const parsed = yield* Effect.try({ - try: () => Tool.unsafeSecureJsonParse(event.arguments), - catch: (cause) => - AiError.make({ - module: "OpenAiLanguageModel", - method: "makeStreamResponse", - reason: new AiError.ToolParameterValidationError({ - toolName: toolCall.name, - description: `Failed securely JSON parse tool parameters: ${cause}` - }) - }) - }).pipe(Effect.result) - if (parsed._tag === "Failure") { - toolParseError ??= parsed.failure - parts.push({ type: "tool-params-end", id: toolCall.id }) - toolCall.functionCall.emitted = true - break - } - const toolParams = parsed.success - const params = yield* transformToolCallParams(options.tools, toolCall.name, toolParams) - - parts.push({ - type: "tool-params-end", - id: toolCall.id - }) - - parts.push({ - type: "tool-call", - id: toolCall.id, - name: toolCall.name, - params, - metadata: { openai: makeItemIdMetadata(event.item_id) } - }) - - toolCall.functionCall.emitted = true + const functionCall = activeToolCalls[event.output_index]?.functionCall + if (Predicate.isNotUndefined(functionCall)) { + functionCall.arguments = event.arguments + functionCall.itemId = event.item_id } break } @@ -2990,7 +3010,7 @@ const getStatus = ( | Prompt.ToolResultPart ): typeof OpenAiSchema.MessageStatus.Encoded | null => part.options.openai?.status ?? null const getEncryptedContent = ( - part: Prompt.ReasoningPart + part: Prompt.ReasoningPart | Prompt.ToolCallPart ): string | null => part.options.openai?.encryptedContent ?? null const getImageDetail = (part: Prompt.FilePart): ImageDetail => part.options.openai?.imageDetail ?? "auto" diff --git a/packages/ai/openai/src/OpenAiSchema.ts b/packages/ai/openai/src/OpenAiSchema.ts index 073e562566b..aceb72f807e 100644 --- a/packages/ai/openai/src/OpenAiSchema.ts +++ b/packages/ai/openai/src/OpenAiSchema.ts @@ -326,7 +326,10 @@ const FunctionCall = Schema.Struct({ call_id: Schema.String, name: Schema.String, arguments: Schema.String, - status: Schema.optionalKey(MessageStatus) + status: Schema.optionalKey(MessageStatus), + // Some Responses-compatible providers attach the model's opaque reasoning + // state to function calls. It must be sent back unchanged. + encrypted_content: Schema.optionalKey(Schema.NullOr(Schema.String)) }) const FunctionCallOutput = Schema.Struct({ diff --git a/packages/ai/openai/test/OpenAiLanguageModel.test.ts b/packages/ai/openai/test/OpenAiLanguageModel.test.ts index 37014015dcf..ed048f2fd6a 100644 --- a/packages/ai/openai/test/OpenAiLanguageModel.test.ts +++ b/packages/ai/openai/test/OpenAiLanguageModel.test.ts @@ -2040,6 +2040,108 @@ describe("OpenAiLanguageModel", () => { strictEqual(body.temperature, 0.5) }).pipe(Effect.provide(makeTestLayer()))) }) + + describe("stateless reasoning replay", () => { + it.effect("replays reasoning and function call encrypted content for an explicitly configured model", () => + Effect.gen(function*() { + const model = OpenAiLanguageModel.model("vendor-reasoner", { + store: false, + include: ["reasoning.encrypted_content"] + }) + const first = yield* LanguageModel.generateText({ + prompt: "Use the tool", + toolkit: TestToolkit + }).pipe(Effect.provide(model)) + + const toolCall = first.content.find((part) => part.type === "tool-call") + deepStrictEqual(toolCall?.metadata.openai, { itemId: "fc_1", encryptedContent: "call-signature" }) + + yield* LanguageModel.generateText({ + prompt: Prompt.concat(Prompt.make("Use the tool"), Prompt.fromResponseParts(first.content)), + toolkit: TestToolkit + }).pipe(Effect.provide(model)) + + const requests = yield* MockHttpClient.requests + const firstBody = yield* getRequestBody(requests[0]) + const replayBody = yield* getRequestBody(requests[1]) + deepStrictEqual(firstBody.include, ["reasoning.encrypted_content"]) + deepStrictEqual(replayBody.include, ["reasoning.encrypted_content"]) + deepStrictEqual(replayBody.input.slice(1, 3), [ + { + type: "reasoning", + id: "rs_1", + summary: [{ type: "summary_text", text: "Plan" }], + encrypted_content: "reasoning-state" + }, + { + type: "function_call", + name: "TestTool", + call_id: "call_1", + arguments: "{\"input\":\"hello\"}", + id: "fc_1", + encrypted_content: "call-signature" + } + ]) + strictEqual(replayBody.input[3].type, "function_call_output") + strictEqual(replayBody.input[3].call_id, "call_1") + }).pipe( + Effect.provide([ + makeTestLayer({ + body: { + model: "vendor-reasoner" as any, + output: [ + makeReasoningOutput(["Plan"], { id: "rs_1", encrypted_content: "reasoning-state" }), + { + ...makeFunctionCall("TestTool", { input: "hello" }, { id: "fc_1", call_id: "call_1" }), + encrypted_content: "call-signature" + } as Generated.FunctionToolCall + ] + } + }), + TestToolkitLayer + ]) + )) + + for ( + const { model, include } of [ + { model: "gpt-4o-mini", include: undefined }, + { model: "gpt-5", include: ["reasoning.encrypted_content"] } + ] + ) { + it.effect(`keeps inferred includes and plain function calls for ${model}`, () => + Effect.gen(function*() { + yield* LanguageModel.generateText({ + prompt: Prompt.make([ + { role: "user", content: "Use the tool" }, + { + role: "assistant", + content: [ + Prompt.toolCallPart({ + id: "call_abc", + name: "TestTool", + params: { input: "test" }, + providerExecuted: false, + options: { openai: { itemId: "fc_abc" } } + }) + ] + } + ]), + toolkit: TestToolkit + }).pipe(Effect.provide(OpenAiLanguageModel.model(model, { store: false }))) + + const requests = yield* MockHttpClient.requests + const body = yield* getRequestBody(requests[0]) + deepStrictEqual(body.include, include) + deepStrictEqual(body.input.find((item: any) => item.type === "function_call"), { + type: "function_call", + name: "TestTool", + call_id: "call_abc", + arguments: "{\"input\":\"test\"}", + id: "fc_abc" + }) + }).pipe(Effect.provide([makeTestLayer({ body: { model: model as any } }), TestToolkitLayer]))) + } + }) }) // =============================================================================