diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7932d9dc..d23a2530 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed custom Anthropic-compatible model aliases for adaptive-thinking Claude models by adding `compat.forceAdaptiveThinking` model metadata and moving built-in adaptive-thinking selection out of provider id substring checks ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)). - Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard. - Fixed Amazon Bedrock provider loading under strict package managers by declaring its direct `@smithy/node-http-handler` dependency ([#4842](https://github.com/earendil-works/pi/issues/4842)). - Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)). diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 0c025c68..e2693a3b 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -178,6 +178,21 @@ function isGoogleThinkingApi(model: Model): boolean { return model.api === "google-generative-ai" || model.api === "google-vertex"; } +function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { + return ( + modelId.includes("opus-4-6") || + modelId.includes("opus-4.6") || + modelId.includes("opus-4-7") || + modelId.includes("opus-4.7") || + modelId.includes("sonnet-4-6") || + modelId.includes("sonnet-4.6") + ); +} + +function mergeAnthropicMessagesCompat(model: Model, compat: AnthropicMessagesCompat): void { + model.compat = { ...(model.compat as AnthropicMessagesCompat | undefined), ...compat }; +} + function isGemini3ProModel(modelId: string): boolean { return /gemini-3(?:\.\d+)?-pro/.test(modelId.toLowerCase()); } @@ -216,6 +231,9 @@ function applyThinkingLevelMetadata(model: Model): void { if (model.id.includes("opus-4-7") || model.id.includes("opus-4.7")) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } + if (model.api === "anthropic-messages" && isAnthropicAdaptiveThinkingModel(model.id)) { + mergeAnthropicMessagesCompat(model, { forceAdaptiveThinking: true }); + } if (model.api === "openai-completions" && model.id.includes("deepseek-v4")) { mergeThinkingLevelMap(model, DEEPSEEK_V4_THINKING_LEVEL_MAP); } diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 782c4b97..9197fa12 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1723,6 +1723,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -1741,6 +1742,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -1827,6 +1829,7 @@ export const MODELS = { api: "anthropic-messages", provider: "anthropic", baseUrl: "https://api.anthropic.com", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -2826,6 +2829,7 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -2844,6 +2848,7 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -2896,6 +2901,7 @@ export const MODELS = { api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -3924,6 +3930,7 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -3943,6 +3950,7 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -3981,6 +3989,7 @@ export const MODELS = { provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -7226,6 +7235,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -7244,6 +7254,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -7296,6 +7307,7 @@ export const MODELS = { api: "anthropic-messages", provider: "opencode", baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { @@ -13369,6 +13381,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"max"}, input: ["text", "image"], @@ -13387,6 +13400,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], @@ -13439,6 +13453,7 @@ export const MODELS = { api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true}, reasoning: true, input: ["text", "image"], cost: { diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a594a479..ce306056 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -164,7 +164,9 @@ export type AnthropicThinkingDisplay = "summarized" | "omitted"; const FINE_GRAINED_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"; const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14"; -function getAnthropicCompat(model: Model<"anthropic-messages">): Required { +function getAnthropicCompat( + model: Model<"anthropic-messages">, +): Required> { // Auto-detect session affinity and cache control support from provider const isFireworks = model.provider === "fireworks"; const isCloudflareAiGatewayAnthropic = @@ -181,29 +183,34 @@ function getAnthropicCompat(model: Model<"anthropic-messages">): Required, sessionId?: string, ): { client: Anthropic; isOAuthToken: boolean } { - // Adaptive thinking models (Opus 4.6, Sonnet 4.6) have interleaved thinking built-in. - // The beta header is deprecated on Opus 4.6 and redundant on Sonnet 4.6, so skip it. - const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model.id); + // Adaptive thinking models have interleaved thinking built in, so skip the beta header. + const needsInterleavedBeta = interleavedThinking && model.compat?.forceAdaptiveThinking !== true; const betaFeatures: string[] = []; if (useFineGrainedToolStreamingBeta) { betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA); @@ -939,14 +942,13 @@ function buildParams( ); } - // Configure thinking mode: adaptive (Opus 4.6+ and Sonnet 4.6), - // budget-based (older models), or explicitly disabled. + // Configure thinking mode: adaptive, budget-based, or explicitly disabled. if (model.reasoning) { if (options?.thinkingEnabled) { // Default to "summarized" so Opus 4.7 and Mythos Preview behave like // older Claude 4 models (whose API default is also "summarized"). const display: AnthropicThinkingDisplay = options.thinkingDisplay ?? "summarized"; - if (supportsAdaptiveThinking(model.id)) { + if (model.compat?.forceAdaptiveThinking === true) { // Adaptive thinking: Claude decides when and how much to think. params.thinking = { type: "adaptive", display }; if (options.effort) { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 6fc703f1..5adc69c6 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -435,6 +435,16 @@ export interface AnthropicMessagesCompat { * Default: true. */ supportsCacheControlOnTools?: boolean; + /** + * Whether to force adaptive thinking (`thinking.type: "adaptive"` plus + * `output_config.effort`) regardless of the model id. Built-in models that + * require adaptive thinking set this in generated metadata. Custom + * Anthropic-compatible providers can set this to `true` for any model whose + * upstream requires the adaptive format. Set to `false` to + * opt out on overridden built-in models. + * Default: false. + */ + forceAdaptiveThinking?: boolean; } /** diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts new file mode 100644 index 00000000..f118401b --- /dev/null +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { getModels, getProviders } from "../src/models.ts"; +import type { Api, Model } from "../src/types.ts"; + +const EXPECTED_ADAPTIVE_THINKING_MODELS = [ + "anthropic/claude-opus-4-6", + "anthropic/claude-opus-4-7", + "anthropic/claude-sonnet-4-6", + "cloudflare-ai-gateway/claude-opus-4-6", + "cloudflare-ai-gateway/claude-opus-4-7", + "cloudflare-ai-gateway/claude-sonnet-4-6", + "github-copilot/claude-opus-4.6", + "github-copilot/claude-opus-4.7", + "github-copilot/claude-sonnet-4.6", + "opencode/claude-opus-4-6", + "opencode/claude-opus-4-7", + "opencode/claude-sonnet-4-6", + "vercel-ai-gateway/anthropic/claude-opus-4.6", + "vercel-ai-gateway/anthropic/claude-opus-4.7", + "vercel-ai-gateway/anthropic/claude-sonnet-4.6", +]; + +function getAllModels(): Model[] { + return getProviders().flatMap((provider) => getModels(provider) as Model[]); +} + +describe("Anthropic adaptive thinking model metadata", () => { + it("marks exactly the built-in Anthropic Messages models that use adaptive thinking", () => { + const flaggedModels = getAllModels() + .filter((model): model is Model<"anthropic-messages"> => model.api === "anthropic-messages") + .filter((model) => model.compat?.forceAdaptiveThinking === true) + .map((model) => `${model.provider}/${model.id}`) + .sort(); + + expect(flaggedModels).toEqual([...EXPECTED_ADAPTIVE_THINKING_MODELS].sort()); + }); +}); diff --git a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts new file mode 100644 index 00000000..fd18df96 --- /dev/null +++ b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; +import { streamSimple } from "../src/stream.ts"; +import type { Context, Model, SimpleStreamOptions } from "../src/types.ts"; + +interface AnthropicThinkingPayload { + thinking?: { type: string; budget_tokens?: number; display?: string }; + output_config?: { effort?: string }; +} + +class PayloadCaptured extends Error { + constructor() { + super("payload captured"); + this.name = "PayloadCaptured"; + } +} + +function makeContext(): Context { + return { + messages: [{ role: "user", content: "Hello", timestamp: Date.now() }], + }; +} + +function makeCustomModel(compat?: Model<"anthropic-messages">["compat"]): Model<"anthropic-messages"> { + return { + // Id intentionally does not match any built-in adaptive substring. This + // mirrors corporate proxy schemes such as `anthropic--claude-opus-latest`. + id: "vendor--claude-opus-latest", + name: "Vendor Proxy Opus Latest", + api: "anthropic-messages", + provider: "vendor-proxy", + baseUrl: "http://127.0.0.1:9", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 32000, + compat, + }; +} + +async function capturePayload( + model: Model<"anthropic-messages">, + options?: SimpleStreamOptions, +): Promise { + let capturedPayload: AnthropicThinkingPayload | undefined; + + const payloadCaptureModel: Model<"anthropic-messages"> = { + ...model, + baseUrl: "http://127.0.0.1:9", + }; + + const s = streamSimple(payloadCaptureModel, makeContext(), { + ...options, + apiKey: "fake-key", + onPayload: (payload) => { + capturedPayload = payload as AnthropicThinkingPayload; + throw new PayloadCaptured(); + }, + }); + + await s.result(); + + if (!capturedPayload) { + throw new Error("Expected payload to be captured before request failure"); + } + + return capturedPayload; +} + +describe("Anthropic forceAdaptiveThinking compat override", () => { + it("sends legacy thinking payload for custom model ids by default", async () => { + const payload = await capturePayload(makeCustomModel(), { reasoning: "medium" }); + + expect(payload.thinking?.type).toBe("enabled"); + expect(payload.output_config).toBeUndefined(); + }); + + it("sends adaptive thinking payload when compat.forceAdaptiveThinking is true", async () => { + const payload = await capturePayload(makeCustomModel({ forceAdaptiveThinking: true }), { reasoning: "medium" }); + + expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(payload.output_config).toEqual({ effort: "medium" }); + }); + + it("allows built-in adaptive models to opt out with compat.forceAdaptiveThinking false", async () => { + const model: Model<"anthropic-messages"> = { + ...getModel("anthropic", "claude-opus-4-7"), + compat: { forceAdaptiveThinking: false }, + }; + const payload = await capturePayload(model, { reasoning: "medium" }); + + expect(payload.thinking?.type).toBe("enabled"); + expect(payload.output_config).toBeUndefined(); + }); + + it("preserves thinking.type=disabled when reasoning is off regardless of override", async () => { + const payload = await capturePayload(makeCustomModel({ forceAdaptiveThinking: true })); + + expect(payload.thinking).toEqual({ type: "disabled" }); + expect(payload.output_config).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 43643b5f..2cf41048 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `compat.forceAdaptiveThinking` support to custom Anthropic-compatible model configuration docs and validation ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)). - Added a standard unified patch to edit tool result details for SDK consumers ([#4821](https://github.com/earendil-works/pi/issues/4821)). ### Changed diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index e7fecc2d..70ede4c3 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -230,6 +230,8 @@ models: [{ Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` instead for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content. +For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. + > Migration note: Mistral moved from `openai-completions` to `mistral-conversations`. > Use `mistral-conversations` for native Mistral models. > If you intentionally route Mistral-compatible/custom endpoints through `openai-completions`, set `compat` flags explicitly as needed. @@ -702,8 +704,9 @@ interface ProviderModelConfig { /** Custom headers for this specific model. */ headers?: Record; - /** OpenAI compatibility settings for openai-completions API. */ + /** Compatibility settings for the selected API. */ compat?: { + // openai-completions supportsStore?: boolean; supportsDeveloperRole?: boolean; supportsReasoningEffort?: boolean; @@ -715,6 +718,13 @@ interface ProviderModelConfig { requiresReasoningContentOnAssistantMessages?: boolean; thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template"; cacheControlFormat?: "anthropic"; + + // anthropic-messages + supportsEagerToolInputStreaming?: boolean; + supportsLongCacheRetention?: boolean; + sendSessionAffinityHeaders?: boolean; + supportsCacheControlOnTools?: boolean; + forceAdaptiveThinking?: boolean; }; } ``` diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 12636700..85fccaf3 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -315,10 +315,12 @@ Behavior notes: ## Anthropic Messages Compatibility -For providers or proxies using `api: "anthropic-messages"`, use `compat.supportsEagerToolInputStreaming` to control Anthropic fine-grained tool streaming compatibility. +For providers or proxies using `api: "anthropic-messages"`, use `compat` to control Anthropic-specific request compatibility. By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthropic-compatible backend rejects that field, set `supportsEagerToolInputStreaming` to `false`. Pi will omit `tools[].eager_input_streaming` and send the legacy `fine-grained-tool-streaming-2025-05-14` beta header for tool-enabled requests instead. +Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) instead of the legacy budget-based thinking payload. Built-in models set this automatically. For custom providers or aliases that route to those models, set `forceAdaptiveThinking` to `true`. + ```json { "providers": { @@ -328,7 +330,8 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro "apiKey": "ANTHROPIC_PROXY_KEY", "compat": { "supportsEagerToolInputStreaming": false, - "supportsLongCacheRetention": true + "supportsLongCacheRetention": true, + "forceAdaptiveThinking": true }, "models": [ { @@ -346,6 +349,9 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro |-------|-------------| | `supportsEagerToolInputStreaming` | Whether the provider accepts per-tool `eager_input_streaming`. Default: `true`. Set to `false` to omit that field and use the legacy fine-grained tool streaming beta header on tool-enabled requests. | | `supportsLongCacheRetention` | Whether the provider accepts Anthropic long cache retention (`cache_control.ttl: "1h"`) when cache retention is `long`. Default: `true`. | +| `sendSessionAffinityHeaders` | Whether to send `x-session-affinity` from the session id when caching is enabled. Default: auto-detected for known providers. | +| `supportsCacheControlOnTools` | Whether the provider accepts Anthropic-style `cache_control` markers on tool definitions. Default: `true`. | +| `forceAdaptiveThinking` | Whether to send adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) for this model. Built-in adaptive models set this automatically. Default: `false`. | ## OpenAI Compatibility diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 6e3a1031..712874d8 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -128,6 +128,9 @@ const OpenAIResponsesCompatSchema = Type.Object({ const AnthropicMessagesCompatSchema = Type.Object({ supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()), supportsLongCacheRetention: Type.Optional(Type.Boolean()), + sendSessionAffinityHeaders: Type.Optional(Type.Boolean()), + supportsCacheControlOnTools: Type.Optional(Type.Boolean()), + forceAdaptiveThinking: Type.Optional(Type.Boolean()), }); const ProviderCompatSchema = Type.Union([