Skip to content

Commit 14b4c7f

Browse files
committed
refactor: dedupe provider usage auth/fetch logic and expand coverage
1 parent 2d485cd commit 14b4c7f

6 files changed

Lines changed: 283 additions & 86 deletions

src/infra/provider-usage.auth.normalizes-keys.test.ts

Lines changed: 226 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,39 @@ describe("resolveProviderAuths key normalization", () => {
7474
);
7575
}
7676

77+
async function writeConfig(home: string, config: Record<string, unknown>) {
78+
const stateDir = path.join(home, ".openclaw");
79+
await fs.mkdir(stateDir, { recursive: true });
80+
await fs.writeFile(
81+
path.join(stateDir, "openclaw.json"),
82+
`${JSON.stringify(config, null, 2)}\n`,
83+
"utf8",
84+
);
85+
}
86+
87+
async function writeProfileOrder(home: string, provider: string, profileIds: string[]) {
88+
const agentDir = path.join(home, ".openclaw", "agents", "main", "agent");
89+
const parsed = JSON.parse(
90+
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"),
91+
) as Record<string, unknown>;
92+
const order = (parsed.order && typeof parsed.order === "object" ? parsed.order : {}) as Record<
93+
string,
94+
unknown
95+
>;
96+
order[provider] = profileIds;
97+
parsed.order = order;
98+
await fs.writeFile(
99+
path.join(agentDir, "auth-profiles.json"),
100+
`${JSON.stringify(parsed, null, 2)}\n`,
101+
);
102+
}
103+
104+
async function writeLegacyPiAuth(home: string, raw: string) {
105+
const legacyDir = path.join(home, ".pi", "agent");
106+
await fs.mkdir(legacyDir, { recursive: true });
107+
await fs.writeFile(path.join(legacyDir, "auth.json"), raw, "utf8");
108+
}
109+
77110
it("strips embedded CR/LF from env keys", async () => {
78111
await withSuiteHome(
79112
async () => {
@@ -144,12 +177,9 @@ describe("resolveProviderAuths key normalization", () => {
144177
it("falls back to legacy .pi auth file for zai keys", async () => {
145178
await withSuiteHome(
146179
async (home) => {
147-
const legacyDir = path.join(home, ".pi", "agent");
148-
await fs.mkdir(legacyDir, { recursive: true });
149-
await fs.writeFile(
150-
path.join(legacyDir, "auth.json"),
180+
await writeLegacyPiAuth(
181+
home,
151182
`${JSON.stringify({ "z-ai": { access: "legacy-zai-key" } }, null, 2)}\n`,
152-
"utf8",
153183
);
154184

155185
const auths = await resolveProviderAuths({
@@ -180,4 +210,195 @@ describe("resolveProviderAuths key normalization", () => {
180210
expect(auths).toEqual([{ provider: "google-gemini-cli", token: "google-oauth-token" }]);
181211
}, {});
182212
});
213+
214+
it("keeps raw google token when token payload is not JSON", async () => {
215+
await withSuiteHome(async (home) => {
216+
await writeAuthProfiles(home, {
217+
"google-antigravity:default": {
218+
type: "token",
219+
provider: "google-antigravity",
220+
token: "plain-google-token",
221+
},
222+
});
223+
224+
const auths = await resolveProviderAuths({
225+
providers: ["google-antigravity"],
226+
});
227+
expect(auths).toEqual([{ provider: "google-antigravity", token: "plain-google-token" }]);
228+
}, {});
229+
});
230+
231+
it("uses config api keys when env and profiles are missing", async () => {
232+
await withSuiteHome(
233+
async (home) => {
234+
const modelDef = {
235+
id: "test-model",
236+
name: "Test Model",
237+
reasoning: false,
238+
input: ["text"],
239+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
240+
contextWindow: 1024,
241+
maxTokens: 256,
242+
};
243+
await writeConfig(home, {
244+
models: {
245+
providers: {
246+
zai: {
247+
baseUrl: "https://api.z.ai",
248+
models: [modelDef],
249+
apiKey: "cfg-zai-key",
250+
},
251+
minimax: {
252+
baseUrl: "https://api.minimaxi.com",
253+
models: [modelDef],
254+
apiKey: "cfg-minimax-key",
255+
},
256+
xiaomi: {
257+
baseUrl: "https://api.xiaomi.example",
258+
models: [modelDef],
259+
apiKey: "cfg-xiaomi-key",
260+
},
261+
},
262+
},
263+
});
264+
265+
const auths = await resolveProviderAuths({
266+
providers: ["zai", "minimax", "xiaomi"],
267+
});
268+
expect(auths).toEqual([
269+
{ provider: "zai", token: "cfg-zai-key" },
270+
{ provider: "minimax", token: "cfg-minimax-key" },
271+
{ provider: "xiaomi", token: "cfg-xiaomi-key" },
272+
]);
273+
},
274+
{
275+
ZAI_API_KEY: undefined,
276+
Z_AI_API_KEY: undefined,
277+
MINIMAX_API_KEY: undefined,
278+
MINIMAX_CODE_PLAN_KEY: undefined,
279+
XIAOMI_API_KEY: undefined,
280+
},
281+
);
282+
});
283+
284+
it("returns no auth when providers have no configured credentials", async () => {
285+
await withSuiteHome(
286+
async () => {
287+
const auths = await resolveProviderAuths({
288+
providers: ["zai", "minimax", "xiaomi"],
289+
});
290+
expect(auths).toEqual([]);
291+
},
292+
{
293+
ZAI_API_KEY: undefined,
294+
Z_AI_API_KEY: undefined,
295+
MINIMAX_API_KEY: undefined,
296+
MINIMAX_CODE_PLAN_KEY: undefined,
297+
XIAOMI_API_KEY: undefined,
298+
},
299+
);
300+
});
301+
302+
it("uses zai api_key auth profiles when env and config are missing", async () => {
303+
await withSuiteHome(
304+
async (home) => {
305+
await writeAuthProfiles(home, {
306+
"zai:default": { type: "api_key", provider: "zai", key: "profile-zai-key" },
307+
});
308+
309+
const auths = await resolveProviderAuths({
310+
providers: ["zai"],
311+
});
312+
expect(auths).toEqual([{ provider: "zai", token: "profile-zai-key" }]);
313+
},
314+
{
315+
ZAI_API_KEY: undefined,
316+
Z_AI_API_KEY: undefined,
317+
},
318+
);
319+
});
320+
321+
it("ignores invalid legacy z-ai auth files", async () => {
322+
await withSuiteHome(
323+
async (home) => {
324+
await writeLegacyPiAuth(home, "{not-json");
325+
const auths = await resolveProviderAuths({
326+
providers: ["zai"],
327+
});
328+
expect(auths).toEqual([]);
329+
},
330+
{
331+
ZAI_API_KEY: undefined,
332+
Z_AI_API_KEY: undefined,
333+
},
334+
);
335+
});
336+
337+
it("discovers oauth provider from config but skips mismatched profile providers", async () => {
338+
await withSuiteHome(async (home) => {
339+
await writeConfig(home, {
340+
auth: {
341+
profiles: {
342+
"anthropic:default": { provider: "anthropic", mode: "token" },
343+
},
344+
},
345+
});
346+
await writeAuthProfiles(home, {
347+
"anthropic:default": {
348+
type: "token",
349+
provider: "zai",
350+
token: "mismatched-provider-token",
351+
},
352+
});
353+
354+
const auths = await resolveProviderAuths({
355+
providers: ["anthropic"],
356+
});
357+
expect(auths).toEqual([]);
358+
}, {});
359+
});
360+
361+
it("skips providers without oauth-compatible profiles", async () => {
362+
await withSuiteHome(async () => {
363+
const auths = await resolveProviderAuths({
364+
providers: ["anthropic"],
365+
});
366+
expect(auths).toEqual([]);
367+
}, {});
368+
});
369+
370+
it("skips oauth profiles that resolve without an api key and uses later profiles", async () => {
371+
await withSuiteHome(async (home) => {
372+
await writeAuthProfiles(home, {
373+
"anthropic:empty": {
374+
type: "token",
375+
provider: "anthropic",
376+
token: "expired-token",
377+
expires: Date.now() - 60_000,
378+
},
379+
"anthropic:valid": { type: "token", provider: "anthropic", token: "anthropic-token" },
380+
});
381+
await writeProfileOrder(home, "anthropic", ["anthropic:empty", "anthropic:valid"]);
382+
383+
const auths = await resolveProviderAuths({
384+
providers: ["anthropic"],
385+
});
386+
expect(auths).toEqual([{ provider: "anthropic", token: "anthropic-token" }]);
387+
}, {});
388+
});
389+
390+
it("skips api_key entries in oauth token resolution order", async () => {
391+
await withSuiteHome(async (home) => {
392+
await writeAuthProfiles(home, {
393+
"anthropic:api": { type: "api_key", provider: "anthropic", key: "api-key-1" },
394+
"anthropic:token": { type: "token", provider: "anthropic", token: "token-1" },
395+
});
396+
await writeProfileOrder(home, "anthropic", ["anthropic:api", "anthropic:token"]);
397+
398+
const auths = await resolveProviderAuths({
399+
providers: ["anthropic"],
400+
});
401+
expect(auths).toEqual([{ provider: "anthropic", token: "token-1" }]);
402+
}, {});
403+
});
183404
});

src/infra/provider-usage.auth.ts

Lines changed: 28 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
resolveApiKeyForProfile,
99
resolveAuthProfileOrder,
1010
} from "../agents/auth-profiles.js";
11-
import { getCustomProviderApiKey, resolveEnvApiKey } from "../agents/model-auth.js";
11+
import { getCustomProviderApiKey } from "../agents/model-auth.js";
1212
import { normalizeProviderId } from "../agents/model-selection.js";
1313
import { loadConfig } from "../config/config.js";
1414
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
@@ -21,9 +21,6 @@ export type ProviderAuth = {
2121
};
2222

2323
function parseGoogleToken(apiKey: string): { token: string } | null {
24-
if (!apiKey) {
25-
return null;
26-
}
2724
try {
2825
const parsed = JSON.parse(apiKey) as { token?: unknown };
2926
if (parsed && typeof parsed.token === "string") {
@@ -42,11 +39,6 @@ function resolveZaiApiKey(): string | undefined {
4239
return envDirect;
4340
}
4441

45-
const envResolved = resolveEnvApiKey("zai");
46-
if (envResolved?.apiKey) {
47-
return envResolved.apiKey;
48-
}
49-
5042
const cfg = loadConfig();
5143
const key = getCustomProviderApiKey(cfg, "zai") || getCustomProviderApiKey(cfg, "z-ai");
5244
if (key) {
@@ -103,33 +95,30 @@ function resolveProviderApiKeyFromConfigAndStore(params: {
10395
return envDirect;
10496
}
10597

106-
const envResolved = resolveEnvApiKey(params.providerId);
107-
if (envResolved?.apiKey) {
108-
return envResolved.apiKey;
109-
}
110-
11198
const cfg = loadConfig();
11299
const key = getCustomProviderApiKey(cfg, params.providerId);
113100
if (key) {
114101
return key;
115102
}
116103

117104
const store = ensureAuthProfileStore();
118-
const apiProfile = listProfilesForProvider(store, params.providerId).find((id) => {
119-
const cred = store.profiles[id];
120-
return cred?.type === "api_key" || cred?.type === "token";
121-
});
122-
if (!apiProfile) {
105+
const cred = listProfilesForProvider(store, params.providerId)
106+
.map((id) => store.profiles[id])
107+
.find(
108+
(
109+
profile,
110+
): profile is
111+
| { type: "api_key"; provider: string; key: string }
112+
| { type: "token"; provider: string; token: string } =>
113+
profile?.type === "api_key" || profile?.type === "token",
114+
);
115+
if (!cred) {
123116
return undefined;
124117
}
125-
const cred = store.profiles[apiProfile];
126-
if (cred?.type === "api_key") {
118+
if (cred.type === "api_key") {
127119
return normalizeSecretInput(cred.key);
128120
}
129-
if (cred?.type === "token") {
130-
return normalizeSecretInput(cred.token);
131-
}
132-
return undefined;
121+
return normalizeSecretInput(cred.token);
133122
}
134123

135124
async function resolveOAuthToken(params: {
@@ -161,22 +150,21 @@ async function resolveOAuthToken(params: {
161150
profileId,
162151
agentDir: params.agentDir,
163152
});
164-
if (!resolved?.apiKey) {
165-
continue;
166-
}
167-
let token = resolved.apiKey;
168-
if (params.provider === "google-gemini-cli" || params.provider === "google-antigravity") {
169-
const parsed = parseGoogleToken(resolved.apiKey);
170-
token = parsed?.token ?? resolved.apiKey;
153+
if (resolved) {
154+
let token = resolved.apiKey;
155+
if (params.provider === "google-gemini-cli" || params.provider === "google-antigravity") {
156+
const parsed = parseGoogleToken(resolved.apiKey);
157+
token = parsed?.token ?? resolved.apiKey;
158+
}
159+
return {
160+
provider: params.provider,
161+
token,
162+
accountId:
163+
cred.type === "oauth" && "accountId" in cred
164+
? (cred as { accountId?: string }).accountId
165+
: undefined,
166+
};
171167
}
172-
return {
173-
provider: params.provider,
174-
token,
175-
accountId:
176-
cred.type === "oauth" && "accountId" in cred
177-
? (cred as { accountId?: string }).accountId
178-
: undefined,
179-
};
180168
} catch {
181169
// ignore
182170
}

src/infra/provider-usage.fetch.antigravity.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { logDebug } from "../logger.js";
2-
import { fetchJson } from "./provider-usage.fetch.shared.js";
2+
import { fetchJson, parseFiniteNumber } from "./provider-usage.fetch.shared.js";
33
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
44
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
55

@@ -46,16 +46,7 @@ const METADATA = {
4646
};
4747

4848
function parseNumber(value: number | string | undefined): number | undefined {
49-
if (typeof value === "number" && Number.isFinite(value)) {
50-
return value;
51-
}
52-
if (typeof value === "string") {
53-
const parsed = Number.parseFloat(value);
54-
if (Number.isFinite(parsed)) {
55-
return parsed;
56-
}
57-
}
58-
return undefined;
49+
return parseFiniteNumber(value);
5950
}
6051

6152
function parseEpochMs(isoString: string | undefined): number | undefined {

0 commit comments

Comments
 (0)