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"); }