fix(core): strip stale response item IDs before serialization
This commit is contained in:
parent
23483ea013
commit
73ee68b6a2
5 changed files with 207 additions and 23 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue