From 41a29c83c89053e22acba696d8cd7a85e70bd3c3 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 6 Jul 2026 06:19:01 +0000 Subject: [PATCH] fix(providers): disabled strict tools on azure anthropic Azure Foundry Anthropic routes reject Anthropic structured-output strict tooling for Sonnet 5 utility requests. Detect Azure Anthropic hosts as strict-tool-incompatible, gate the structured-output beta when strict tools are disabled, and cover the utility header plus tool-schema contracts. Fixes #4679 --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/anthropic.ts | 18 +++- packages/ai/test/issue-4679-repro.test.ts | 104 ++++++++++++++++++++++ packages/catalog/CHANGELOG.md | 4 + packages/catalog/src/compat/anthropic.ts | 13 ++- 5 files changed, 132 insertions(+), 11 deletions(-) create mode 100644 packages/ai/test/issue-4679-repro.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d49636d82..b6c527af1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Azure Foundry Anthropic utility requests to omit the structured-output beta whenever strict tools are disabled, preventing `structured_outputs not supported in your workspace` failures for Sonnet 5 compaction ([#4679](https://github.com/can1357/oh-my-pi/issues/4679)). + ## [16.3.7] - 2026-07-05 ### Fixed diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index b4949d838..7bef77e49 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -127,12 +127,13 @@ export function buildBetaHeader(baseBetas: readonly string[], extraBetas: readon const midConversationSystemBeta = "mid-conversation-system-2026-04-07"; const contextManagementBeta = "context-management-2025-06-27"; +const structuredOutputsBeta = "structured-outputs-2025-12-15"; const claudeCodeUtilityBetaDefaults = [ "oauth-2025-04-20", "interleaved-thinking-2025-05-14", contextManagementBeta, "prompt-caching-scope-2026-01-05", - "structured-outputs-2025-12-15", + structuredOutputsBeta, ] as const; const claudeCodeAgentBetaDefaults = [ "claude-code-20250219", @@ -159,10 +160,12 @@ function buildClaudeCodeBetas( agentRequest: boolean, thinkingRequest: boolean, redactThinking: boolean, + disableStrictTools = false, ): readonly string[] { - if (!agentRequest && !redactThinking) return claudeCodeUtilityBetaDefaults; + if (!agentRequest && !redactThinking && !disableStrictTools) return claudeCodeUtilityBetaDefaults; const betas: string[] = []; for (const beta of agentRequest ? claudeCodeAgentBetaDefaults : claudeCodeUtilityBetaDefaults) { + if (disableStrictTools && beta === structuredOutputsBeta) continue; betas.push(beta); // Match CC's header order: redact-thinking immediately follows interleaved-thinking. if (redactThinking && beta === interleavedThinkingBeta) betas.push(redactThinkingBeta); @@ -1098,6 +1101,7 @@ export type AnthropicClientOptionsArgs = { hasTools?: boolean; thinkingEnabled?: boolean; thinkingDisplay?: AnthropicThinkingDisplay; + disableStrictTools?: boolean; fetch?: FetchImpl; claudeCodeSessionId?: string; }; @@ -1833,6 +1837,7 @@ const streamAnthropicOnce = ( thinkingDisplay: options?.thinkingDisplay, fetch: options?.fetch, claudeCodeSessionId: options?.sessionId ?? extractClaudeMetadataSessionId(options?.metadata?.user_id), + disableStrictTools, }); client = created.client; isOAuthToken = created.isOAuthToken; @@ -2648,8 +2653,10 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A thinkingDisplay, isOAuth, claudeCodeSessionId, + disableStrictTools: disableStrictToolsOverride, } = args; const compat = model.compat; + const disableStrictTools = disableStrictToolsOverride ?? compat.disableStrictTools; const needsInterleavedBeta = interleavedThinking && !model.thinking?.supportsDisplay; const needsFineGrainedToolStreamingBeta = hasTools && !compat.supportsEagerToolInputStreaming; const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey); @@ -2722,7 +2729,12 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway", claudeCodeSessionId, claudeCodeBetas: oauthToken - ? buildClaudeCodeBetas(hasTools || thinkingEnabled, thinkingEnabled, thinkingDisplay === "omitted") + ? buildClaudeCodeBetas( + hasTools || thinkingEnabled, + thinkingEnabled, + thinkingDisplay === "omitted", + disableStrictTools, + ) : [], }); diff --git a/packages/ai/test/issue-4679-repro.test.ts b/packages/ai/test/issue-4679-repro.test.ts new file mode 100644 index 000000000..2372bfc2d --- /dev/null +++ b/packages/ai/test/issue-4679-repro.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test"; +import { buildAnthropicClientOptions, streamAnthropic } from "@oh-my-pi/pi-ai/providers/anthropic"; +import type { Context, Model, ModelSpec, TJsonSchema, Tool } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +const STRUCTURED_OUTPUTS_BETA = "structured-outputs-2025-12-15"; + +const bashTool: Tool = { + name: "bash", + description: "run a bash command", + parameters: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + } satisfies TJsonSchema, +}; + +const toolContext: Context = { + systemPrompt: ["Stay concise."], + messages: [{ role: "user", content: "Hi", timestamp: 0 }], + tools: [bashTool], +}; + +function anthropicSpec(baseUrl: string): ModelSpec<"anthropic-messages"> { + return { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + api: "anthropic-messages", + provider: "anthropic", + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }; +} + +function buildOAuthUtilityBetaHeader(model: Model<"anthropic-messages">): string { + const options = buildAnthropicClientOptions({ + model, + apiKey: "oauth-token", + isOAuth: true, + hasTools: false, + thinkingEnabled: false, + }); + return options.defaultHeaders["anthropic-beta"] ?? ""; +} + +function abortedSignal(): AbortSignal { + const controller = new AbortController(); + controller.abort(); + return controller.signal; +} + +async function captureToolParams( + model: Model<"anthropic-messages">, +): Promise<{ tools?: Array<{ name: string; strict?: unknown }> }> { + const { promise, resolve } = Promise.withResolvers<{ tools?: Array<{ name: string; strict?: unknown }> }>(); + void streamAnthropic(model, toolContext, { + apiKey: "sk-ant-api-test", + isOAuth: false, + signal: abortedSignal(), + onPayload: payload => { + resolve(payload as { tools?: Array<{ name: string; strict?: unknown }> }); + return undefined; + }, + }); + return promise; +} + +describe("issue #4679 Azure Foundry Anthropic strict tools", () => { + it.each([ + ["inference", "https://example.inference.ai.azure.com/anthropic/v1"], + ["services", "https://example.services.ai.azure.com/anthropic/v1"], + ])("disables strict tools and omits structured-output beta for Azure Foundry %s routes", (_kind, baseUrl) => { + const model = buildModel(anthropicSpec(baseUrl)); + + expect(model.compat.disableStrictTools).toBe(true); + expect(buildOAuthUtilityBetaHeader(model)).not.toContain(STRUCTURED_OUTPUTS_BETA); + }); + + it("keeps structured-output beta on direct Anthropic OAuth utility headers", () => { + const model = buildModel(anthropicSpec("https://api.anthropic.com")); + + expect(model.compat.disableStrictTools).toBe(false); + expect(buildOAuthUtilityBetaHeader(model)).toContain(STRUCTURED_OUTPUTS_BETA); + }); + + it("omits strict tool schemas on Azure Foundry Anthropic requests without disabling direct Anthropic", async () => { + const azureParams = await captureToolParams( + buildModel(anthropicSpec("https://example.services.ai.azure.com/anthropic/v1")), + ); + const directParams = await captureToolParams(buildModel(anthropicSpec("https://api.anthropic.com"))); + + const azureBashTool = azureParams.tools?.find(tool => tool.name === "bash"); + const directBashTool = directParams.tools?.find(tool => tool.name === "bash"); + + expect(azureBashTool).toBeDefined(); + expect(azureBashTool?.strict).toBeUndefined(); + expect(directBashTool).toBeDefined(); + expect(directBashTool?.strict).toBe(true); + }); +}); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index ec9debe58..8f5be6e7b 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Detected Azure AI Inference / Foundry Anthropic routes as strict-tool-incompatible so resolved Anthropic compat disables strict tools before request construction ([#4679](https://github.com/can1357/oh-my-pi/issues/4679)). + ## [16.3.9] - 2026-07-06 ### Fixed diff --git a/packages/catalog/src/compat/anthropic.ts b/packages/catalog/src/compat/anthropic.ts index 7bc1777c0..fafb6cac9 100644 --- a/packages/catalog/src/compat/anthropic.ts +++ b/packages/catalog/src/compat/anthropic.ts @@ -99,18 +99,15 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res // (issue #4192). const isZenmux = modelMatchesHost(spec, "zenmux"); const requiresThinkingEnabled = modelMatchesHost(spec, "moonshotNative") && matchesKimiK27CodeFamily(spec); + const isVertex = isVertexAnthropicRoute(baseUrl); + const isBedrock = isBedrockAnthropicRoute(baseUrl); + const isAzure = isAzureAnthropicRoute(baseUrl); const signingEndpoint = - official || - isCopilot || - isZenmux || - isCloudflareAnthropicGateway(baseUrl) || - isVertexAnthropicRoute(baseUrl) || - isBedrockAnthropicRoute(baseUrl) || - isAzureAnthropicRoute(baseUrl); + official || isCopilot || isZenmux || isCloudflareAnthropicGateway(baseUrl) || isVertex || isBedrock || isAzure; const compat: ResolvedAnthropicCompat = { officialEndpoint: official, signingEndpoint, - disableStrictTools: false, + disableStrictTools: isAzure, disableAdaptiveThinking: false, supportsEagerToolInputStreaming: !isCopilot, // Long cache retention is only sent to the official API by default;