Skip to content

Commit 92ac52f

Browse files
committed
fix: authenticate Hub prices requests
1 parent 0502423 commit 92ac52f

4 files changed

Lines changed: 334 additions & 11 deletions

File tree

‎src/models.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { readFile } from "node:fs/promises";
22
import { homedir } from "node:os";
33
import { join } from "node:path";
4-
import { xdgCache } from "xdg-basedir";
4+
import { xdgCache, xdgData } from "xdg-basedir";
55

66
const FETCH_TIMEOUT_MS = 10_000;
77
const CACHE_PATH = join(
88
xdgCache ?? join(homedir(), ".cache"),
99
"opencode",
1010
"models.json",
1111
);
12+
const AUTH_PATH = join(
13+
xdgData ?? join(homedir(), ".local", "share"),
14+
"opencode",
15+
"auth.json",
16+
);
1217
const MODELS_DEV_URL = "https://models.dev/api.json";
1318
const DEFAULT_HUB_BASE = "https://hub.coreinfra.ai";
1419
const HUB_URL = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/hub/api/prices`;
@@ -88,7 +93,7 @@ type HubResponse = {
8893
};
8994
};
9095

91-
export type ConfigModel = {
96+
type ConfigModel = {
9297
id: string;
9398
name: string;
9499
provider: {
@@ -165,8 +170,32 @@ export async function fetchModelsDevData(): Promise<ModelsDevData> {
165170
}
166171
}
167172

173+
type AuthStore = { [provider: string]: { type?: string; key?: string } };
174+
175+
// The config hook runs before OpenCode consults our auth.loader, so the key
176+
// has to be resolved independently: first from the environment, then from the
177+
// auth store OpenCode writes for `opencode auth login`. Every failure on the
178+
// store path (missing file, bad JSON, no usable entry) means "no key".
179+
async function resolveHubApiKey(): Promise<string | undefined> {
180+
const fromEnv = process.env.COREINFRA_API_KEY?.trim();
181+
if (fromEnv) return fromEnv;
182+
183+
try {
184+
const raw = await readFile(AUTH_PATH, "utf-8");
185+
const store = JSON.parse(raw) as AuthStore;
186+
const entry = store["coreinfra"];
187+
const key = entry?.type === "api" ? entry.key?.trim() : undefined;
188+
if (key) return key;
189+
} catch {
190+
// best-effort: anything wrong here just means no key
191+
}
192+
return undefined;
193+
}
194+
168195
export async function fetchHubModels(): Promise<HubResponse> {
196+
const apiKey = await resolveHubApiKey();
169197
const res = await fetch(HUB_URL, {
198+
headers: apiKey ? { "X-CoreInfra-Api-Key": apiKey } : undefined,
170199
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
171200
});
172201
if (!res.ok) {

‎test/index.test.ts‎

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ vi.mock("node:fs/promises", () => ({
99
readFile: vi.fn(),
1010
}));
1111

12-
function mockInput(): PluginInput {
12+
function mockInput() {
1313
const log = vi.fn().mockResolvedValue(undefined);
14-
return {
14+
const input = {
1515
client: {
1616
app: { log },
1717
},
@@ -20,7 +20,8 @@ function mockInput(): PluginInput {
2020
worktree: "/tmp",
2121
serverUrl: new URL("http://localhost:3000"),
2222
$: {} as PluginInput["$"],
23-
};
23+
} as PluginInput;
24+
return { input, log };
2425
}
2526

2627
const MODELS_DEV_DATA = {
@@ -166,7 +167,8 @@ describe("config hook", () => {
166167
it("populates config with full capabilities from models.dev", async () => {
167168
await setupFetchMocks();
168169

169-
const hooks: Hooks = await plugin(mockInput());
170+
const { input } = mockInput();
171+
const hooks: Hooks = await plugin(input);
170172
const config = { provider: {} } as Parameters<
171173
NonNullable<Hooks["config"]>
172174
>[0];
@@ -255,7 +257,8 @@ describe("config hook", () => {
255257
it("uses defaults when models.dev has no matching model", async () => {
256258
await setupFetchMocks({ modelsDevData: {} });
257259

258-
const hooks: Hooks = await plugin(mockInput());
260+
const { input } = mockInput();
261+
const hooks: Hooks = await plugin(input);
259262
const config = { provider: {} } as Parameters<
260263
NonNullable<Hooks["config"]>
261264
>[0];
@@ -275,7 +278,8 @@ describe("config hook", () => {
275278
it("leaves models empty on fetchModels failure", async () => {
276279
await setupFetchMocks({ fetchError: new Error("network down") });
277280

278-
const hooks: Hooks = await plugin(mockInput());
281+
const { input } = mockInput();
282+
const hooks: Hooks = await plugin(input);
279283
const config = { provider: {} } as Parameters<
280284
NonNullable<Hooks["config"]>
281285
>[0];
@@ -285,12 +289,127 @@ describe("config hook", () => {
285289
expect(config.provider?.coreinfra?.name).toBe("CoreInfra AI Hub");
286290
expect(config.provider?.coreinfra?.models).toBeUndefined();
287291
});
292+
293+
it("logs the failure without retrying when the hub returns 401", async () => {
294+
vi.stubEnv("COREINFRA_API_KEY", "bad-key");
295+
const { readFile } = await import("node:fs/promises");
296+
vi.mocked(readFile).mockResolvedValue(JSON.stringify(MODELS_DEV_DATA));
297+
298+
const hubCalls: Array<{ headers?: Record<string, string> }> = [];
299+
vi.stubGlobal(
300+
"fetch",
301+
vi.fn().mockImplementation(
302+
(
303+
url: string,
304+
init?: { headers?: Record<string, string> },
305+
): Promise<{
306+
ok: boolean;
307+
status?: number;
308+
statusText?: string;
309+
json: () => Promise<object>;
310+
}> => {
311+
if (url.includes("models.dev")) {
312+
return Promise.resolve({
313+
ok: true,
314+
json: () => Promise.resolve(MODELS_DEV_DATA),
315+
});
316+
}
317+
hubCalls.push({ headers: init?.headers });
318+
if (hubCalls.length === 1) {
319+
return Promise.resolve({
320+
ok: false,
321+
status: 401,
322+
statusText: "Unauthorized",
323+
json: () => Promise.resolve({}),
324+
});
325+
}
326+
return Promise.resolve({
327+
ok: true,
328+
json: () => Promise.resolve(hubResponse()),
329+
});
330+
},
331+
),
332+
);
333+
334+
const { input, log } = mockInput();
335+
const hooks: Hooks = await plugin(input);
336+
const config = { provider: {} } as Parameters<
337+
NonNullable<Hooks["config"]>
338+
>[0];
339+
340+
await hooks.config?.(config);
341+
342+
expect(hubCalls).toHaveLength(1);
343+
expect(hubCalls[0]?.headers).toEqual({ "X-CoreInfra-Api-Key": "bad-key" });
344+
345+
expect(config.provider?.coreinfra?.models).toBeUndefined();
346+
347+
expect(log).toHaveBeenCalledWith(
348+
expect.objectContaining({
349+
body: expect.objectContaining({
350+
level: "error",
351+
message: "fetchModels failed",
352+
extra: {
353+
error: "Failed to fetch CoreInfra prices: 401 Unauthorized",
354+
},
355+
}),
356+
}),
357+
);
358+
});
359+
360+
it("does not retry when the hub accepts the key", async () => {
361+
vi.stubEnv("COREINFRA_API_KEY", "good-key");
362+
const { readFile } = await import("node:fs/promises");
363+
vi.mocked(readFile).mockResolvedValue(JSON.stringify(MODELS_DEV_DATA));
364+
365+
const hubCalls: Array<{ headers?: Record<string, string> }> = [];
366+
vi.stubGlobal(
367+
"fetch",
368+
vi
369+
.fn()
370+
.mockImplementation(
371+
(url: string, init?: { headers?: Record<string, string> }) => {
372+
if (url.includes("models.dev")) {
373+
return Promise.resolve({
374+
ok: true,
375+
json: () => Promise.resolve(MODELS_DEV_DATA),
376+
});
377+
}
378+
hubCalls.push({ headers: init?.headers });
379+
return Promise.resolve({
380+
ok: true,
381+
json: () => Promise.resolve(hubResponse()),
382+
});
383+
},
384+
),
385+
);
386+
387+
const { input, log } = mockInput();
388+
const hooks: Hooks = await plugin(input);
389+
const config = { provider: {} } as Parameters<
390+
NonNullable<Hooks["config"]>
391+
>[0];
392+
393+
await hooks.config?.(config);
394+
395+
expect(hubCalls).toHaveLength(1);
396+
expect(hubCalls[0]?.headers).toEqual({
397+
"X-CoreInfra-Api-Key": "good-key",
398+
});
399+
expect(config.provider?.coreinfra?.models?.["gpt-5.4-nano"]).toBeDefined();
400+
expect(log).not.toHaveBeenCalledWith(
401+
expect.objectContaining({
402+
body: expect.objectContaining({ level: "warn" }),
403+
}),
404+
);
405+
});
288406
});
289407

290408
describe("auth hook", () => {
291409
it("returns apiKey from auth loader", async () => {
292410
await setupFetchMocks({ hubData: { providers: {} } });
293-
const hooks: Hooks = await plugin(mockInput());
411+
const { input } = mockInput();
412+
const hooks: Hooks = await plugin(input);
294413
const getAuth = vi.fn().mockResolvedValue({ type: "api", key: "sk-test" });
295414
const providerArg = {} as Parameters<
296415
NonNullable<Hooks["auth"]>["loader"]
@@ -301,7 +420,8 @@ describe("auth hook", () => {
301420

302421
it("returns empty object when no auth", async () => {
303422
await setupFetchMocks({ hubData: { providers: {} } });
304-
const hooks: Hooks = await plugin(mockInput());
423+
const { input } = mockInput();
424+
const hooks: Hooks = await plugin(input);
305425
const getAuth = vi.fn().mockResolvedValue(null);
306426
const providerArg = {} as Parameters<
307427
NonNullable<Hooks["auth"]>["loader"]

0 commit comments

Comments
 (0)