diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 6d749b8526..e22a909f0a 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -129,20 +129,6 @@ function prepareOptions(model: ModelV2.Info, pkg: string) { if (abortSignals.length === 1) opts.signal = abortSignals[0] if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals) - if ( - (pkg === "@ai-sdk/openai" || pkg === "@ai-sdk/azure" || pkg === "@ai-sdk/amazon-bedrock/mantle") && - opts.body && - opts.method === "POST" - ) { - const body = JSON.parse(opts.body as string) - if (body.store !== true && Array.isArray(body.input)) { - for (const item of body.input) { - if ("id" in item) delete item.id - } - opts.body = JSON.stringify(body) - } - } - if (typeof opts.body === "string" && model.body !== undefined) { const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(opts.body)) if (Schema.is(Schema.Record(Schema.String, Schema.Json))(decoded)) { @@ -337,7 +323,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { }, body: { schema: Schema.Unknown, - from: (request) => Effect.succeed(callOptions(request)), + from: (request) => Effect.succeed(callOptions(request, info)), }, with: () => route, model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }), @@ -411,8 +397,11 @@ function mapBodyToProviderOptions(model: ModelV2.Info, packageName: string) { } } -function callOptions(request: LLMRequest): LanguageModelV3CallOptions { - return { +function callOptions(request: LLMRequest, model: ModelV2.Info): LanguageModelV3CallOptions { + const packageName = ProviderV2.isAISDK(model.package) ? ProviderV2.packageName(model.package) : undefined + const optionKey = providerOptionKey(packageName, model.providerID) + const store = request.providerOptions?.[optionKey]?.store + const options: LanguageModelV3CallOptions = { prompt: prompt(request), maxOutputTokens: request.generation?.maxTokens ?? request.model.route.defaults.limits?.output, temperature: request.generation?.temperature, @@ -428,6 +417,30 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions { headers: request.http?.headers, providerOptions: providerOptions(request.providerOptions), } + if ( + store !== true && + packageName !== undefined && + ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/github-copilot"].includes( + packageName, + ) + ) { + options.prompt = options.prompt.map((message) => { + if (!Array.isArray(message.content)) return message + return { + ...message, + content: message.content.map((part) => { + if (!("providerOptions" in part)) return part + const providerOptions = part.providerOptions + const metadata = providerOptions?.[optionKey] + if (metadata === undefined || !("itemId" in metadata)) return part + const next = { ...metadata } + delete next.itemId + return { ...part, providerOptions: { ...providerOptions, [optionKey]: next } } + }), + } as LanguageModelV3Message + }) + } + return options } function prompt(request: LLMRequest): LanguageModelV3Prompt { diff --git a/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts index 1e4f86d933..80aea89728 100644 --- a/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts +++ b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts @@ -237,10 +237,19 @@ export async function convertToOpenAIResponsesInput({ } } } else { - warnings.push({ - type: "other", - message: `Non-OpenAI reasoning parts are not supported. Skipping reasoning part: ${JSON.stringify(part)}.`, - }) + const encryptedContent = providerOptions?.reasoningEncryptedContent + if (encryptedContent != null) { + input.push({ + type: "reasoning", + encrypted_content: encryptedContent, + summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [], + }) + } else { + warnings.push({ + type: "other", + message: `Non-OpenAI reasoning parts are not supported. Skipping reasoning part: ${JSON.stringify(part)}.`, + }) + } } break } diff --git a/packages/core/src/github-copilot/responses/openai-responses-api-types.ts b/packages/core/src/github-copilot/responses/openai-responses-api-types.ts index dfdd066750..56d44de884 100644 --- a/packages/core/src/github-copilot/responses/openai-responses-api-types.ts +++ b/packages/core/src/github-copilot/responses/openai-responses-api-types.ts @@ -205,7 +205,7 @@ export type OpenAIResponsesTool = export type OpenAIResponsesReasoning = { type: "reasoning" - id: string + id?: string encrypted_content?: string | null summary: Array<{ type: "summary_text" diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 0bc16e8f59..d15c1c9fc4 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -270,6 +270,140 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () => }), ) +it.effect("strips stale Azure item IDs before stateless AI SDK serialization", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const messages = [ + Message.assistant([ + { + type: "reasoning" as const, + text: "Think", + providerMetadata: { + azure: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, + }, + }, + { + type: "tool-call" as const, + id: "call_1", + name: "lookup", + input: { query: "Effect" }, + providerMetadata: { azure: { itemId: "fc_123", otherOption: "value" } }, + }, + ]), + ] + const resolved = yield* aisdk.model(model("@ai-sdk/azure", { store: false })) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: resolved, + messages, + }), + ) + + expect(prepared.body.prompt).toEqual([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Think", + providerOptions: { azure: { reasoningEncryptedContent: "encrypted" } }, + }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "lookup", + input: { query: "Effect" }, + providerExecuted: undefined, + providerOptions: { azure: { otherOption: "value" } }, + }, + ], + }, + ]) + + const stateful = yield* aisdk.model(model("@ai-sdk/azure", { store: true })) + const statefulPrepared = yield* LLMClient.prepare( + LLM.request({ model: stateful, messages }), + ) + expect(statefulPrepared.body.prompt[0]).toMatchObject({ + role: "assistant", + content: [ + { providerOptions: { azure: { itemId: "rs_123" } } }, + { providerOptions: { azure: { itemId: "fc_123" } } }, + ], + }) + + const copilot = yield* aisdk.model(model("@ai-sdk/github-copilot", { store: false })) + const copilotPrepared = yield* LLMClient.prepare( + LLM.request({ + model: copilot, + messages: [ + Message.assistant([ + { + type: "reasoning", + text: "Think", + providerMetadata: { + copilot: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, + }, + }, + ]), + ], + }), + ) + expect(copilotPrepared.body.prompt[0]).toMatchObject({ + role: "assistant", + content: [ + { + providerOptions: { copilot: { reasoningEncryptedContent: "encrypted" } }, + }, + ], + }) + }), +) + +it.effect("does not corrupt AI SDK item reference payloads", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + let sent: unknown + const server = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + async fetch(request) { + sent = await request.json() + return new Response(null, { status: 200 }) + }, + }), + ), + (server) => Effect.sync(() => server.stop(true)), + ) + let wrapped: typeof fetch | undefined + yield* aisdk.hook.sdk((event) => { + wrapped = event.options.fetch + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + yield* aisdk.language(model("@ai-sdk/azure")) + yield* Effect.promise(() => + wrapped!(new URL("responses", server.url), { + method: "POST", + body: JSON.stringify({ + store: false, + input: [{ type: "item_reference", id: "fc_123" }], + }), + }), + ) + + expect(sent).toEqual({ + store: false, + input: [{ type: "item_reference", id: "fc_123" }], + }) + }), +) + it.effect("emits malformed AI SDK tool input without executing it", () => Effect.gen(function* () { const aisdk = yield* AISDK.Service diff --git a/packages/core/test/github-copilot/openai-responses-language-model.test.ts b/packages/core/test/github-copilot/openai-responses-language-model.test.ts index ab047d04eb..b7d4d0ffcd 100644 --- a/packages/core/test/github-copilot/openai-responses-language-model.test.ts +++ b/packages/core/test/github-copilot/openai-responses-language-model.test.ts @@ -163,7 +163,35 @@ describe("convertToOpenAIResponsesInput", () => { ]) }) - test("drops reasoning items with no copilot itemId and warns, as before", async () => { + test("preserves encrypted reasoning with no copilot itemId", async () => { + const { input, warnings } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { copilot: { reasoningEncryptedContent: "enc_1" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(warnings).toEqual([]) + expect(input).toEqual([ + { + type: "reasoning", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "thinking..." }], + }, + ]) + }) + + test("drops reasoning with neither a copilot itemId nor encrypted content", async () => { const { input, warnings } = await convertToOpenAIResponsesInput({ prompt: [ {