diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 2d6ede4cd12..ddb2992c3b7 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -74,6 +74,39 @@ describe("resolveProviderAuths key normalization", () => { ); } + async function writeConfig(home: string, config: Record) { + const stateDir = path.join(home, ".openclaw"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + `${JSON.stringify(config, null, 2)}\n`, + "utf8", + ); + } + + async function writeProfileOrder(home: string, provider: string, profileIds: string[]) { + const agentDir = path.join(home, ".openclaw", "agents", "main", "agent"); + const parsed = JSON.parse( + await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"), + ) as Record; + const order = (parsed.order && typeof parsed.order === "object" ? parsed.order : {}) as Record< + string, + unknown + >; + order[provider] = profileIds; + parsed.order = order; + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify(parsed, null, 2)}\n`, + ); + } + + async function writeLegacyPiAuth(home: string, raw: string) { + const legacyDir = path.join(home, ".pi", "agent"); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile(path.join(legacyDir, "auth.json"), raw, "utf8"); + } + it("strips embedded CR/LF from env keys", async () => { await withSuiteHome( async () => { @@ -144,12 +177,9 @@ describe("resolveProviderAuths key normalization", () => { it("falls back to legacy .pi auth file for zai keys", async () => { await withSuiteHome( async (home) => { - const legacyDir = path.join(home, ".pi", "agent"); - await fs.mkdir(legacyDir, { recursive: true }); - await fs.writeFile( - path.join(legacyDir, "auth.json"), + await writeLegacyPiAuth( + home, `${JSON.stringify({ "z-ai": { access: "legacy-zai-key" } }, null, 2)}\n`, - "utf8", ); const auths = await resolveProviderAuths({ @@ -180,4 +210,195 @@ describe("resolveProviderAuths key normalization", () => { expect(auths).toEqual([{ provider: "google-gemini-cli", token: "google-oauth-token" }]); }, {}); }); + + it("keeps raw google token when token payload is not JSON", async () => { + await withSuiteHome(async (home) => { + await writeAuthProfiles(home, { + "google-antigravity:default": { + type: "token", + provider: "google-antigravity", + token: "plain-google-token", + }, + }); + + const auths = await resolveProviderAuths({ + providers: ["google-antigravity"], + }); + expect(auths).toEqual([{ provider: "google-antigravity", token: "plain-google-token" }]); + }, {}); + }); + + it("uses config api keys when env and profiles are missing", async () => { + await withSuiteHome( + async (home) => { + const modelDef = { + id: "test-model", + name: "Test Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1024, + maxTokens: 256, + }; + await writeConfig(home, { + models: { + providers: { + zai: { + baseUrl: "https://api.z.ai", + models: [modelDef], + apiKey: "cfg-zai-key", + }, + minimax: { + baseUrl: "https://api.minimaxi.com", + models: [modelDef], + apiKey: "cfg-minimax-key", + }, + xiaomi: { + baseUrl: "https://api.xiaomi.example", + models: [modelDef], + apiKey: "cfg-xiaomi-key", + }, + }, + }, + }); + + const auths = await resolveProviderAuths({ + providers: ["zai", "minimax", "xiaomi"], + }); + expect(auths).toEqual([ + { provider: "zai", token: "cfg-zai-key" }, + { provider: "minimax", token: "cfg-minimax-key" }, + { provider: "xiaomi", token: "cfg-xiaomi-key" }, + ]); + }, + { + ZAI_API_KEY: undefined, + Z_AI_API_KEY: undefined, + MINIMAX_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: undefined, + XIAOMI_API_KEY: undefined, + }, + ); + }); + + it("returns no auth when providers have no configured credentials", async () => { + await withSuiteHome( + async () => { + const auths = await resolveProviderAuths({ + providers: ["zai", "minimax", "xiaomi"], + }); + expect(auths).toEqual([]); + }, + { + ZAI_API_KEY: undefined, + Z_AI_API_KEY: undefined, + MINIMAX_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: undefined, + XIAOMI_API_KEY: undefined, + }, + ); + }); + + it("uses zai api_key auth profiles when env and config are missing", async () => { + await withSuiteHome( + async (home) => { + await writeAuthProfiles(home, { + "zai:default": { type: "api_key", provider: "zai", key: "profile-zai-key" }, + }); + + const auths = await resolveProviderAuths({ + providers: ["zai"], + }); + expect(auths).toEqual([{ provider: "zai", token: "profile-zai-key" }]); + }, + { + ZAI_API_KEY: undefined, + Z_AI_API_KEY: undefined, + }, + ); + }); + + it("ignores invalid legacy z-ai auth files", async () => { + await withSuiteHome( + async (home) => { + await writeLegacyPiAuth(home, "{not-json"); + const auths = await resolveProviderAuths({ + providers: ["zai"], + }); + expect(auths).toEqual([]); + }, + { + ZAI_API_KEY: undefined, + Z_AI_API_KEY: undefined, + }, + ); + }); + + it("discovers oauth provider from config but skips mismatched profile providers", async () => { + await withSuiteHome(async (home) => { + await writeConfig(home, { + auth: { + profiles: { + "anthropic:default": { provider: "anthropic", mode: "token" }, + }, + }, + }); + await writeAuthProfiles(home, { + "anthropic:default": { + type: "token", + provider: "zai", + token: "mismatched-provider-token", + }, + }); + + const auths = await resolveProviderAuths({ + providers: ["anthropic"], + }); + expect(auths).toEqual([]); + }, {}); + }); + + it("skips providers without oauth-compatible profiles", async () => { + await withSuiteHome(async () => { + const auths = await resolveProviderAuths({ + providers: ["anthropic"], + }); + expect(auths).toEqual([]); + }, {}); + }); + + it("skips oauth profiles that resolve without an api key and uses later profiles", async () => { + await withSuiteHome(async (home) => { + await writeAuthProfiles(home, { + "anthropic:empty": { + type: "token", + provider: "anthropic", + token: "expired-token", + expires: Date.now() - 60_000, + }, + "anthropic:valid": { type: "token", provider: "anthropic", token: "anthropic-token" }, + }); + await writeProfileOrder(home, "anthropic", ["anthropic:empty", "anthropic:valid"]); + + const auths = await resolveProviderAuths({ + providers: ["anthropic"], + }); + expect(auths).toEqual([{ provider: "anthropic", token: "anthropic-token" }]); + }, {}); + }); + + it("skips api_key entries in oauth token resolution order", async () => { + await withSuiteHome(async (home) => { + await writeAuthProfiles(home, { + "anthropic:api": { type: "api_key", provider: "anthropic", key: "api-key-1" }, + "anthropic:token": { type: "token", provider: "anthropic", token: "token-1" }, + }); + await writeProfileOrder(home, "anthropic", ["anthropic:api", "anthropic:token"]); + + const auths = await resolveProviderAuths({ + providers: ["anthropic"], + }); + expect(auths).toEqual([{ provider: "anthropic", token: "token-1" }]); + }, {}); + }); }); diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index d5dafab8338..05c968f7222 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -8,7 +8,7 @@ import { resolveApiKeyForProfile, resolveAuthProfileOrder, } from "../agents/auth-profiles.js"; -import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js"; +import { getCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; @@ -21,9 +21,6 @@ export type ProviderAuth = { }; function parseGoogleToken(apiKey: string): { token: string } | null { - if (!apiKey) { - return null; - } try { const parsed = JSON.parse(apiKey) as { token?: unknown }; if (parsed && typeof parsed.token === "string") { @@ -42,11 +39,6 @@ function resolveZaiApiKey(): string | undefined { return envDirect; } - const envResolved = resolveEnvApiKey("zai"); - if (envResolved?.apiKey) { - return envResolved.apiKey; - } - const cfg = loadConfig(); const key = getCustomProviderApiKey(cfg, "zai") || getCustomProviderApiKey(cfg, "z-ai"); if (key) { @@ -103,11 +95,6 @@ function resolveProviderApiKeyFromConfigAndStore(params: { return envDirect; } - const envResolved = resolveEnvApiKey(params.providerId); - if (envResolved?.apiKey) { - return envResolved.apiKey; - } - const cfg = loadConfig(); const key = getCustomProviderApiKey(cfg, params.providerId); if (key) { @@ -115,21 +102,23 @@ function resolveProviderApiKeyFromConfigAndStore(params: { } const store = ensureAuthProfileStore(); - const apiProfile = listProfilesForProvider(store, params.providerId).find((id) => { - const cred = store.profiles[id]; - return cred?.type === "api_key" || cred?.type === "token"; - }); - if (!apiProfile) { + const cred = listProfilesForProvider(store, params.providerId) + .map((id) => store.profiles[id]) + .find( + ( + profile, + ): profile is + | { type: "api_key"; provider: string; key: string } + | { type: "token"; provider: string; token: string } => + profile?.type === "api_key" || profile?.type === "token", + ); + if (!cred) { return undefined; } - const cred = store.profiles[apiProfile]; - if (cred?.type === "api_key") { + if (cred.type === "api_key") { return normalizeSecretInput(cred.key); } - if (cred?.type === "token") { - return normalizeSecretInput(cred.token); - } - return undefined; + return normalizeSecretInput(cred.token); } async function resolveOAuthToken(params: { @@ -161,22 +150,21 @@ async function resolveOAuthToken(params: { profileId, agentDir: params.agentDir, }); - if (!resolved?.apiKey) { - continue; + if (resolved) { + let token = resolved.apiKey; + if (params.provider === "google-gemini-cli" || params.provider === "google-antigravity") { + const parsed = parseGoogleToken(resolved.apiKey); + token = parsed?.token ?? resolved.apiKey; + } + return { + provider: params.provider, + token, + accountId: + cred.type === "oauth" && "accountId" in cred + ? (cred as { accountId?: string }).accountId + : undefined, + }; } - let token = resolved.apiKey; - if (params.provider === "google-gemini-cli" || params.provider === "google-antigravity") { - const parsed = parseGoogleToken(resolved.apiKey); - token = parsed?.token ?? resolved.apiKey; - } - return { - provider: params.provider, - token, - accountId: - cred.type === "oauth" && "accountId" in cred - ? (cred as { accountId?: string }).accountId - : undefined, - }; } catch { // ignore } diff --git a/src/infra/provider-usage.fetch.antigravity.ts b/src/infra/provider-usage.fetch.antigravity.ts index fe4fd9de10a..ce21f77b798 100644 --- a/src/infra/provider-usage.fetch.antigravity.ts +++ b/src/infra/provider-usage.fetch.antigravity.ts @@ -1,5 +1,5 @@ import { logDebug } from "../logger.js"; -import { fetchJson } from "./provider-usage.fetch.shared.js"; +import { fetchJson, parseFiniteNumber } from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; @@ -46,16 +46,7 @@ const METADATA = { }; function parseNumber(value: number | string | undefined): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Number.parseFloat(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return undefined; + return parseFiniteNumber(value); } function parseEpochMs(isoString: string | undefined): number | undefined { diff --git a/src/infra/provider-usage.fetch.minimax.ts b/src/infra/provider-usage.fetch.minimax.ts index ee794a90a19..224c2455264 100644 --- a/src/infra/provider-usage.fetch.minimax.ts +++ b/src/infra/provider-usage.fetch.minimax.ts @@ -1,5 +1,9 @@ import { isRecord } from "../utils.js"; -import { buildUsageHttpErrorSnapshot, fetchJson } from "./provider-usage.fetch.shared.js"; +import { + buildUsageHttpErrorSnapshot, + fetchJson, + parseFiniteNumber, +} from "./provider-usage.fetch.shared.js"; import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; @@ -151,15 +155,9 @@ const WINDOW_MINUTE_KEYS = [ function pickNumber(record: Record, keys: readonly string[]): number | undefined { for (const key of keys) { - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Number.parseFloat(value); - if (Number.isFinite(parsed)) { - return parsed; - } + const parsed = parseFiniteNumber(record[key]); + if (parsed !== undefined) { + return parsed; } } return undefined; diff --git a/src/infra/provider-usage.fetch.shared.ts b/src/infra/provider-usage.fetch.shared.ts index 0ce82ee9cb1..2a2d2d0201b 100644 --- a/src/infra/provider-usage.fetch.shared.ts +++ b/src/infra/provider-usage.fetch.shared.ts @@ -16,6 +16,19 @@ export async function fetchJson( } } +export function parseFiniteNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +} + type BuildUsageHttpErrorSnapshotOptions = { provider: UsageProviderId; status: number; diff --git a/src/infra/provider-usage.format.ts b/src/infra/provider-usage.format.ts index 3b02828f499..5cfe6ba0ef1 100644 --- a/src/infra/provider-usage.format.ts +++ b/src/infra/provider-usage.format.ts @@ -33,13 +33,6 @@ function formatResetRemaining(targetMs?: number, now?: number): string | null { }).format(new Date(targetMs)); } -function pickPrimaryWindow(windows: UsageWindow[]): UsageWindow | undefined { - if (windows.length === 0) { - return undefined; - } - return windows.reduce((best, next) => (next.usedPercent > best.usedPercent ? next : best)); -} - function formatWindowShort(window: UsageWindow, now?: number): string { const remaining = clampPercent(100 - window.usedPercent); const reset = formatResetRemaining(window.resetAt, now); @@ -84,19 +77,12 @@ export function formatUsageSummaryLine( return null; } - const parts = providers - .map((entry) => { - const window = pickPrimaryWindow(entry.windows); - if (!window) { - return null; - } - return `${entry.displayName} ${formatWindowShort(window, opts?.now)}`; - }) - .filter(Boolean) as string[]; - - if (parts.length === 0) { - return null; - } + const parts = providers.map((entry) => { + const window = entry.windows.reduce((best, next) => + next.usedPercent > best.usedPercent ? next : best, + ); + return `${entry.displayName} ${formatWindowShort(window, opts?.now)}`; + }); return `๐Ÿ“Š Usage: ${parts.join(" ยท ")}`; }