Files
openclaw/extensions/anthropic/live-discovery-auth.test.ts
Jason (Json) 4bb8df63ca 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.
2026-07-26 12:19:57 -06:00

114 lines
4.3 KiB
TypeScript

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