Skip to content

Commit c9375f8

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

5 files changed

Lines changed: 339 additions & 13 deletions

File tree

‎src/index.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
buildConfigModels,
55
fetchModelsDevData,
66
fetchHubModels,
7+
HubAuthError,
8+
resolveHubApiKey,
79
} from "./models.ts";
10+
import type { HubResponse } from "./models.ts";
811

912
const PROVIDER_NAME = "CoreInfra AI Hub";
1013

@@ -34,6 +37,16 @@ async function log(
3437
.catch(() => {});
3538
}
3639

40+
// If the Hub rejects the key, silently fall back to the anonymous listing.
41+
async function fetchHubData(apiKey: string | undefined): Promise<HubResponse> {
42+
try {
43+
return await fetchHubModels(apiKey);
44+
} catch (err) {
45+
if (!(err instanceof HubAuthError)) throw err;
46+
return fetchHubModels();
47+
}
48+
}
49+
3750
async function plugin(input: PluginInput): Promise<Hooks> {
3851
const t0 = performance.now();
3952
await log(input, "plugin load started");
@@ -46,9 +59,10 @@ async function plugin(input: PluginInput): Promise<Hooks> {
4659
const t = performance.now();
4760
ensureCoreInfraProvider(config);
4861
try {
62+
const apiKey = await resolveHubApiKey();
4963
const [modelsDevData, hubData] = await Promise.all([
5064
fetchModelsDevData(),
51-
fetchHubModels(),
65+
fetchHubData(apiKey),
5266
]);
5367
const { models: configModels, warnings } = buildConfigModels(
5468
modelsDevData,

‎src/models.ts‎

Lines changed: 47 additions & 3 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`;
@@ -78,7 +83,7 @@ type ModelsDevData = {
7883
};
7984
};
8085

81-
type HubResponse = {
86+
export type HubResponse = {
8287
providers: {
8388
[provider: string]: {
8489
models?: {
@@ -165,11 +170,50 @@ export async function fetchModelsDevData(): Promise<ModelsDevData> {
165170
}
166171
}
167172

168-
export async function fetchHubModels(): Promise<HubResponse> {
173+
// Thrown only when the hub rejects the key we sent (401 with an apiKey).
174+
// The caller in src/index.ts catches it and silently retries
175+
// anonymously; any other failure keeps the plain Error below.
176+
export class HubAuthError extends Error {
177+
readonly status: number;
178+
179+
constructor(status: number, statusText: string) {
180+
super(`Hub rejected the CoreInfra API key: ${status} ${statusText}`);
181+
this.name = "HubAuthError";
182+
this.status = status;
183+
}
184+
}
185+
186+
type AuthStore = { [provider: string]: { type?: string; key?: string } };
187+
188+
// The config hook runs before OpenCode consults our auth.loader, so the key
189+
// has to be resolved independently: first from the environment, then from the
190+
// auth store OpenCode writes for `opencode auth login`. Every failure on the
191+
// store path (missing file, bad JSON, no usable entry) means "no key".
192+
export async function resolveHubApiKey(): Promise<string | undefined> {
193+
const fromEnv = process.env.COREINFRA_API_KEY?.trim();
194+
if (fromEnv) return fromEnv;
195+
196+
try {
197+
const raw = await readFile(AUTH_PATH, "utf-8");
198+
const store = JSON.parse(raw) as AuthStore;
199+
const entry = store["coreinfra"];
200+
const key = entry?.type === "api" ? entry.key?.trim() : undefined;
201+
if (key) return key;
202+
} catch {
203+
// best-effort: anything wrong here just means no key
204+
}
205+
return undefined;
206+
}
207+
208+
export async function fetchHubModels(apiKey?: string): Promise<HubResponse> {
169209
const res = await fetch(HUB_URL, {
210+
headers: apiKey ? { "X-CoreInfra-Api-Key": apiKey } : undefined,
170211
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
171212
});
172213
if (!res.ok) {
214+
if (apiKey && res.status === 401) {
215+
throw new HubAuthError(res.status, res.statusText);
216+
}
173217
throw new Error(
174218
`Failed to fetch CoreInfra prices: ${res.status} ${res.statusText}`,
175219
);

‎test/index.test.ts‎

Lines changed: 127 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,126 @@ describe("config hook", () => {
285289
expect(config.provider?.coreinfra?.name).toBe("CoreInfra AI Hub");
286290
expect(config.provider?.coreinfra?.models).toBeUndefined();
287291
});
292+
293+
it("silently retries anonymously when the hub rejects the key with 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(2);
343+
expect(hubCalls[0]?.headers).toEqual({ "X-CoreInfra-Api-Key": "bad-key" });
344+
expect(hubCalls[1]?.headers).toBeUndefined();
345+
346+
const models = config.provider?.coreinfra?.models ?? {};
347+
expect(models["gpt-5.4-nano"]).toBeDefined();
348+
expect(models["claude-sonnet-4-20250514"]).toBeDefined();
349+
350+
expect(log).not.toHaveBeenCalledWith(
351+
expect.objectContaining({
352+
body: expect.objectContaining({
353+
level: expect.stringMatching(/^(warn|error)$/),
354+
}),
355+
}),
356+
);
357+
});
358+
359+
it("does not retry when the hub accepts the key", async () => {
360+
vi.stubEnv("COREINFRA_API_KEY", "good-key");
361+
const { readFile } = await import("node:fs/promises");
362+
vi.mocked(readFile).mockResolvedValue(JSON.stringify(MODELS_DEV_DATA));
363+
364+
const hubCalls: Array<{ headers?: Record<string, string> }> = [];
365+
vi.stubGlobal(
366+
"fetch",
367+
vi
368+
.fn()
369+
.mockImplementation(
370+
(url: string, init?: { headers?: Record<string, string> }) => {
371+
if (url.includes("models.dev")) {
372+
return Promise.resolve({
373+
ok: true,
374+
json: () => Promise.resolve(MODELS_DEV_DATA),
375+
});
376+
}
377+
hubCalls.push({ headers: init?.headers });
378+
return Promise.resolve({
379+
ok: true,
380+
json: () => Promise.resolve(hubResponse()),
381+
});
382+
},
383+
),
384+
);
385+
386+
const { input, log } = mockInput();
387+
const hooks: Hooks = await plugin(input);
388+
const config = { provider: {} } as Parameters<
389+
NonNullable<Hooks["config"]>
390+
>[0];
391+
392+
await hooks.config?.(config);
393+
394+
expect(hubCalls).toHaveLength(1);
395+
expect(hubCalls[0]?.headers).toEqual({
396+
"X-CoreInfra-Api-Key": "good-key",
397+
});
398+
expect(config.provider?.coreinfra?.models?.["gpt-5.4-nano"]).toBeDefined();
399+
expect(log).not.toHaveBeenCalledWith(
400+
expect.objectContaining({
401+
body: expect.objectContaining({ level: "warn" }),
402+
}),
403+
);
404+
});
288405
});
289406

290407
describe("auth hook", () => {
291408
it("returns apiKey from auth loader", async () => {
292409
await setupFetchMocks({ hubData: { providers: {} } });
293-
const hooks: Hooks = await plugin(mockInput());
410+
const { input } = mockInput();
411+
const hooks: Hooks = await plugin(input);
294412
const getAuth = vi.fn().mockResolvedValue({ type: "api", key: "sk-test" });
295413
const providerArg = {} as Parameters<
296414
NonNullable<Hooks["auth"]>["loader"]
@@ -301,7 +419,8 @@ describe("auth hook", () => {
301419

302420
it("returns empty object when no auth", async () => {
303421
await setupFetchMocks({ hubData: { providers: {} } });
304-
const hooks: Hooks = await plugin(mockInput());
422+
const { input } = mockInput();
423+
const hooks: Hooks = await plugin(input);
305424
const getAuth = vi.fn().mockResolvedValue(null);
306425
const providerArg = {} as Parameters<
307426
NonNullable<Hooks["auth"]>["loader"]

0 commit comments

Comments
 (0)