diff --git a/src/inference.ts b/src/inference.ts index 6349bca..8c0b4ed 100644 --- a/src/inference.ts +++ b/src/inference.ts @@ -46,6 +46,42 @@ interface RawUsage { }; } +/** + * The key the SDK namespaces provider options under, from a resolved model: + * "anthropic.messages" → "anthropic". It is the adapter family, not the id kloe + * knows the provider as, because that is what the adapter reads. + */ +export function providerFamily(model: LanguageModel): string { + const id = typeof model === "string" ? model : model.provider; + return id.split(".")[0]!; +} + +/** + * Drops reasoning blocks that a DIFFERENT provider signed. A signed thinking + * block is a token from one endpoint to itself: Anthropic verifies the + * signature it issued, and anyone else is handed a block it cannot account for. + * Switching a conversation's model mid-thread is exactly how history comes to + * hold foreign blocks, and the endpoint answers the replay with a 400 whose + * message ("Invalid input") names nothing. + */ +export function dropForeignReasoning(messages: ModelMessage[], family: string): ModelMessage[] { + const out: ModelMessage[] = []; + for (const m of messages) { + if (m.role !== "assistant" || !Array.isArray(m.content)) { + out.push(m); + continue; + } + const kept = m.content.filter( + (p) => p.type !== "reasoning" || Object.hasOwn(p.providerOptions ?? {}, family), + ); + if (kept.length === m.content.length) out.push(m); + // An assistant turn that was nothing but a foreign thinking block has + // nothing left to say; an empty message is itself a 400. + else if (kept.length > 0) out.push({ ...m, content: kept } as ModelMessage); + } + return out; +} + function finite(n: number | undefined): number { return Number.isFinite(n) ? n! : 0; } @@ -477,7 +513,7 @@ export async function* run(messages: ModelMessage[], opts: RunOptions): AsyncGen const result = streamText({ model, system, - messages, + messages: dropForeignReasoning(messages, providerFamily(model)), temperature: opts.temperature ?? 0.7, abortSignal: opts.abortSignal, ...(maxOutputTokens ? { maxOutputTokens } : {}), diff --git a/tests/inference.test.ts b/tests/inference.test.ts index d1e1b68..ff91224 100644 --- a/tests/inference.test.ts +++ b/tests/inference.test.ts @@ -1,7 +1,13 @@ import { expect, test } from "bun:test"; import type { ModelMessage } from "ai"; import { Catalog } from "../src/catalog"; -import { effortFor, promptChars, setRegistry, usageFor } from "../src/inference"; +import { + dropForeignReasoning, + effortFor, + promptChars, + setRegistry, + usageFor, +} from "../src/inference"; import { ProviderRegistry } from "../src/providers"; test("context occupancy comes from the final step, not the summed steps", () => { @@ -135,3 +141,36 @@ test("a reasoning level the model doesn't offer is dropped, not sent", () => { expect(effortFor("acme/plain", "high")).toBeNull(); // offers none at all expect(effortFor("acme/thinker", undefined)).toBeNull(); }); + +test("a thinking block another provider signed is dropped, not replayed", () => { + // Switching a conversation's model mid-thread leaves Anthropic-signed blocks + // in the history; sending them to OpenAI is a 400 that names nothing. + const messages: ModelMessage[] = [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking", providerOptions: { anthropic: { signature: "s" } } }, + { type: "text", text: "hello" }, + ], + }, + ]; + const forOpenAI = dropForeignReasoning(messages, "openai"); + expect(forOpenAI[1]!.content).toEqual([{ type: "text", text: "hello" }]); + // Back on the provider that signed it, the block is echoed verbatim. + expect(dropForeignReasoning(messages, "anthropic")).toEqual(messages); +}); + +test("an assistant turn that was only a foreign thinking block is dropped whole", () => { + const messages: ModelMessage[] = [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "t", providerOptions: { anthropic: { signature: "s" } } }, + ], + }, + ]; + // An empty assistant message is itself a 400, so the turn goes with it. + expect(dropForeignReasoning(messages, "openai")).toEqual([messages[0]!]); +});