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
This commit is contained in:
roboomp
2026-07-06 06:19:01 +00:00
parent 674cf01762
commit 41a29c83c8
5 changed files with 132 additions and 11 deletions
+4
View File
@@ -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
+15 -3
View File
@@ -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,
)
: [],
});
+104
View File
@@ -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);
});
});
+4
View File
@@ -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
+5 -8
View File
@@ -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;