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.
This commit is contained in:
Jason (Json)
2026-07-26 12:19:57 -06:00
committed by GitHub
parent c16c0dbef4
commit 4bb8df63ca
3 changed files with 182 additions and 18 deletions

View File

@@ -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<typeof import("openclaw/plugin-sdk/ssrf-runtime").fetchWithSsrFGuard>[0]
>,
);
const discoveryRows = vi.hoisted(() => ({ value: [] as unknown[] }));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (params: Parameters<typeof actual.fetchWithSsrFGuard>[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<Headers> {
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);
});
});

View File

@@ -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<string, string> {
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",

View File

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