Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand Down Expand Up @@ -1098,6 +1101,7 @@ export type AnthropicClientOptionsArgs = {
hasTools?: boolean;
thinkingEnabled?: boolean;
thinkingDisplay?: AnthropicThinkingDisplay;
disableStrictTools?: boolean;
fetch?: FetchImpl;
claudeCodeSessionId?: string;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
)
: [],
});

Expand Down
104 changes: 104 additions & 0 deletions packages/ai/test/issue-4679-repro.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions packages/catalog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 5 additions & 8 deletions packages/catalog/src/compat/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down