Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 73ee68b6a2 fix(core): strip stale response item IDs before serialization 2026-07-22 05:17:13 +00:00
5 changed files with 207 additions and 23 deletions
+30 -17
View File
@@ -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 {
@@ -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
}
@@ -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"
+134
View File
@@ -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<LanguageModelV3CallOptions>(
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<LanguageModelV3CallOptions>(
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<LanguageModelV3CallOptions>(
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
@@ -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: [
{