From 40288b2a020271ebd36c2093132960ab2503c8d8 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Wed, 16 Sep 2026 17:40:20 +0300 Subject: [PATCH] fix: authenticate Hub prices requests --- src/models.ts | 33 ++++++++- test/index.test.ts | 136 ++++++++++++++++++++++++++++++++-- test/models.test.ts | 175 +++++++++++++++++++++++++++++++++++++++++++- vitest.config.ts | 1 + 4 files changed, 334 insertions(+), 11 deletions(-) diff --git a/src/models.ts b/src/models.ts index e6f8417..8277bb3 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { xdgCache } from "xdg-basedir"; +import { xdgCache, xdgData } from "xdg-basedir"; const FETCH_TIMEOUT_MS = 10_000; const CACHE_PATH = join( @@ -9,6 +9,11 @@ const CACHE_PATH = join( "opencode", "models.json", ); +const AUTH_PATH = join( + xdgData ?? join(homedir(), ".local", "share"), + "opencode", + "auth.json", +); const MODELS_DEV_URL = "https://models.dev/api.json"; const DEFAULT_HUB_BASE = "https://hub.coreinfra.ai"; const HUB_URL = `${process.env.COREINFRA_HUB_BASE_URL ?? DEFAULT_HUB_BASE}/hub/api/prices`; @@ -88,7 +93,7 @@ type HubResponse = { }; }; -export type ConfigModel = { +type ConfigModel = { id: string; name: string; provider: { @@ -165,8 +170,32 @@ export async function fetchModelsDevData(): Promise { } } +type AuthStore = { [provider: string]: { type?: string; key?: string } }; + +// The config hook runs before OpenCode consults our auth.loader, so the key +// has to be resolved independently: first from the environment, then from the +// auth store OpenCode writes for `opencode auth login`. Every failure on the +// store path (missing file, bad JSON, no usable entry) means "no key". +async function resolveHubApiKey(): Promise { + const fromEnv = process.env.COREINFRA_API_KEY?.trim(); + if (fromEnv) return fromEnv; + + try { + const raw = await readFile(AUTH_PATH, "utf-8"); + const store = JSON.parse(raw) as AuthStore; + const entry = store["coreinfra"]; + const key = entry?.type === "api" ? entry.key?.trim() : undefined; + if (key) return key; + } catch { + // best-effort: anything wrong here just means no key + } + return undefined; +} + export async function fetchHubModels(): Promise { + const apiKey = await resolveHubApiKey(); const res = await fetch(HUB_URL, { + headers: apiKey ? { "X-CoreInfra-Api-Key": apiKey } : undefined, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) { diff --git a/test/index.test.ts b/test/index.test.ts index f14c757..42f2b84 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -9,9 +9,9 @@ vi.mock("node:fs/promises", () => ({ readFile: vi.fn(), })); -function mockInput(): PluginInput { +function mockInput() { const log = vi.fn().mockResolvedValue(undefined); - return { + const input = { client: { app: { log }, }, @@ -20,7 +20,8 @@ function mockInput(): PluginInput { worktree: "/tmp", serverUrl: new URL("http://localhost:3000"), $: {} as PluginInput["$"], - }; + } as PluginInput; + return { input, log }; } const MODELS_DEV_DATA = { @@ -166,7 +167,8 @@ describe("config hook", () => { it("populates config with full capabilities from models.dev", async () => { await setupFetchMocks(); - const hooks: Hooks = await plugin(mockInput()); + const { input } = mockInput(); + const hooks: Hooks = await plugin(input); const config = { provider: {} } as Parameters< NonNullable >[0]; @@ -255,7 +257,8 @@ describe("config hook", () => { it("uses defaults when models.dev has no matching model", async () => { await setupFetchMocks({ modelsDevData: {} }); - const hooks: Hooks = await plugin(mockInput()); + const { input } = mockInput(); + const hooks: Hooks = await plugin(input); const config = { provider: {} } as Parameters< NonNullable >[0]; @@ -275,7 +278,8 @@ describe("config hook", () => { it("leaves models empty on fetchModels failure", async () => { await setupFetchMocks({ fetchError: new Error("network down") }); - const hooks: Hooks = await plugin(mockInput()); + const { input } = mockInput(); + const hooks: Hooks = await plugin(input); const config = { provider: {} } as Parameters< NonNullable >[0]; @@ -285,12 +289,127 @@ describe("config hook", () => { expect(config.provider?.coreinfra?.name).toBe("CoreInfra AI Hub"); expect(config.provider?.coreinfra?.models).toBeUndefined(); }); + + it("logs the failure without retrying when the hub returns 401", async () => { + vi.stubEnv("COREINFRA_API_KEY", "bad-key"); + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue(JSON.stringify(MODELS_DEV_DATA)); + + const hubCalls: Array<{ headers?: Record }> = []; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation( + ( + url: string, + init?: { headers?: Record }, + ): Promise<{ + ok: boolean; + status?: number; + statusText?: string; + json: () => Promise; + }> => { + if (url.includes("models.dev")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(MODELS_DEV_DATA), + }); + } + hubCalls.push({ headers: init?.headers }); + if (hubCalls.length === 1) { + return Promise.resolve({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: () => Promise.resolve({}), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(hubResponse()), + }); + }, + ), + ); + + const { input, log } = mockInput(); + const hooks: Hooks = await plugin(input); + const config = { provider: {} } as Parameters< + NonNullable + >[0]; + + await hooks.config?.(config); + + expect(hubCalls).toHaveLength(1); + expect(hubCalls[0]?.headers).toEqual({ "X-CoreInfra-Api-Key": "bad-key" }); + + expect(config.provider?.coreinfra?.models).toBeUndefined(); + + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + level: "error", + message: "fetchModels failed", + extra: { + error: "Failed to fetch CoreInfra prices: 401 Unauthorized", + }, + }), + }), + ); + }); + + it("does not retry when the hub accepts the key", async () => { + vi.stubEnv("COREINFRA_API_KEY", "good-key"); + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue(JSON.stringify(MODELS_DEV_DATA)); + + const hubCalls: Array<{ headers?: Record }> = []; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockImplementation( + (url: string, init?: { headers?: Record }) => { + if (url.includes("models.dev")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(MODELS_DEV_DATA), + }); + } + hubCalls.push({ headers: init?.headers }); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(hubResponse()), + }); + }, + ), + ); + + const { input, log } = mockInput(); + const hooks: Hooks = await plugin(input); + const config = { provider: {} } as Parameters< + NonNullable + >[0]; + + await hooks.config?.(config); + + expect(hubCalls).toHaveLength(1); + expect(hubCalls[0]?.headers).toEqual({ + "X-CoreInfra-Api-Key": "good-key", + }); + expect(config.provider?.coreinfra?.models?.["gpt-5.4-nano"]).toBeDefined(); + expect(log).not.toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ level: "warn" }), + }), + ); + }); }); describe("auth hook", () => { it("returns apiKey from auth loader", async () => { await setupFetchMocks({ hubData: { providers: {} } }); - const hooks: Hooks = await plugin(mockInput()); + const { input } = mockInput(); + const hooks: Hooks = await plugin(input); const getAuth = vi.fn().mockResolvedValue({ type: "api", key: "sk-test" }); const providerArg = {} as Parameters< NonNullable["loader"] @@ -301,7 +420,8 @@ describe("auth hook", () => { it("returns empty object when no auth", async () => { await setupFetchMocks({ hubData: { providers: {} } }); - const hooks: Hooks = await plugin(mockInput()); + const { input } = mockInput(); + const hooks: Hooks = await plugin(input); const getAuth = vi.fn().mockResolvedValue(null); const providerArg = {} as Parameters< NonNullable["loader"] diff --git a/test/models.test.ts b/test/models.test.ts index 388d8d1..0857b35 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildConfigModels, @@ -628,7 +628,118 @@ describe("fetchModelsDevData", () => { }); }); +describe("fetchHubModels authentication", () => { + const AUTH_STORE = { + coreinfra: { type: "api", key: "stored-key" }, + other: { type: "api", key: "other-key" }, + }; + + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(HUB_FIXTURE), + }), + ); + }); + + async function expectAuthHeader(key?: string) { + await expect(fetchHubModels()).resolves.toEqual(HUB_FIXTURE); + expect(fetch).toHaveBeenCalledExactlyOnceWith( + "https://hub.coreinfra.ai/hub/api/prices", + { + headers: key ? { "X-CoreInfra-Api-Key": key } : undefined, + signal: expect.any(AbortSignal), + }, + ); + } + + it("prefers COREINFRA_API_KEY over the auth store", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue(JSON.stringify(AUTH_STORE)); + vi.stubEnv("COREINFRA_API_KEY", "env-key"); + + await expectAuthHeader("env-key"); + expect(readFile).not.toHaveBeenCalled(); + }); + + it("trims COREINFRA_API_KEY", async () => { + vi.stubEnv("COREINFRA_API_KEY", " env-key "); + + await expectAuthHeader("env-key"); + }); + + it("falls back to the coreinfra entry in the auth store", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue(JSON.stringify(AUTH_STORE)); + vi.stubEnv("COREINFRA_API_KEY", ""); + + await expectAuthHeader("stored-key"); + }); + + it("ignores a blank env var and falls through to the auth store", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue(JSON.stringify(AUTH_STORE)); + vi.stubEnv("COREINFRA_API_KEY", " "); + + await expectAuthHeader("stored-key"); + }); + + it("sends no auth header when the auth store file is missing", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockRejectedValue(new Error("ENOENT")); + vi.stubEnv("COREINFRA_API_KEY", ""); + + await expectAuthHeader(); + }); + + it("sends no auth header on invalid JSON", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue("not json"); + vi.stubEnv("COREINFRA_API_KEY", ""); + + await expectAuthHeader(); + }); + + it("sends no auth header when there is no coreinfra entry", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ other: { type: "api", key: "other-key" } }), + ); + vi.stubEnv("COREINFRA_API_KEY", ""); + + await expectAuthHeader(); + }); + + it("sends no auth header when the entry type is not api", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ coreinfra: { type: "oauth", key: "token" } }), + ); + vi.stubEnv("COREINFRA_API_KEY", ""); + + await expectAuthHeader(); + }); + + it("sends no auth header when the stored key is blank", async () => { + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockResolvedValue( + JSON.stringify({ coreinfra: { type: "api", key: " " } }), + ); + vi.stubEnv("COREINFRA_API_KEY", ""); + + await expectAuthHeader(); + }); +}); + describe("fetchHubModels", () => { + beforeEach(async () => { + vi.stubEnv("COREINFRA_API_KEY", ""); + const { readFile } = await import("node:fs/promises"); + vi.mocked(readFile).mockRejectedValue(new Error("ENOENT")); + }); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -646,7 +757,69 @@ describe("fetchHubModels", () => { expect(data).toEqual(HUB_FIXTURE); }); + it("sends the key in X-CoreInfra-Api-Key when configured", async () => { + vi.stubEnv("COREINFRA_API_KEY", "test-key"); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(HUB_FIXTURE), + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchHubModels()).resolves.toEqual(HUB_FIXTURE); + expect(fetchMock.mock.calls[0]?.[1]).toEqual({ + headers: { "X-CoreInfra-Api-Key": "test-key" }, + signal: expect.any(AbortSignal), + }); + }); + + it("sends no auth header without a key", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(HUB_FIXTURE), + }); + vi.stubGlobal("fetch", fetchMock); + + await fetchHubModels(); + expect(fetchMock.mock.calls[0]?.[1]).toEqual({ + headers: undefined, + signal: expect.any(AbortSignal), + }); + }); + + it("throws the generic error on 401 when a key was sent", async () => { + vi.stubEnv("COREINFRA_API_KEY", "secret-key"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + await expect(fetchHubModels()).rejects.toThrow( + "Failed to fetch CoreInfra prices: 401 Unauthorized", + ); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("keeps the generic error on 401 without a key", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + }), + ); + + await expect(fetchHubModels()).rejects.toThrow( + "Failed to fetch CoreInfra prices: 401 Unauthorized", + ); + }); + it("throws on non-ok response", async () => { + vi.stubEnv("COREINFRA_API_KEY", "secret-key"); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ diff --git a/vitest.config.ts b/vitest.config.ts index 5575f86..c96c669 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ restoreMocks: true, clearMocks: true, unstubGlobals: true, + unstubEnvs: true, }, });