diff --git a/CHANGELOG.md b/CHANGELOG.md index 4323dac22bb..09a156bb456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf. ## 2026.4.5 ### Breaking diff --git a/extensions/anthropic/stream-wrappers.test.ts b/extensions/anthropic/stream-wrappers.test.ts index 3ea20c14ce6..f219b17604e 100644 --- a/extensions/anthropic/stream-wrappers.test.ts +++ b/extensions/anthropic/stream-wrappers.test.ts @@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { __testing, createAnthropicBetaHeadersWrapper, + createAnthropicFastModeWrapper, + createAnthropicServiceTierWrapper, wrapAnthropicProviderStream, } from "./stream-wrappers.js"; @@ -46,6 +48,33 @@ describe("anthropic stream wrappers", () => { expect(warn).not.toHaveBeenCalled(); }); + it("skips service_tier for OAuth token in composed stream chain", () => { + const captured: { headers?: Record; payload?: Record } = {}; + const base: StreamFn = (model, _context, options) => { + captured.headers = options?.headers; + const payload = {} as Record; + options?.onPayload?.(payload as never, model as never); + captured.payload = payload; + return {} as never; + }; + + const wrapped = wrapAnthropicProviderStream({ + streamFn: base, + modelId: "claude-sonnet-4-6", + extraParams: { context1m: true, serviceTier: "auto" }, + } as never); + + wrapped?.( + { provider: "anthropic", api: "anthropic-messages", id: "claude-sonnet-4-6" } as never, + {} as never, + { apiKey: "sk-ant-oat01-oauth-token" } as never, + ); + + expect(captured.headers?.["anthropic-beta"]).toContain(OAUTH_BETA); + expect(captured.headers?.["anthropic-beta"]).not.toContain(CONTEXT_1M_BETA); + expect(captured.payload?.service_tier).toBeUndefined(); + }); + it("composes the anthropic provider stream chain from extra params", () => { const captured: { headers?: Record; payload?: Record } = {}; const base: StreamFn = (model, _context, options) => { @@ -72,3 +101,118 @@ describe("anthropic stream wrappers", () => { expect(captured.payload).toMatchObject({ service_tier: "auto" }); }); }); + +describe("createAnthropicFastModeWrapper", () => { + function runFastModeWrapper(params: { + apiKey?: string; + provider?: string; + api?: string; + baseUrl?: string; + enabled?: boolean; + }): Record | undefined { + const captured: { payload?: Record } = {}; + const base: StreamFn = (_model, _context, options) => { + if (options?.onPayload) { + const payload: Record = {}; + options.onPayload(payload, _model); + captured.payload = payload; + } + return {} as never; + }; + + const wrapper = createAnthropicFastModeWrapper(base, params.enabled ?? true); + wrapper( + { + provider: params.provider ?? "anthropic", + api: params.api ?? "anthropic-messages", + baseUrl: params.baseUrl, + id: "claude-sonnet-4-6", + } as never, + {} as never, + { apiKey: params.apiKey } as never, + ); + return captured.payload; + } + + it("does not inject service_tier for OAuth token", () => { + const payload = runFastModeWrapper({ apiKey: "sk-ant-oat01-test-token" }); + expect(payload?.service_tier).toBeUndefined(); + }); + + it("injects service_tier for regular API keys", () => { + const payload = runFastModeWrapper({ apiKey: "sk-ant-api03-test-key" }); + expect(payload?.service_tier).toBe("auto"); + }); + + it("injects service_tier=standard_only when disabled for API keys", () => { + const payload = runFastModeWrapper({ apiKey: "sk-ant-api03-test-key", enabled: false }); + expect(payload?.service_tier).toBe("standard_only"); + }); + + it("does not inject service_tier for non-anthropic provider", () => { + const payload = runFastModeWrapper({ + apiKey: "sk-ant-api03-test-key", + provider: "openai", + api: "openai-completions", + }); + expect(payload?.service_tier).toBeUndefined(); + }); +}); + +describe("createAnthropicServiceTierWrapper", () => { + function runServiceTierWrapper(params: { + apiKey?: string; + provider?: string; + api?: string; + serviceTier?: "auto" | "standard_only"; + }): Record | undefined { + const captured: { payload?: Record } = {}; + const base: StreamFn = (_model, _context, options) => { + if (options?.onPayload) { + const payload: Record = {}; + options.onPayload(payload, _model); + captured.payload = payload; + } + return {} as never; + }; + + const wrapper = createAnthropicServiceTierWrapper(base, params.serviceTier ?? "auto"); + wrapper( + { + provider: params.provider ?? "anthropic", + api: params.api ?? "anthropic-messages", + id: "claude-sonnet-4-6", + } as never, + {} as never, + { apiKey: params.apiKey } as never, + ); + return captured.payload; + } + + it("does not inject service_tier for OAuth token", () => { + const payload = runServiceTierWrapper({ apiKey: "sk-ant-oat01-test-token" }); + expect(payload?.service_tier).toBeUndefined(); + }); + + it("injects service_tier for regular API keys", () => { + const payload = runServiceTierWrapper({ apiKey: "sk-ant-api03-test-key" }); + expect(payload?.service_tier).toBe("auto"); + }); + + it("injects service_tier=standard_only for regular API keys", () => { + const payload = runServiceTierWrapper({ + apiKey: "sk-ant-api03-test-key", + serviceTier: "standard_only", + }); + expect(payload?.service_tier).toBe("standard_only"); + }); + + it("does not inject service_tier for non-anthropic provider", () => { + const payload = runServiceTierWrapper({ + apiKey: "sk-ant-api03-test-key", + provider: "openai", + api: "openai-completions", + }); + expect(payload?.service_tier).toBeUndefined(); + }); +}); diff --git a/extensions/anthropic/stream-wrappers.ts b/extensions/anthropic/stream-wrappers.ts index 4c9d7b81b22..505d8d9b347 100644 --- a/extensions/anthropic/stream-wrappers.ts +++ b/extensions/anthropic/stream-wrappers.ts @@ -152,6 +152,10 @@ export function createAnthropicFastModeWrapper( const underlying = baseStreamFn ?? streamSimple; const serviceTier = resolveAnthropicFastServiceTier(enabled); return (model, context, options) => { + if (isAnthropicOAuthApiKey(options?.apiKey)) { + return underlying(model, context, options); + } + const payloadPolicy = resolveAnthropicPayloadPolicy({ provider: typeof model.provider === "string" ? model.provider : undefined, api: typeof model.api === "string" ? model.api : undefined, @@ -174,6 +178,10 @@ export function createAnthropicServiceTierWrapper( ): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => { + if (isAnthropicOAuthApiKey(options?.apiKey)) { + return underlying(model, context, options); + } + const payloadPolicy = resolveAnthropicPayloadPolicy({ provider: typeof model.provider === "string" ? model.provider : undefined, api: typeof model.api === "string" ? model.api : undefined, diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 103397cf0d3..650ef994092 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -2727,7 +2727,7 @@ describe("applyExtraParamsToAgent", () => { expect(payload.service_tier).toBe("standard_only"); }); - it("injects configured Anthropic service_tier into OAuth-authenticated Anthropic payloads", () => { + it("does not inject configured Anthropic service_tier into OAuth-authenticated Anthropic payloads", () => { const payload = runResponsesPayloadMutationCase({ applyProvider: "anthropic", applyModelId: "claude-sonnet-4-5", @@ -2755,7 +2755,7 @@ describe("applyExtraParamsToAgent", () => { }, payload: {}, }); - expect(payload.service_tier).toBe("standard_only"); + expect(payload.service_tier).toBeUndefined(); }); it("does not warn for valid Anthropic serviceTier values", () => { @@ -2840,7 +2840,7 @@ describe("applyExtraParamsToAgent", () => { expect(payload.service_tier).toBe("standard_only"); }); - it("lets explicit Anthropic service_tier override OAuth fast mode defaults", () => { + it("does not inject explicit Anthropic service_tier for OAuth auth even when fast mode is enabled", () => { const payload = runResponsesPayloadMutationCase({ applyProvider: "anthropic", applyModelId: "claude-sonnet-4-5", @@ -2869,10 +2869,10 @@ describe("applyExtraParamsToAgent", () => { }, payload: {}, }); - expect(payload.service_tier).toBe("standard_only"); + expect(payload.service_tier).toBeUndefined(); }); - it("injects Anthropic fast mode service_tier for OAuth auth", () => { + it("does not inject Anthropic fast mode service_tier for OAuth auth", () => { const payload = runResponsesPayloadMutationCase({ applyProvider: "anthropic", applyModelId: "claude-sonnet-4-5", @@ -2888,10 +2888,10 @@ describe("applyExtraParamsToAgent", () => { }, payload: {}, }); - expect(payload.service_tier).toBe("auto"); + expect(payload.service_tier).toBeUndefined(); }); - it("injects Anthropic standard_only service_tier for OAuth auth when fastMode is false", () => { + it("does not inject Anthropic standard_only service_tier for OAuth auth when fastMode is false", () => { const payload = runResponsesPayloadMutationCase({ applyProvider: "anthropic", applyModelId: "claude-sonnet-4-5", @@ -2907,7 +2907,7 @@ describe("applyExtraParamsToAgent", () => { }, payload: {}, }); - expect(payload.service_tier).toBe("standard_only"); + expect(payload.service_tier).toBeUndefined(); }); it("does not inject Anthropic fast mode service_tier for proxied base URLs", () => {