Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(aikit): record thinkingLevel and providerThinkingLevel on assist…
…ant messages

Every assistant message now records the thinking level the caller requested
(`thinkingLevel`, required, "off" when none) and the provider-native value the
request carried (`providerThinkingLevel`, optional).

The native value is read off the exact request body: the `start-step` part's
body for streamed requests, and `APICallError.requestBodyValues` for rejected
ones. Reading the options instead would record efforts the adapter drops, such
as OpenAI efforts for model ids the AI SDK does not know.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  • Loading branch information
sanchitrk and claude committed Oct 8, 2026
commit fc01a3b13042ed54388aea35e0db132d59540351
22 changes: 20 additions & 2 deletions packages/aikit/src/llm/stream.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { streamText, type TextStreamPart, type ToolSet } from "ai";
import { APICallError } from "@ai-sdk/provider";
import { RetryError, streamText, type TextStreamPart, type ToolSet } from "ai";
import * as Message from "../message/message.ts";
import * as Model from "../model/model.ts";
import { AssistantMessageEventStream } from "../utils/eventstream.ts";
Expand Down Expand Up @@ -318,6 +319,9 @@ function handlePart(
case "tool-call":
finalizeToolCall(output, part, stream);
break;
case "start-step":
stampProviderThinkingLevel(output, part.request.body);
break;
case "finish-step":
output.responseId ||= part.response.id;
if (part.response.modelId && part.response.modelId !== model.id)
Expand All @@ -339,6 +343,17 @@ function handlePart(
}
}

function stampProviderThinkingLevel(output: Message.AssistantMessage, body: unknown): void {
const level = Thinking.providerThinkingLevel(body);
if (level !== undefined) output.providerThinkingLevel = level;
}

/** A request the provider refused never streams a `start-step`; its body travels on the error. */
function rejectedRequestBody(error: unknown): unknown {
if (RetryError.isInstance(error)) return rejectedRequestBody(error.lastError);
return APICallError.isInstance(error) ? error.requestBodyValues : undefined;
}

function resolvePricing(model: Model.Info, pricing: Pricing): number {
return Pricing.serviceTierCostMultiplier(
model,
Expand Down Expand Up @@ -376,7 +391,7 @@ export const stream: Protocol.StreamFunction<Model.KnownProviderEnum, typeof Opt
const runtimeOptions = applyDefaultMaxTokens(model, (options ?? {}) as RuntimeOptions);

void (async () => {
const output = createAssistantMessage(model);
const output = createAssistantMessage(model, runtimeOptions.reasoning ?? "off");
try {
const plan = Thinking.resolvePlan(model, context, runtimeOptions);
const languageModel = await resolveAISDKLanguageModel(model, runtimeOptions, plan.budget);
Expand Down Expand Up @@ -404,6 +419,8 @@ export const stream: Protocol.StreamFunction<Model.KnownProviderEnum, typeof Opt
maxOutputTokens: resolveMaxOutputTokens(model, plan),
temperature: runtimeOptions.temperature,
providerOptions,
// The exact body is where `providerThinkingLevel` is read from.
include: { requestBody: true },
abortSignal: runtimeOptions.signal,
timeout: runtimeOptions.timeoutMs,
maxRetries: runtimeOptions.maxRetries,
Expand Down Expand Up @@ -445,6 +462,7 @@ export const stream: Protocol.StreamFunction<Model.KnownProviderEnum, typeof Opt
output.time.completed = Date.now();
// Only the caller's signal means "stop". An abort from inside the SDK, such as its
// `timeoutMs` timer, is a failure the caller did not ask for.
stampProviderThinkingLevel(output, rejectedRequestBody(error));
output.stopReason = runtimeOptions.signal?.aborted ? "aborted" : "error";
if (output.stopReason === "aborted") {
output.errorMessage = formatThrownError(error);
Expand Down
22 changes: 22 additions & 0 deletions packages/aikit/src/llm/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,26 @@ export function reasoningProviderOptions(model: Model.Info, plan: Plan): Provide
return {};
}

/**
* The provider-native thinking value a request carried, read off the exact body the
* adapter sent. Adapters drop efforts they believe a model rejects, so the options
* aikit hands them can say more than the wire did.
*/
export function providerThinkingLevel(body: unknown): string | undefined {
const value =
field(body, "reasoning", "effort") ??
field(body, "reasoning_effort") ??
field(body, "generationConfig", "thinkingConfig", "thinkingLevel") ??
// With thinking disabled Anthropic's adapter adds its own default effort, which says nothing about thinking.
(field(body, "thinking", "type") === "disabled" ? undefined : field(body, "output_config", "effort"));
return typeof value === "string" ? value : undefined;
}

function field(value: unknown, ...keys: string[]): unknown {
return keys.reduce<unknown>(
(at, key) => (typeof at === "object" && at !== null ? (at as Record<string, unknown>)[key] : undefined),
value,
);
}

export * as Thinking from "./thinking.ts";
6 changes: 5 additions & 1 deletion packages/aikit/src/llm/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ import { sanitizeSurrogates } from "../utils/sanitize.ts";
type ToolCallPart = Extract<Message.AssistantMessage["parts"][number], { type: "toolCall" }>;
type TerminalToolCall = Exclude<ToolCallPart, Message.ToolCallPendingPart | Message.ToolCallRunningPart>;

export function createAssistantMessage(model: Model.Info): Message.AssistantMessage {
export function createAssistantMessage(
model: Model.Info,
thinkingLevel: Model.ThinkingLevel,
): Message.AssistantMessage {
return Message.createAssistantMessage({
role: "assistant",
parts: [],
protocol: model.protocol,
provider: model.provider,
model: model.id,
thinkingLevel,
usage: {
input: 0,
output: 0,
Expand Down
4 changes: 4 additions & 0 deletions packages/aikit/src/message/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ export const AssistantMessageSchema = Type.Object({
protocol: Model.KnownProviderEnumSchema,
provider: Model.ProviderInfo,
model: Type.String(),
/** Thinking level the caller requested, before aikit fits it to the model. */
thinkingLevel: Model.ThinkingLevel,
/** Provider-native thinking value the request carried. Absent for budget-only or unmanaged reasoning. */
providerThinkingLevel: Type.Optional(Type.String()),
usage: UsageSchema,
stopReason: StopReasonSchema,
errorMessage: Type.Optional(Type.String()),
Expand Down
20 changes: 15 additions & 5 deletions packages/aikit/test/e2e/thinking.levels.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ async function expectLevelAccepted(model: StreamableModel, options: object) {

expect(response.stopReason, response.errorMessage).toBe("stop");
expect(getText(response)).toContain(String(a + b));
expect(response.thinkingLevel).toBe((options as { reasoning?: Model.ThinkingLevel }).reasoning ?? "off");
return response;
}

Expand All @@ -102,11 +103,13 @@ describeIfOpenAI.each(OPENAI_E2E_MODELS)("OpenAI thinking levels (%s)", (modelId
const { reasoning: _, ...off } = openaiOptions();
const response = await expectLevelAccepted(model, off);
expect(response.parts.some((part) => part.type === "thinking")).toBe(false);
expect(response.providerThinkingLevel).toBeDefined();
});

it.each(ACTIVE_LEVELS)("accepts reasoning %s", { retry: 2, timeout: 180_000 }, async (reasoning) => {
const model = await getOpenAIModel(modelId);
await expectLevelAccepted(model, openaiOptions({ reasoning }));
const response = await expectLevelAccepted(model, openaiOptions({ reasoning }));
expect(response.providerThinkingLevel).toBeDefined();
});
});

Expand All @@ -122,11 +125,13 @@ describeIfOpenAICodex.each(OPENAI_CODEX_E2E_MODELS)("OpenAI Codex thinking level
const { reasoning: _, ...off } = openaiCodexOptions();
const response = await expectLevelAccepted(model, off);
expect(response.parts.some((part) => part.type === "thinking")).toBe(false);
expect(response.providerThinkingLevel).toBeDefined();
});

it.each(ACTIVE_LEVELS)("accepts reasoning %s", { retry: 2, timeout: 180_000 }, async (reasoning) => {
const model = await getOpenAICodexModel(modelId);
await expectLevelAccepted(model, openaiCodexOptions({ reasoning }));
const response = await expectLevelAccepted(model, openaiCodexOptions({ reasoning }));
expect(response.providerThinkingLevel).toBeDefined();
});
});

Expand All @@ -139,6 +144,7 @@ describeIfAnthropic.each(ANTHROPIC_E2E_MODELS)("Anthropic thinking levels (%s)",
it("answers when no level is requested", { retry: 2, timeout: 60_000 }, async () => {
const model = await getAnthropicModel(modelId);
const response = await expectLevelAccepted(model, anthropicOptions());
expect(response.providerThinkingLevel).toBeUndefined();
// Models that cannot disable thinking may still think; the request must not fail.
if (ANTHROPIC_LEVELS[modelId].includes("off")) {
expect(response.parts.some((part) => part.type === "thinking")).toBe(false);
Expand All @@ -147,7 +153,9 @@ describeIfAnthropic.each(ANTHROPIC_E2E_MODELS)("Anthropic thinking levels (%s)",

it.each(ACTIVE_LEVELS)("accepts reasoning %s", { retry: 2, timeout: 180_000 }, async (reasoning) => {
const model = await getAnthropicModel(modelId);
await expectLevelAccepted(model, anthropicOptions({ reasoning }));
const response = await expectLevelAccepted(model, anthropicOptions({ reasoning }));
if (model.compat?.forceAdaptiveThinking) expect(response.providerThinkingLevel).toBeDefined();
else expect(response.providerThinkingLevel).toBeUndefined();
});
});

Expand All @@ -160,11 +168,13 @@ describeIfOpenRouter.each(OPENROUTER_E2E_MODELS)("OpenRouter thinking levels (%s
});

it("answers when no level is requested", { retry: 2, timeout: 60_000 }, async () => {
await expectLevelAccepted(await getOpenRouterModel(modelId), openrouterOptions());
const response = await expectLevelAccepted(await getOpenRouterModel(modelId), openrouterOptions());
expect(response.providerThinkingLevel).toBeUndefined();
});

it.each(ACTIVE_LEVELS)("accepts reasoning %s", { retry: 2, timeout: 180_000 }, async (reasoning) => {
const model = await getOpenRouterModel(modelId);
await expectLevelAccepted(model, openrouterOptions({ reasoning }));
const response = await expectLevelAccepted(model, openrouterOptions({ reasoning }));
expect(response.providerThinkingLevel).toBeDefined();
});
});
2 changes: 2 additions & 0 deletions packages/aikit/test/estimate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function assistant(created: number, totalTokens: number): Message.AssistantMessa
protocol: model.protocol,
provider: model.provider,
model: model.id,
thinkingLevel: "off",
usage: makeUsage({ input: totalTokens, totalTokens }),
stopReason: "stop",
time: { created, completed: created },
Expand Down Expand Up @@ -51,6 +52,7 @@ describe("estimateMessageTokens", () => {
protocol: model.protocol,
provider: model.provider,
model: model.id,
thinkingLevel: "off",
usage: makeUsage(),
stopReason: "stop",
time: { created: 1, completed: 2 },
Expand Down
1 change: 1 addition & 0 deletions packages/aikit/test/thinking.provideroptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ describe("provider thinking-budget floor", () => {
protocol: anthropic.protocol,
provider: anthropic.provider,
model: anthropic.id,
thinkingLevel: "off",
usage: makeUsage({ input: 198_000, totalTokens: 198_000 }),
stopReason: "stop",
time: { created: 1, completed: 2 },
Expand Down
117 changes: 110 additions & 7 deletions packages/aikit/test/thinking.wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ async function capture(model: ReturnType<typeof makeModel>, options: RuntimeOpti
headers: { "content-type": "application/json" },
});
};
await stream(
const message = await stream(
model,
{ messages: [makeUserMessage("hello")] },
{
Expand All @@ -23,9 +23,11 @@ async function capture(model: ReturnType<typeof makeModel>, options: RuntimeOpti
},
).result();
expect(body).toBeDefined();
return body;
return { body, message };
}

const captureBody = async (...args: Parameters<typeof capture>) => (await capture(...args)).body;

const google = (overrides: Parameters<typeof makeModel>[0] = {}) =>
makeModel({
...makeGeneratedModel(overrides.id ?? "gemini-2.5-pro"),
Expand All @@ -38,7 +40,7 @@ const google = (overrides: Parameters<typeof makeModel>[0] = {}) =>

describe("thinking wire budgets", () => {
it("adds Google's family budget to the requested answer allowance", async () => {
expect(await capture(google(), { reasoning: "high", maxTokens: 4_096 })).toMatchObject({
expect(await captureBody(google(), { reasoning: "high", maxTokens: 4_096 })).toMatchObject({
generationConfig: {
maxOutputTokens: 36_864,
thinkingConfig: { thinkingBudget: 32_768, includeThoughts: true },
Expand All @@ -51,7 +53,7 @@ describe("thinking wire budgets", () => {
async (thinkingBudgets) => {
// hello = 2 estimated tokens; 10,000 - 2 - 4,096 = 5,902.
expect(
await capture(google({ contextWindow: 10_000 }), {
await captureBody(google({ contextWindow: 10_000 }), {
reasoning: "high",
maxTokens: 4_096,
...(thinkingBudgets ? { thinkingBudgets } : {}),
Expand All @@ -66,13 +68,15 @@ describe("thinking wire budgets", () => {
);

it("fits Google's budget under the model ceiling", async () => {
expect(await capture(google({ maxTokens: 8_192 }), { reasoning: "high" })).toMatchObject({
expect(await captureBody(google({ maxTokens: 8_192 }), { reasoning: "high" })).toMatchObject({
generationConfig: { maxOutputTokens: 8_192, thinkingConfig: { thinkingBudget: 7_168 } },
});
});

it("preserves Google's dynamic sentinel without subtracting it from the ceiling", async () => {
expect(await capture(google({ id: "gemini-unknown" }), { reasoning: "high", maxTokens: 4_096 })).toMatchObject({
expect(
await captureBody(google({ id: "gemini-unknown" }), { reasoning: "high", maxTokens: 4_096 }),
).toMatchObject({
generationConfig: { maxOutputTokens: 4_096, thinkingConfig: { thinkingBudget: -1 } },
});
});
Expand All @@ -84,9 +88,108 @@ describe("thinking wire budgets", () => {
reasoning: true,
maxTokens: 64_000,
});
expect(await capture(model, { reasoning: "high", maxTokens: 4_096 })).toMatchObject({
expect(await captureBody(model, { reasoning: "high", maxTokens: 4_096 })).toMatchObject({
max_tokens: 20_480,
thinking: { type: "enabled", budget_tokens: 16_384 },
});
});
});

type Wire = Record<string, unknown>;
const path = (value: unknown, ...keys: string[]): unknown =>
keys.reduce<unknown>((at, key) => (typeof at === "object" && at !== null ? (at as Wire)[key] : undefined), value);

const openai = makeModel({ id: "gpt-5.5", protocol: "openai", npm: "@ai-sdk/openai", reasoning: true });
const adaptiveClaude = makeModel({
id: "claude-opus-5-5",
npm: "@ai-sdk/anthropic",
reasoning: true,
maxTokens: 64_000,
compat: { forceAdaptiveThinking: true },
});
const budgetClaude = makeModel({
id: "claude-sonnet-4-5",
npm: "@ai-sdk/anthropic",
reasoning: true,
maxTokens: 64_000,
});
const gemini3 = google({ id: "gemini-3-pro", compat: { supportsThinkingLevel: true } });
const openrouter = makeModel({
id: "z-ai/glm-5.3-flash",
protocol: "openrouter",
npm: "@openrouter/ai-sdk-provider",
providerOptionsKey: "openrouter",
reasoning: true,
});

/*
* `providerThinkingLevel` must be the effort the request carried, so every case
* reads it back off the captured wire body rather than restating the mapping.
*/
describe("thinking level bookkeeping", () => {
it.each([
["OpenAI", openai, ["reasoning", "effort"]],
["Anthropic adaptive", adaptiveClaude, ["output_config", "effort"]],
["Gemini 3", gemini3, ["generationConfig", "thinkingConfig", "thinkingLevel"]],
["OpenRouter", openrouter, ["reasoning", "effort"]],
] as const)("records the effort %s was sent", async (_name, model, effort) => {
for (const reasoning of ["minimal", "low", "medium", "high"] as const) {
const { body, message } = await capture(model, { reasoning });
expect(message.thinkingLevel).toBe(reasoning);
expect(message.providerThinkingLevel).toBeDefined();
expect(message.providerThinkingLevel).toBe(path(body, ...effort));
}
});

it("records the requested level beside the clamped effort", async () => {
const { body, message } = await capture(adaptiveClaude, { reasoning: "minimal" });
expect(path(body, "output_config", "effort")).toBe("low");
expect(message).toMatchObject({ thinkingLevel: "minimal", providerThinkingLevel: "low" });
});

it("records off, and the provider's own off, when no level is requested", async () => {
const { body, message } = await capture(openai, {});
expect(path(body, "reasoning", "effort")).toBe("none");
expect(message).toMatchObject({ thinkingLevel: "off", providerThinkingLevel: "none" });
});

it.each([
["a token budget", budgetClaude, { reasoning: "high" }],
["disabled thinking", adaptiveClaude, {}],
["OpenRouter's pass-through off", openrouter, {}],
] as const)("records no native level for %s", async (_name, model, options) => {
const { body, message } = await capture(model, options);
expect(path(body, "output_config", "effort") ?? path(body, "reasoning", "effort")).toBeUndefined();
expect(message.providerThinkingLevel).toBeUndefined();
expect(message.thinkingLevel).toBe("reasoning" in options ? options.reasoning : "off");
});

it.each([
["an OpenAI effort", openai, { openai: { reasoningEffort: "low" } }, ["reasoning", "effort"], "low"],
[
"an effort on a budgeted Claude",
budgetClaude,
{ anthropic: { effort: "max" } },
["output_config", "effort"],
"max",
],
] as const)("records %s override as what was sent", async (_name, model, providerOptions, effort, sent) => {
const { body, message } = await capture(model, { reasoning: "high", providerOptions });
expect(path(body, ...effort)).toBe(sent);
expect(message).toMatchObject({ thinkingLevel: "high", providerThinkingLevel: sent });
});

it("records nothing when the adapter drops the effort for a model it does not know", async () => {
const unknown = makeModel({ id: "custom-reasoner", protocol: "openai", npm: "@ai-sdk/openai", reasoning: true });
const { body, message } = await capture(unknown, { reasoning: "high" });
expect(path(body, "reasoning")).toBeUndefined();
expect(message).toMatchObject({ thinkingLevel: "high" });
expect(message.providerThinkingLevel).toBeUndefined();
});

it("records no native level when thinking is disabled, whatever effort the adapter sends", async () => {
const { body, message } = await capture(adaptiveClaude, { providerOptions: { anthropic: { effort: "max" } } });
expect(path(body, "thinking", "type")).toBe("disabled");
expect(message.providerThinkingLevel).toBeUndefined();
});
});
1 change: 1 addition & 0 deletions packages/aikit/test/utils/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function makeAssistantMessage(
protocol: model.protocol,
provider: model.provider,
model: model.id,
thinkingLevel: "off",
usage: makeUsage(),
stopReason: "stop",
time: { created: Date.now(), completed: Date.now() },
Expand Down
1 change: 1 addition & 0 deletions packages/codework/test/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const message = (input: {
protocol: "openai",
provider: { id: "openai", name: "OpenAI", source: "custom", env: [] },
model: input.model,
thinkingLevel: "off",
usage: {
input: input.input,
output: input.output,
Expand Down
Loading