fix(nanogpt): validate login key via models endpoint
This commit is contained in:
@@ -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/<account_id>/<gateway_id>/anthropic`
|
||||
(for example in `~/.omp/agent/models.yml`).
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
if (!options.onPrompt) {
|
||||
@@ -40,11 +40,10 @@ export async function loginNanoGPT(options: OAuthController): Promise<string> {
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user