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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user