From 4bb8df63ca9c11d486cf2e97a09045eae113b24c Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:19:57 -0600 Subject: [PATCH] fix(models): authenticate Anthropic discovery with OAuth tokens (#113906) * fix(models): authenticate Anthropic discovery with OAuth tokens Live model discovery sent every credential as x-api-key. Claude subscription credentials are OAuth access tokens, which Anthropic rejects in that header, so discovery 401d and silently fell back to the shipped catalog. Select the auth header from the detected credential shape: API keys keep x-api-key, OAuth tokens use Authorization: Bearer. The two are mutually exclusive because Anthropic rejects requests carrying both. * fix(models): keep shipped Anthropic models when discovery succeeds Live discovery replaces the seed catalog with the /v1/models response. Anthropic does not publish every model it serves, so a shipped entry with no live row was dropped from the provider listing once discovery started working. Re-add manifest models the live response omitted; discovered rows still win on shared ids. --- .../anthropic/live-discovery-auth.test.ts | 113 ++++++++++++++++++ extensions/anthropic/register.runtime.ts | 80 ++++++++++--- extensions/anthropic/stream-wrappers.ts | 7 +- 3 files changed, 182 insertions(+), 18 deletions(-) create mode 100644 extensions/anthropic/live-discovery-auth.test.ts diff --git a/extensions/anthropic/live-discovery-auth.test.ts b/extensions/anthropic/live-discovery-auth.test.ts new file mode 100644 index 000000000000..4d1f74f5779f --- /dev/null +++ b/extensions/anthropic/live-discovery-auth.test.ts @@ -0,0 +1,113 @@ +// Anthropic tests cover live model discovery request auth. +import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildAnthropicProvider } from "./register.runtime.js"; + +const guardedFetchCalls = vi.hoisted( + () => + [] as Array< + Parameters[0] + >, +); + +const discoveryRows = vi.hoisted(() => ({ value: [] as unknown[] })); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: (params: Parameters[0]) => { + guardedFetchCalls.push(params); + return Promise.resolve({ + response: new Response(JSON.stringify({ data: discoveryRows.value })), + finalUrl: "https://api.anthropic.com/v1/models", + release: async () => undefined, + }); + }, + }; +}); + +function buildCatalogContext(apiKey: string): ProviderCatalogContext { + return { + config: {}, + env: {}, + resolveProviderApiKey: () => ({ apiKey }), + } as unknown as ProviderCatalogContext; +} + +async function readDiscoveryHeaders(apiKey: string): Promise { + const provider = buildAnthropicProvider(); + await provider.catalog?.run?.(buildCatalogContext(apiKey)); + const request = guardedFetchCalls.at(-1); + expect(request).toBeDefined(); + return new Headers(request?.init?.headers); +} + +describe("anthropic live model discovery auth", () => { + beforeEach(() => { + guardedFetchCalls.length = 0; + discoveryRows.value = []; + clearLiveCatalogCacheForTests(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends an API key as x-api-key", async () => { + const headers = await readDiscoveryHeaders("sk-ant-api03-test-key"); + expect(headers.get("x-api-key")).toBe("sk-ant-api03-test-key"); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("anthropic-version")).toBe("2023-06-01"); + }); + + it("sends a subscription OAuth token as a bearer credential", async () => { + const headers = await readDiscoveryHeaders("sk-ant-oat01-test-token"); + expect(headers.get("authorization")).toBe("Bearer sk-ant-oat01-test-token"); + expect(headers.get("anthropic-version")).toBe("2023-06-01"); + }); + + it("never pairs an OAuth bearer credential with x-api-key", async () => { + // Anthropic rejects the request outright when both auth headers are present, + // so the OAuth branch must replace x-api-key rather than add to it. + const headers = await readDiscoveryHeaders("sk-ant-oat01-test-token"); + expect(headers.get("x-api-key")).toBeNull(); + }); + + it("keeps shipped models Anthropic does not publish while adding discovered ones", async () => { + // Discovery replaces the seed catalog, so a shipped model with no live row + // would silently vanish from the provider listing once discovery succeeds. + discoveryRows.value = [ + { + id: "claude-opus-5", + type: "model", + max_input_tokens: 1_000_000, + max_tokens: 128_000, + }, + { + id: "claude-brand-new-9", + type: "model", + max_input_tokens: 1_000_000, + max_tokens: 128_000, + capabilities: { + // Advertises the Claude 5 contract our predicates do not yet grant an + // unrecognized id, so the gate must keep it hidden. + thinking: { types: { adaptive: { supported: true } } }, + effort: { xhigh: { supported: true }, max: { supported: true } }, + }, + }, + ]; + const provider = buildAnthropicProvider(); + const result = await provider.catalog?.run?.(buildCatalogContext("sk-ant-oat01-test-token")); + const ids = new Set( + result && "provider" in result ? (result.provider.models ?? []).map((model) => model.id) : [], + ); + + expect(ids.has("claude-opus-5")).toBe(true); + // Shipped but absent from the live response: must survive discovery. + expect(ids.has("claude-mythos-5")).toBe(true); + // Unknown id whose advertised capabilities disagree with our contracts stays gated out. + expect(ids.has("claude-brand-new-9")).toBe(false); + }); +}); diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 9055d9f1c4a8..e71d1e385aed 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -26,7 +26,10 @@ import { validateAnthropicSetupToken, } from "openclaw/plugin-sdk/provider-auth"; import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; -import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared"; +import { + buildManifestModelProviderConfig, + type ProviderCatalogResult, +} from "openclaw/plugin-sdk/provider-catalog-shared"; import { buildProviderReplayFamilyHooks, cloneFirstTemplateModel, @@ -64,7 +67,7 @@ import manifest from "./openclaw.plugin.json" with { type: "json" }; import { resolveClaudeCliSyntheticAuth } from "./provider-discovery.js"; import { createClaudeSessionNodeInvokePolicies } from "./session-catalog-node-commands.js"; import { registerClaudeSessionDiscovery } from "./session-catalog-registration.js"; -import { wrapAnthropicProviderStream } from "./stream-wrappers.js"; +import { isAnthropicOAuthApiKey, wrapAnthropicProviderStream } from "./stream-wrappers.js"; import { fetchAnthropicUsage, resolveAnthropicUsageAuth } from "./usage.js"; type ProviderAuthMethodNonInteractiveValidationContext = Parameters< @@ -134,6 +137,50 @@ function buildAnthropicCatalogProvider() { }); } +/** + * Discovery credentials arrive as either an API key or a Claude subscription + * OAuth access token. Anthropic rejects an OAuth token sent as `x-api-key`, and + * rejects the request outright when both auth headers are present, so the two + * shapes must select mutually exclusive headers. + */ +function buildAnthropicDiscoveryAuthHeaders(key: string | undefined): Record { + if (!key) { + return {}; + } + return isAnthropicOAuthApiKey(key) ? { authorization: `Bearer ${key}` } : { "x-api-key": key }; +} + +/** + * Live discovery replaces the seed catalog with whatever `/v1/models` returns. + * Anthropic does not publish every model it serves, so replacement alone would + * hide shipped entries that have no live row. Re-add the manifest models the + * live response omitted; discovered rows still win on shared ids. + */ +function restoreUnpublishedAnthropicModels(result: ProviderCatalogResult): ProviderCatalogResult { + if (!result || !("provider" in result)) { + return result; + } + const discovered = result.provider.models ?? []; + if (discovered.length === 0) { + return result; + } + const discoveredIds = new Set(discovered.map((model) => model.id)); + const unpublished = (buildAnthropicCatalogProvider().models ?? []).filter( + (model) => !discoveredIds.has(model.id), + ); + if (unpublished.length === 0) { + return result; + } + // Discovered rows arrive id-sorted; keep the appended tail sorted too so the + // catalog stays byte-stable for prompt caching. + return { + provider: { + ...result.provider, + models: [...discovered, ...unpublished.toSorted((a, b) => a.id.localeCompare(b.id))], + }, + }; +} + function resolveAnthropicSonnet5Cost(nowMs: number = Date.now()) { return nowMs >= ANTHROPIC_SONNET_5_STANDARD_PRICING_START_MS ? ANTHROPIC_SONNET_5_STANDARD_COST @@ -937,23 +984,22 @@ export function buildAnthropicProvider(): ProviderPlugin { ], catalog: { order: "simple", - run: (ctx) => - buildOpenAICompatibleProviderCatalog({ - ctx, - providerId, - buildProvider: buildAnthropicCatalogProvider, - modelDiscovery: { - endpointPath: "v1/models", - buildRequestHeaders: ({ apiKey, discoveryApiKey }) => { - const key = discoveryApiKey ?? apiKey; - return { + run: async (ctx) => + restoreUnpublishedAnthropicModels( + await buildOpenAICompatibleProviderCatalog({ + ctx, + providerId, + buildProvider: buildAnthropicCatalogProvider, + modelDiscovery: { + endpointPath: "v1/models", + buildRequestHeaders: ({ apiKey, discoveryApiKey }) => ({ "anthropic-version": "2023-06-01", - ...(key ? { "x-api-key": key } : {}), - }; + ...buildAnthropicDiscoveryAuthHeaders(discoveryApiKey ?? apiKey), + }), + acceptUnknownModel: acceptsAnthropicLiveModelContract, }, - acceptUnknownModel: acceptsAnthropicLiveModelContract, - }, - }), + }), + ), }, staticCatalog: { order: "simple", diff --git a/extensions/anthropic/stream-wrappers.ts b/extensions/anthropic/stream-wrappers.ts index 3206b616b50a..a4c98a9f5f28 100644 --- a/extensions/anthropic/stream-wrappers.ts +++ b/extensions/anthropic/stream-wrappers.ts @@ -73,7 +73,12 @@ function mergeAnthropicBetaHeader( return merged; } -function isAnthropicOAuthApiKey(apiKey: unknown): boolean { +/** + * Claude subscription credentials are OAuth access tokens rather than API keys. + * Anthropic authenticates them through `Authorization: Bearer`, so every caller + * that builds request auth must branch on this instead of assuming `x-api-key`. + */ +export function isAnthropicOAuthApiKey(apiKey: unknown): boolean { return typeof apiKey === "string" && apiKey.includes("sk-ant-oat"); }