From 65f34a80e89d60e5283d2f84b608c6a7b7e79334 Mon Sep 17 00:00:00 2001 From: Jordan Humberto de Souza Date: Thu, 19 Feb 2026 11:51:27 -0300 Subject: [PATCH] fix(nanogpt): validate login key via models endpoint --- README.md | 2 +- .../ai/src/utils/oauth/api-key-validation.ts | 42 ++++++++++++++++ packages/ai/src/utils/oauth/nanogpt.ts | 9 ++-- packages/ai/test/nanogpt-login.test.ts | 48 +++++++++++++++++++ 4 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 packages/ai/test/nanogpt-login.test.ts diff --git a/README.md b/README.md index a9fa0776a..36f202c96 100644 --- a/README.md +++ b/README.md @@ -516,7 +516,7 @@ Use `/login` with supported providers: For `ollama`, API key is optional. Leave it unset for local no-auth instances, or set `OLLAMA_API_KEY` for authenticated hosts. For `vllm`, paste your key in `/login` (or use `VLLM_API_KEY`). For local no-auth servers, any placeholder value works (for example `vllm-local`). -For `nanogpt`, `/login nanogpt` opens `https://nano-gpt.com/api` and prompts for your `sk-...` key (or set `NANO_GPT_API_KEY`). +For `nanogpt`, `/login nanogpt` opens `https://nano-gpt.com/api` and prompts for your `sk-...` key (or set `NANO_GPT_API_KEY`). Login validates the key via NanoGPT's models endpoint (not a fixed model entitlement). For `cloudflare-ai-gateway`, set provider base URL to `https://gateway.ai.cloudflare.com/v1///anthropic` (for example in `~/.omp/agent/models.yml`). diff --git a/packages/ai/src/utils/oauth/api-key-validation.ts b/packages/ai/src/utils/oauth/api-key-validation.ts index 0f02daec5..baeacc590 100644 --- a/packages/ai/src/utils/oauth/api-key-validation.ts +++ b/packages/ai/src/utils/oauth/api-key-validation.ts @@ -6,6 +6,13 @@ type OpenAICompatibleValidationOptions = { signal?: AbortSignal; }; +type ModelListValidationOptions = { + provider: string; + apiKey: string; + modelsUrl: string; + signal?: AbortSignal; +}; + const VALIDATION_TIMEOUT_MS = 15_000; /** @@ -48,3 +55,38 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa : `${options.provider} API key validation failed (${response.status})`; throw new Error(message); } + +/** + * Validate an API key against a provider models endpoint. + * + * Useful for providers where access to specific models may vary by plan and + * should not block key validation. + */ +export async function validateApiKeyAgainstModelsEndpoint(options: ModelListValidationOptions): Promise { + const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS); + const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; + + const response = await fetch(options.modelsUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${options.apiKey}`, + }, + signal, + }); + + if (response.ok) { + return; + } + + let details = ""; + try { + details = (await response.text()).trim(); + } catch { + // ignore body parse errors, status is enough + } + + const message = details + ? `${options.provider} API key validation failed (${response.status}): ${details}` + : `${options.provider} API key validation failed (${response.status})`; + throw new Error(message); +} diff --git a/packages/ai/src/utils/oauth/nanogpt.ts b/packages/ai/src/utils/oauth/nanogpt.ts index 33767847d..a97037cc1 100644 --- a/packages/ai/src/utils/oauth/nanogpt.ts +++ b/packages/ai/src/utils/oauth/nanogpt.ts @@ -8,12 +8,12 @@ * 3. Paste key into CLI */ -import { validateOpenAICompatibleApiKey } from "./api-key-validation"; +import { validateApiKeyAgainstModelsEndpoint } from "./api-key-validation"; import type { OAuthController } from "./types"; const AUTH_URL = "https://nano-gpt.com/api"; const API_BASE_URL = "https://nano-gpt.com/api/v1"; -const VALIDATION_MODEL = "openai/gpt-4o-mini"; +const MODELS_URL = `${API_BASE_URL}/models`; export async function loginNanoGPT(options: OAuthController): Promise { if (!options.onPrompt) { @@ -40,11 +40,10 @@ export async function loginNanoGPT(options: OAuthController): Promise { } options.onProgress?.("Validating API key..."); - await validateOpenAICompatibleApiKey({ + await validateApiKeyAgainstModelsEndpoint({ provider: "NanoGPT", apiKey: trimmed, - baseUrl: API_BASE_URL, - model: VALIDATION_MODEL, + modelsUrl: MODELS_URL, signal: options.signal, }); diff --git a/packages/ai/test/nanogpt-login.test.ts b/packages/ai/test/nanogpt-login.test.ts new file mode 100644 index 000000000..601a10e83 --- /dev/null +++ b/packages/ai/test/nanogpt-login.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { loginNanoGPT } from "../src/utils/oauth/nanogpt"; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("nanogpt login", () => { + it("validates API key without requiring a specific model entitlement", async () => { + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + expect(url).toBe("https://nano-gpt.com/api/v1/models"); + expect(init?.method).toBe("GET"); + expect(init?.headers).toEqual({ Authorization: "Bearer sk-nano-test" }); + return new Response(JSON.stringify({ object: "list", data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const apiKey = await loginNanoGPT({ + onPrompt: async () => "sk-nano-test", + }); + + expect(apiKey).toBe("sk-nano-test"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("surfaces validation errors from models endpoint", async () => { + const fetchMock = vi.fn(async () => { + return new Response('{"code":"invalid_api_key"}', { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + }); + global.fetch = fetchMock as unknown as typeof fetch; + + await expect( + loginNanoGPT({ + onPrompt: async () => "sk-nano-test", + }), + ).rejects.toThrow("NanoGPT API key validation failed (401)"); + }); +});