mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -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-
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ export type {
|
||||
ProviderResolveTransportTurnStateContext,
|
||||
ProviderResolveWebSocketSessionPolicyContext,
|
||||
ProviderResolvedUsageAuth,
|
||||
ProviderUsageAuthToken,
|
||||
RealtimeTranscriptionProviderPlugin,
|
||||
ProviderSanitizeReplayHistoryContext,
|
||||
ProviderTransportTurnState,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user