diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 52dafa1674..02fbd3f6b4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - Cursor native tool calls (shell/read/write/… oneof variants) now convert their protobuf payloads into plain JSON-safe data before attaching them as toolCall `arguments`: `$typeName` markers are stripped, safe-range bigints become numbers (decimal strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64 strings, and cycles/functions collapse to null. Raw protobuf-es payloads carry `bigint` fields (`fileSize`, `durationMs`, `fileOutputThresholdBytes`, …) that defeat `JSON.stringify`, which broke managed snapshot staging, JSONL transcript persistence, and provider replay — the issue #4578 local-snapshot producer defect class fixed at its producer boundary. +- `validateApiKeyAgainstModelsEndpoint` no longer accepts an API key on HTTP status alone. A 200 whose body is not JSON or carries no recognizable model list (OpenAI-compatible `data` array, gateway `models` array, or a bare array) now fails closed with an actionable error — previously a captive portal or broken gateway answering 200 with an HTML page silently validated and stored the key. Affects every provider using `kind: "models-endpoint"` (Synthetic, DeepSeek, DeepInfra, Fireworks, BizRouter, NanoGPT, OpenGateway, ZenMux, Fugu). Upstream bodies echoed into validation errors are now bounded to 200 characters on both validators. - Generic OpenAI-compatible `/v1/models` discovery now reads served context-window and output-limit metadata instead of defaulting every dynamically listed model to the unknown-window sentinel. `max_model_len` (vLLM/SGLang/oMLX), `context_length`, `context_window`, `max_context_length` (LM Studio), and `max_position_embeddings` populate `contextWindow` in that precedence order, while `max_tokens`/`max_output_tokens` populate `maxTokens`; total-window fields never leak into the output-token ceiling. Malformed values (non-finite, zero, negative, non-numeric) are rejected per-field with fallback to the next candidate, so a `1e400`-style catalog entry can no longer poison compaction thresholds or compact-input budgets. - Codex websocket requests now abort and close their transport when the downstream event-stream consumer returns early (including managed provisional-buffer rejection), so the next turn opens a clean connection instead of inheriting `websocket request already in progress` (#4534). - Refreshed the bundled ZAI catalog with GLM-5.3 and made it the provider's default model. diff --git a/packages/ai/src/utils/oauth/api-key-validation.ts b/packages/ai/src/utils/oauth/api-key-validation.ts index baeacc5906..81f49700ab 100644 --- a/packages/ai/src/utils/oauth/api-key-validation.ts +++ b/packages/ai/src/utils/oauth/api-key-validation.ts @@ -15,6 +15,14 @@ type ModelListValidationOptions = { const VALIDATION_TIMEOUT_MS = 15_000; +/** Most characters of an upstream body echoed into a validation error. */ +const VALIDATION_DETAILS_LIMIT = 200; + +function boundedDetails(text: string): string { + const trimmed = text.trim(); + return trimmed.length > VALIDATION_DETAILS_LIMIT ? `${trimmed.slice(0, VALIDATION_DETAILS_LIMIT)}…` : trimmed; +} + /** * Validate an API key against an OpenAI-compatible chat completions endpoint. * @@ -45,7 +53,7 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa let details = ""; try { - details = (await response.text()).trim(); + details = boundedDetails(await response.text()); } catch { // ignore body parse errors, status is enough } @@ -55,12 +63,30 @@ export async function validateOpenAICompatibleApiKey(options: OpenAICompatibleVa : `${options.provider} API key validation failed (${response.status})`; throw new Error(message); } +/** + * Whether a 200 body is a recognizable model list. OpenAI-compatible endpoints + * return `{"object":"list","data":[...]}`; some gateways answer with a bare + * array or `{"models":[...]}`. Anything else — including valid JSON without a + * list — is not evidence that the credential reached a models endpoint. + */ +function isModelList(parsed: unknown): boolean { + if (Array.isArray(parsed)) return true; + if (typeof parsed !== "object" || parsed === null) return false; + const record = parsed as { data?: unknown; models?: unknown }; + return Array.isArray(record.data) || Array.isArray(record.models); +} /** * 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. + * + * A 200 status alone is NOT accepted: a captive portal, misrouting proxy, or + * broken gateway can answer 200 with an HTML page or an empty JSON object, and + * accepting the key on status alone would store a credential that was never + * actually checked. The body must parse as JSON and carry a recognizable model + * list before the key is considered validated. */ export async function validateApiKeyAgainstModelsEndpoint(options: ModelListValidationOptions): Promise { const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS); @@ -75,12 +101,37 @@ export async function validateApiKeyAgainstModelsEndpoint(options: ModelListVali }); if (response.ok) { + let body: string; + try { + body = await response.text(); + } catch (error) { + throw new Error( + `${options.provider} API key validation failed: the models endpoint response body could not be read (${ + error instanceof Error ? error.message : String(error) + })`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + throw new Error( + `${options.provider} API key validation failed: the models endpoint returned ${response.status} with a non-JSON body` + + `${body.trim() ? ` (${boundedDetails(body)})` : ""}. Refusing to accept the key on status alone.`, + ); + } + if (!isModelList(parsed)) { + throw new Error( + `${options.provider} API key validation failed: the models endpoint returned ${response.status} without a recognizable ` + + `model list. Refusing to accept the key on status alone.`, + ); + } return; } let details = ""; try { - details = (await response.text()).trim(); + details = boundedDetails(await response.text()); } catch { // ignore body parse errors, status is enough } diff --git a/packages/ai/test/api-key-validation.test.ts b/packages/ai/test/api-key-validation.test.ts new file mode 100644 index 0000000000..cab1a0e8d5 --- /dev/null +++ b/packages/ai/test/api-key-validation.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + validateApiKeyAgainstModelsEndpoint, + validateOpenAICompatibleApiKey, +} from "@gajae-code/ai/utils/oauth/api-key-validation"; + +const realFetch = globalThis.fetch; + +/** Install a fetch stub answering the models endpoint with `response`. */ +function stubFetch(response: () => Response, capture?: { url?: string; authorization?: string }): void { + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + if (capture) { + capture.url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + capture.authorization = new Headers(init?.headers).get("authorization") ?? ""; + } + return response(); + }) as typeof globalThis.fetch; +} + +function validate(): Promise { + return validateApiKeyAgainstModelsEndpoint({ + provider: "Synthetic", + apiKey: "sk-test", + modelsUrl: "https://example.invalid/v1/models", + }); +} + +function validateChatCompletions(): Promise { + return validateOpenAICompatibleApiKey({ + provider: "Cerebras", + apiKey: "csk-test", + baseUrl: "https://example.invalid/v1", + model: "test-model", + }); +} + +async function validationErrorMessage(validation: () => Promise): Promise { + try { + await validation(); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + throw new Error("Expected validation to fail"); +} + +describe("validateApiKeyAgainstModelsEndpoint", () => { + beforeEach(() => { + globalThis.fetch = realFetch; + }); + afterEach(() => { + globalThis.fetch = realFetch; + }); + + it("sends the key as a bearer token to the models endpoint", async () => { + const capture: { url?: string; authorization?: string } = {}; + stubFetch(() => new Response(JSON.stringify({ object: "list", data: [] }), { status: 200 }), capture); + await validate(); + expect(capture.url).toBe("https://example.invalid/v1/models"); + expect(capture.authorization).toBe("Bearer sk-test"); + }); + + it("accepts an OpenAI-compatible list, including an empty one", async () => { + stubFetch(() => new Response(JSON.stringify({ object: "list", data: [{ id: "m" }] }), { status: 200 })); + await validate(); + stubFetch(() => new Response(JSON.stringify({ object: "list", data: [] }), { status: 200 })); + await validate(); + }); + + it("accepts gateway list variants: bare array and models field", async () => { + stubFetch(() => new Response(JSON.stringify([{ id: "m" }]), { status: 200 })); + await validate(); + stubFetch(() => new Response(JSON.stringify({ models: [{ id: "m" }] }), { status: 200 })); + await validate(); + }); + + it("rejects a 200 with a non-JSON body instead of accepting on status alone", async () => { + stubFetch(() => new Response("captive portal", { status: 200 })); + await expect(validate()).rejects.toThrow(/non-JSON body.*status alone/s); + }); + + it("rejects malformed JSON returned with 200", async () => { + stubFetch(() => new Response('{"data":[', { status: 200 })); + await expect(validate()).rejects.toThrow(/non-JSON body.*status alone/s); + }); + + it("reports the actual status for another successful dataless response", async () => { + stubFetch(() => new Response(null, { status: 204 })); + await expect(validate()).rejects.toThrow(/returned 204 with a non-JSON body/); + }); + + it("rejects a 200 whose JSON carries no recognizable model list", async () => { + stubFetch(() => new Response(JSON.stringify({ object: "list" }), { status: 200 })); + await expect(validate()).rejects.toThrow(/without a recognizable model list/); + stubFetch(() => new Response(JSON.stringify({ data: "nope" }), { status: 200 })); + await expect(validate()).rejects.toThrow(/without a recognizable model list/); + stubFetch(() => new Response(JSON.stringify(null), { status: 200 })); + await expect(validate()).rejects.toThrow(/without a recognizable model list/); + }); + + it("rejects an unauthorized key with status and bounded details", async () => { + stubFetch(() => new Response("invalid api key", { status: 401 })); + await expect(validate()).rejects.toThrow(/validation failed \(401\): invalid api key/); + }); + + it("bounds huge upstream bodies echoed into error messages", async () => { + stubFetch(() => new Response("x".repeat(5000), { status: 500 })); + const message = await validationErrorMessage(validate); + expect(message).toContain("(500)"); + expect(message.length).toBeLessThan(400); + }); + + it("bounds a huge non-JSON 200 body echoed into the refusal", async () => { + stubFetch(() => new Response(`${"x".repeat(5000)}`, { status: 200 })); + const message = await validationErrorMessage(validate); + expect(message).toContain("non-JSON body"); + expect(message).toContain("status alone"); + expect(message.length).toBeLessThan(500); + }); + + it("bounds upstream bodies echoed by chat-completions validation", async () => { + stubFetch(() => new Response("x".repeat(5000), { status: 500 })); + const message = await validationErrorMessage(validateChatCompletions); + expect(message).toContain("Cerebras API key validation failed (500)"); + expect(message.length).toBeLessThan(400); + }); + + it("propagates network failures without accepting the key", async () => { + globalThis.fetch = (async () => { + throw new Error("network down"); + }) as unknown as typeof globalThis.fetch; + await expect(validate()).rejects.toThrow("network down"); + }); +});