Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 53 additions & 2 deletions packages/ai/src/utils/oauth/api-key-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
}
Expand All @@ -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<void> {
const timeoutSignal = AbortSignal.timeout(VALIDATION_TIMEOUT_MS);
Expand All @@ -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
}
Expand Down
133 changes: 133 additions & 0 deletions packages/ai/test/api-key-validation.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return validateApiKeyAgainstModelsEndpoint({
provider: "Synthetic",
apiKey: "sk-test",
modelsUrl: "https://example.invalid/v1/models",
});
}

function validateChatCompletions(): Promise<void> {
return validateOpenAICompatibleApiKey({
provider: "Cerebras",
apiKey: "csk-test",
baseUrl: "https://example.invalid/v1",
model: "test-model",
});
}

async function validationErrorMessage(validation: () => Promise<void>): Promise<string> {
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("<html>captive portal</html>", { 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(`<html>${"x".repeat(5000)}</html>`, { 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");
});
});
Loading