fix(auth): skip Anthropic API keys for usage status

Fixes #85124.

Anthropic standard API keys no longer resolve as provider usage auth for `openclaw status --usage`, so valid inference keys are not sent to Anthropic's OAuth usage endpoint and surfaced as misleading invalid bearer-token errors.

The provider usage-auth SDK result now has an explicit handled/no-token shape so provider hooks can suppress generic fallback without widening the OAuth helper contract. Docs, Plugin SDK API baseline, and extension package-boundary cache inputs were updated with the new contract.

Thanks @zhangguiping-xydt.

Proof:
- node scripts/run-vitest.mjs src/infra/provider-usage.auth.normalizes-keys.test.ts src/infra/provider-usage.auth.plugin.test.ts extensions/anthropic/index.test.ts
- pnpm plugin-sdk:api:check
- pnpm plugin-sdk:check-exports
- git diff --check origin/main...HEAD
- pnpm docs:list
- pnpm run test:extensions:package-boundary:compile
- autoreview clean: no accepted/actionable findings
- PR CI rollup green: 131 success, 22 skipped, 1 neutral, 0 failures

Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
This commit is contained in:
zhang-guiping
2026-06-01 01:26:03 +08:00
committed by GitHub
parent fbc611ab4c
commit b6e9473e9f
13 changed files with 119 additions and 30 deletions

View File

@@ -1404,7 +1404,7 @@ jobs:
packages/plugin-sdk/dist
extensions/*/dist/.boundary-tsc.tsbuildinfo
extensions/*/dist/.boundary-tsc.stamp
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/plugins/types.ts', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-extension-package-boundary-v1-

View File

@@ -1,2 +1,2 @@
5a9934e0224ac39541bec1d69c350d490874a7c259f464f19a5f5adf46243820 plugin-sdk-api-baseline.json
60dd5b951ec3ce5520d98bdf0bfbfa27ec00f56cde8bb95edc5a6f89149c044a plugin-sdk-api-baseline.jsonl
f1da4b7930475e4be33cb05b8c239f728c7338eb1e8df9b7905bbae94d62da9e plugin-sdk-api-baseline.json
6fd007eede80893680d65c6f245eafb9e6301a1e4306530b0134fd5b3da0cddb plugin-sdk-api-baseline.jsonl

View File

@@ -312,6 +312,13 @@ If the provider needs a fully custom wire protocol or custom request executor,
that is a different class of extension. These hooks are for provider behavior
that still runs on OpenClaw's normal inference loop.
`resolveUsageAuth` decides whether OpenClaw should call `fetchUsageSnapshot` or
fall back to generic credential resolution for usage/status surfaces. Return
`{ token, accountId? }` when the provider has a usage credential, return
`{ handled: true }` when provider-owned usage auth has handled the request and
must suppress generic API-key/OAuth fallback, and return `null` or `undefined`
when the provider did not handle usage auth.
### Provider example
```ts

View File

@@ -448,6 +448,13 @@ API key auth, and dynamic model resolution.
return await fetchAcmeUsage(ctx.token, ctx.timeoutMs);
},
```
`resolveUsageAuth` has three outcomes. Return `{ token, accountId? }`
when the provider has a usage/billing credential. Return
`{ handled: true }` only when the provider has definitively handled usage
auth but has no usable usage token, and OpenClaw must skip generic
API-key/OAuth fallback. Return `null` or `undefined` when the provider did
not handle the request and OpenClaw should continue with generic fallback.
</Tab>
</Tabs>

View File

@@ -6,6 +6,8 @@ import type {
ProviderAuthMethodNonInteractiveContext,
ProviderResolveDynamicModelContext,
ProviderNormalizeResolvedModelContext,
ProviderResolveUsageAuthContext,
ProviderResolvedUsageAuth,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
@@ -677,6 +679,22 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
};
}
async function resolveAnthropicUsageAuth(
ctx: ProviderResolveUsageAuthContext,
): Promise<ProviderResolvedUsageAuth> {
const oauthToken = await ctx.resolveOAuthToken();
if (oauthToken) {
return oauthToken;
}
const apiKey = ctx.resolveApiKeyFromConfigAndStore();
if (apiKey && validateAnthropicSetupToken(apiKey) === undefined) {
return { token: apiKey };
}
return { handled: true };
}
export function buildAnthropicProvider(): ProviderPlugin {
const providerId = "anthropic";
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
@@ -802,7 +820,7 @@ export function buildAnthropicProvider(): ProviderPlugin {
resolveReasoningOutputMode: () => "native",
resolveThinkingProfile: ({ modelId }) => resolveClaudeThinkingProfile(modelId),
wrapStreamFn: wrapAnthropicProviderStream,
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
resolveUsageAuth: resolveAnthropicUsageAuth,
fetchUsageSnapshot: async (ctx) =>
await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
isCacheTtlEligible: () => true,

View File

@@ -37,6 +37,7 @@ function listPackageDtsOutputsFromExports({ packageDir, outputPrefix }) {
const PLUGIN_SDK_TYPE_INPUTS = [
"tsconfig.json",
"src/plugin-sdk",
"src/plugins/types.ts",
"src/auto-reply",
"packages/llm-core/src",
"packages/markdown-core/src",

View File

@@ -164,6 +164,18 @@ const providerRuntimeMocks = vi.hoisted(() => ({
return token ? { token } : null;
}
if (params.provider === "anthropic") {
const oauth = await params.context.resolveOAuthToken();
if (oauth) {
return oauth;
}
const token = resolveToken({
providerIds: ["anthropic"],
envDirect: [params.context.env?.ANTHROPIC_API_KEY],
});
return token?.startsWith("sk-ant-oat01-") ? { token } : { handled: true };
}
if (params.provider === "minimax") {
const token = resolveToken({
providerIds: ["minimax"],
@@ -726,6 +738,26 @@ describe("resolveProviderAuths key normalization", () => {
});
});
it("does not use standard Anthropic API keys for provider usage auth", async () => {
await expectResolvedAuthsFromSuiteHome({
providers: ["anthropic"],
env: {
ANTHROPIC_API_KEY: "sk-ant-api03-status-key", // pragma: allowlist secret
},
expected: [],
});
});
it("allows Anthropic setup tokens from API-key sources for provider usage auth", async () => {
await expectResolvedAuthsFromSuiteHome({
providers: ["anthropic"],
env: {
ANTHROPIC_API_KEY: `sk-ant-oat01-${"a".repeat(80)}`,
},
expected: [{ provider: "anthropic", token: `sk-ant-oat01-${"a".repeat(80)}` }],
});
});
it("ignores marker-backed config keys for provider usage auth resolution", async () => {
const auths = await resolveMinimaxAuthFromConfiguredKey(NON_ENV_SECRETREF_MARKER);
expect(auths).toStrictEqual([]);

View File

@@ -276,7 +276,8 @@ describe("resolveProviderAuths plugin boundary", () => {
expect(resolveProviderUsageAuthWithPluginMock).not.toHaveBeenCalled();
});
it("skips plugin usage auth per provider when only another provider has direct credentials", async () => {
it("does not fall back to standard Anthropic API keys for usage auth", async () => {
resolveProviderUsageAuthWithPluginMock.mockResolvedValueOnce({ handled: true });
await withTempHome(async (homeDir) => {
await expect(
resolveProviderAuthsForTest({
@@ -284,15 +285,10 @@ describe("resolveProviderAuths plugin boundary", () => {
skipPluginAuthWithoutCredentialSource: true,
env: {
HOME: homeDir,
ANTHROPIC_API_KEY: "sk-ant",
ANTHROPIC_API_KEY: "sk-ant-api03-status-key", // pragma: allowlist secret
},
}),
).resolves.toEqual([
{
provider: "anthropic",
token: "sk-ant",
},
]);
).resolves.toEqual([]);
});
expect(resolveProviderUsageAuthWithPluginMock).toHaveBeenCalledTimes(1);

View File

@@ -258,7 +258,7 @@ async function resolveOAuthToken(params: {
async function resolveProviderUsageAuthViaPlugin(params: {
state: UsageAuthState;
provider: UsageProviderId;
}): Promise<ProviderAuth | null> {
}): Promise<{ handled: boolean; auth: ProviderAuth | null }> {
const resolved = await resolveProviderUsageAuthWithPlugin({
provider: params.provider,
config: params.state.cfg,
@@ -290,13 +290,19 @@ async function resolveProviderUsageAuthViaPlugin(params: {
},
},
});
if (!resolved?.token) {
return null;
if (!resolved) {
return { handled: false, auth: null };
}
if ("handled" in resolved) {
return { handled: true, auth: null };
}
return {
provider: params.provider,
token: resolved.token,
...(resolved.accountId ? { accountId: resolved.accountId } : {}),
handled: true,
auth: {
provider: params.provider,
token: resolved.token,
...(resolved.accountId ? { accountId: resolved.accountId } : {}),
},
};
}
@@ -392,8 +398,11 @@ export async function resolveProviderAuths(params: {
state: authProfileSourceState,
provider,
});
if (pluginAuth) {
auths.push(pluginAuth);
if (pluginAuth.auth) {
auths.push(pluginAuth.auth);
continue;
}
if (pluginAuth.handled) {
continue;
}
const fallbackAuth = await resolveProviderUsageAuthFallback({
@@ -442,8 +451,11 @@ export async function resolveProviderAuths(params: {
state,
provider,
});
if (pluginAuth) {
auths.push(pluginAuth);
if (pluginAuth.auth) {
auths.push(pluginAuth.auth);
continue;
}
if (pluginAuth.handled) {
continue;
}
}

View File

@@ -114,6 +114,7 @@ export type {
ProviderResolveTransportTurnStateContext,
ProviderResolveWebSocketSessionPolicyContext,
ProviderResolvedUsageAuth,
ProviderUsageAuthToken,
RealtimeTranscriptionProviderPlugin,
ProviderSanitizeReplayHistoryContext,
ProviderTransportTurnState,

View File

@@ -70,6 +70,7 @@ import type {
ProviderReplaySessionState,
RealtimeTranscriptionProviderPlugin,
ProviderResolvedUsageAuth,
ProviderUsageAuthToken,
ProviderResolveDynamicModelContext,
ProviderResolveTransportTurnStateContext,
ProviderResolveWebSocketSessionPolicyContext,
@@ -207,6 +208,7 @@ export type {
ProviderReasoningOutputMode,
ProviderReasoningOutputModeContext,
ProviderResolvedUsageAuth,
ProviderUsageAuthToken,
ProviderToolSchemaDiagnostic,
ProviderPrepareExtraParamsContext,
ProviderPrepareDynamicModelContext,

View File

@@ -614,7 +614,15 @@ export async function resolveProviderUsageAuthWithPlugin(params: {
env?: NodeJS.ProcessEnv;
context: ProviderResolveUsageAuthContext;
}) {
return await resolveProviderRuntimePlugin(params)?.resolveUsageAuth?.(params.context);
const plugin = resolveProviderRuntimePlugin(params);
if (!plugin?.resolveUsageAuth) {
return undefined;
}
const result = await plugin.resolveUsageAuth(params.context);
if (!result) {
return undefined;
}
return result;
}
export async function resolveProviderUsageSnapshotWithPlugin(params: {

View File

@@ -640,19 +640,24 @@ export type ProviderResolveUsageAuthContext = {
providerIds?: string[];
envDirect?: Array<string | undefined>;
}) => string | undefined;
resolveOAuthToken: (params?: { provider?: string }) => Promise<ProviderResolvedUsageAuth | null>;
resolveOAuthToken: (params?: { provider?: string }) => Promise<ProviderUsageAuthToken | null>;
};
export type ProviderUsageAuthToken = { token: string; accountId?: string };
/**
* Result of `resolveUsageAuth`.
*
* `token` is the credential used for provider usage/billing endpoints.
* `accountId` is optional provider-specific metadata used by some usage APIs.
* Two shapes are supported:
* - `{ token: string; accountId?: string }` — use this token for provider usage endpoints.
* - `{ handled: true }` — this provider handled the request but has no usable
* usage token; core must skip further fallback (generic API-key/OAuth fallback
* must not run).
*
* Returning `null` or `undefined` means "not handled by this provider"; core
* proceeds to generic fallback resolution.
*/
export type ProviderResolvedUsageAuth = {
token: string;
accountId?: string;
};
export type ProviderResolvedUsageAuth = ProviderUsageAuthToken | { handled: true };
/**
* Usage/quota snapshot input for providers that own their usage endpoint