From 8bea9639c3aa2de09bf1aeed1e4fd87759bec2b7 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Tue, 21 Jul 2026 00:01:57 -0500 Subject: [PATCH 1/2] fix(validation): stop capping maxTokens below what models actually serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same defect as the Python SDK, same hardcoded 100000. `validateMaxTokens` rejected anything above it client-side, so the SDK — not the model, not the gateway — was the binding constraint on every caller. Verified against the live gateway 2026-07-21 by bypassing the guard: 19 models advertise a ceiling above 100000 and all 19 accepted it, including zai/glm-5.2 at 262144 and the entire 128000 class (claude-opus-4.8, claude-sonnet-5, claude-fable-5, gpt-5.6-sol/terra/luna, gpt-5.5, gpt-5.4, gpt-5.3-codex, glm-5/5.1/5-turbo). Zero rejections. The old message, "maxTokens too large (maximum: 100000)", reads like a provider response. It was taken for one during an investigation and recorded as an upstream model ceiling in a downstream token table — 19 identical "rejections" that never left the process. MAX_TOKENS_SANITY_LIMIT is now 1_000_000 and exported, documented as a typo guard, with a message stating the number is the SDK's and the gateway owns the real limit. The bound stays because a stray 1e9 or a byte count should fail locally rather than become a payment quote; it just must never be reachable by a real request. Adds test/unit/validation-max-tokens.test.ts — the guard had no coverage at all. Pins the real ceilings (128000, 262144), the typo case, that the bound sits above any plausible model, and that the message does not describe itself as a model limit. Suite 216 pass, typecheck clean. --- src/validation.ts | 671 ++++++++++++------------ test/unit/validation-max-tokens.test.ts | 45 ++ 2 files changed, 392 insertions(+), 324 deletions(-) create mode 100644 test/unit/validation-max-tokens.test.ts diff --git a/src/validation.ts b/src/validation.ts index c87b225..38b9583 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,324 +1,347 @@ -/** - * Input validation and security utilities for BlockRun LLM SDK. - * - * This module provides validation functions to ensure: - * - Private keys are properly formatted - * - API URLs use HTTPS - * - Server responses don't leak sensitive information - * - Resource URLs match expected domains - */ - -import type { Account } from "viem/accounts"; - -/** - * Allowed domains for localhost development. - * Production domains are enforced to use HTTPS. - */ -const LOCALHOST_DOMAINS = ["localhost", "127.0.0.1"]; - -/** - * Known LLM providers (for optional validation). - */ -export const KNOWN_PROVIDERS = new Set([ - "openai", - "anthropic", - "google", - "deepseek", - "mistralai", - "meta-llama", - "together", - "xai", - "moonshot", - "nvidia", - "minimax", - "zai", -]); - -/** - * Validates that a model ID is a non-empty string. - * - * @param model - The model ID (e.g., "openai/gpt-5.2", "anthropic/claude-sonnet-4.5") - * @throws {Error} If the model is invalid - * - * @example - * validateModel("openai/gpt-5.2"); - */ -export function validateModel(model: string): void { - if (!model || typeof model !== "string") { - throw new Error("Model must be a non-empty string"); - } -} - -/** - * Validates that max_tokens is an integer between 1 and 100,000. - * - * @param maxTokens - Maximum number of tokens to generate - * @throws {Error} If maxTokens is invalid - * - * @example - * validateMaxTokens(1000); - */ -export function validateMaxTokens(maxTokens?: number): void { - if (maxTokens === undefined || maxTokens === null) { - return; - } - - if (typeof maxTokens !== "number" || !Number.isInteger(maxTokens)) { - throw new Error("maxTokens must be an integer"); - } - - if (maxTokens < 1) { - throw new Error("maxTokens must be positive (minimum: 1)"); - } - - if (maxTokens > 100000) { - throw new Error("maxTokens too large (maximum: 100000)"); - } -} - -/** - * Validates that temperature is a number between 0 and 2. - * - * @param temperature - Sampling temperature (0-2) - * @throws {Error} If temperature is invalid - * - * @example - * validateTemperature(0.7); - */ -export function validateTemperature(temperature?: number): void { - if (temperature === undefined || temperature === null) { - return; - } - - if (typeof temperature !== "number") { - throw new Error("temperature must be a number"); - } - - if (temperature < 0 || temperature > 2) { - throw new Error("temperature must be between 0 and 2"); - } -} - -/** - * Validates that top_p is a number between 0 and 1. - * - * @param topP - Top-p sampling parameter (0-1) - * @throws {Error} If topP is invalid - * - * @example - * validateTopP(0.9); - */ -export function validateTopP(topP?: number): void { - if (topP === undefined || topP === null) { - return; - } - - if (typeof topP !== "number") { - throw new Error("topP must be a number"); - } - - if (topP < 0 || topP > 1) { - throw new Error("topP must be between 0 and 1"); - } -} - -/** - * Validates that a private key is properly formatted. - * - * @param key - The private key to validate - * @throws {Error} If the key format is invalid - * - * @example - * validatePrivateKey("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); - */ -export function validatePrivateKey(key: string): void { - // Must be a string - if (typeof key !== "string") { - throw new Error("Private key must be a string"); - } - - // Must start with 0x - if (!key.startsWith("0x")) { - throw new Error("Private key must start with 0x"); - } - - // Must be exactly 66 characters (0x + 64 hex chars) - if (key.length !== 66) { - throw new Error( - "Private key must be 66 characters (0x + 64 hexadecimal characters)" - ); - } - - // Must contain only valid hexadecimal characters - if (!/^0x[0-9a-fA-F]{64}$/.test(key)) { - throw new Error( - "Private key must contain only hexadecimal characters (0-9, a-f, A-F)" - ); - } -} - -/** - * Validates that an API URL is secure and properly formatted. - * - * @param url - The API URL to validate - * @throws {Error} If the URL is invalid or insecure - * - * @example - * validateApiUrl("https://blockrun.ai/api"); - * validateApiUrl("http://localhost:3000"); // OK for development - */ -export function validateApiUrl(url: string): void { - let parsed: URL; - - try { - parsed = new URL(url); - } catch { - throw new Error("Invalid API URL format"); - } - - // Ensure we have a valid protocol first - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error( - `Invalid protocol: ${parsed.protocol}. Use http:// or https://` - ); - } - - // Check HTTPS requirement for non-localhost - const isLocalhost = LOCALHOST_DOMAINS.includes(parsed.hostname); - - if (parsed.protocol !== "https:" && !isLocalhost) { - throw new Error( - "API URL must use HTTPS for non-localhost endpoints. " + - `Use https:// instead of ${parsed.protocol}//` - ); - } -} - -/** - * Sanitizes API error responses to prevent information leakage. - * - * Only exposes safe error fields to the caller, filtering out: - * - Internal stack traces - * - Server-side paths - * - API keys or tokens - * - Debugging information - * - * @param errorBody - The raw error response from the API - * @returns Sanitized error object with only safe fields - * - * @example - * const sanitized = sanitizeErrorResponse({ - * error: "Invalid model", - * internal_stack: "/var/app/handler.js:123", - * api_key: "secret" - * }); - * // Returns: { message: "Invalid model", code: undefined } - */ -export function sanitizeErrorResponse(errorBody: unknown): unknown { - // If not an object, return generic error - if (typeof errorBody !== "object" || errorBody === null) { - return { message: "API request failed" }; - } - - const body = errorBody as Record; - - // Only expose safe fields - return { - message: - typeof body.error === "string" ? body.error : "API request failed", - code: typeof body.code === "string" ? body.code : undefined, - }; -} - -/** - * Validates a resource URL from the server to prevent redirection attacks. - * - * Ensures that the resource URL's hostname matches the API's hostname. - * If domains don't match, returns a safe default URL instead. - * - * @param url - The resource URL provided by the server - * @param baseUrl - The base API URL (trusted) - * @returns The validated URL or a safe default - * - * @example - * validateResourceUrl( - * "https://blockrun.ai/api/v1/chat", - * "https://blockrun.ai/api" - * ); - * // Returns: "https://blockrun.ai/api/v1/chat" - * - * validateResourceUrl( - * "https://malicious.com/steal", - * "https://blockrun.ai/api" - * ); - * // Returns: "https://blockrun.ai/api/v1/chat/completions" (safe default) - */ -export function validateResourceUrl(url: string, baseUrl: string): string { - try { - const parsed = new URL(url); - const baseParsed = new URL(baseUrl); - - // Resource URL hostname must match API hostname - if (parsed.hostname !== baseParsed.hostname) { - console.warn( - `Resource URL hostname mismatch: ${parsed.hostname} vs ${baseParsed.hostname}. ` + - `Using safe default instead.` - ); - return `${baseUrl}/v1/chat/completions`; - } - - // Ensure resource uses same protocol as base - if (parsed.protocol !== baseParsed.protocol) { - console.warn( - `Resource URL protocol mismatch: ${parsed.protocol} vs ${baseParsed.protocol}. ` + - `Using safe default instead.` - ); - return `${baseUrl}/v1/chat/completions`; - } - - return url; - } catch { - // Invalid URL format, return safe default - console.warn(`Invalid resource URL format: ${url}. Using safe default.`); - return `${baseUrl}/v1/chat/completions`; - } -} - -/** - * Safely extracts the private key from a viem Account object. - * - * Note: Modern viem versions (2.x+) do NOT expose the private key on the - * Account object - the 'source' property contains the account type name - * (e.g., "privateKey"), not the actual key. The BlockRun SDK stores the - * private key separately in the client constructors. - * - * @param account - The viem Account object - * @returns The private key as a hex string - * @throws {Error} If the private key cannot be extracted - * - * @internal - * @deprecated Use the private key stored in client instead - */ -export function extractPrivateKey(account: Account): `0x${string}` { - // Check 'source' property - must be a valid hex private key, not just a string - if ("source" in account && typeof account.source === "string") { - const source = account.source; - // Validate it looks like a private key (0x + 64 hex chars) - if (source.startsWith("0x") && source.length === 66 && /^0x[0-9a-fA-F]{64}$/.test(source)) { - return source as `0x${string}`; - } - } - - // Check 'key' property (older viem versions) - if ("key" in account && typeof account.key === "string") { - const key = account.key as string; - if (key.startsWith("0x") && key.length === 66 && /^0x[0-9a-fA-F]{64}$/.test(key)) { - return key as `0x${string}`; - } - } - - throw new Error( - "Unable to extract private key from account. " + - "This may indicate an incompatible viem version." - ); -} +/** + * Input validation and security utilities for BlockRun LLM SDK. + * + * This module provides validation functions to ensure: + * - Private keys are properly formatted + * - API URLs use HTTPS + * - Server responses don't leak sensitive information + * - Resource URLs match expected domains + */ + +import type { Account } from "viem/accounts"; + +/** + * Allowed domains for localhost development. + * Production domains are enforced to use HTTPS. + */ +const LOCALHOST_DOMAINS = ["localhost", "127.0.0.1"]; + +/** + * Known LLM providers (for optional validation). + */ +export const KNOWN_PROVIDERS = new Set([ + "openai", + "anthropic", + "google", + "deepseek", + "mistralai", + "meta-llama", + "together", + "xai", + "moonshot", + "nvidia", + "minimax", + "zai", +]); + +/** + * Validates that a model ID is a non-empty string. + * + * @param model - The model ID (e.g., "openai/gpt-5.2", "anthropic/claude-sonnet-4.5") + * @throws {Error} If the model is invalid + * + * @example + * validateModel("openai/gpt-5.2"); + */ +export function validateModel(model: string): void { + if (!model || typeof model !== "string") { + throw new Error("Model must be a non-empty string"); + } +} + +/** + * Validates that max_tokens is an integer between 1 and 100,000. + * + * @param maxTokens - Maximum number of tokens to generate + * @throws {Error} If maxTokens is invalid + * + * @example + * validateMaxTokens(1000); + */ +export function validateMaxTokens(maxTokens?: number): void { + if (maxTokens === undefined || maxTokens === null) { + return; + } + + if (typeof maxTokens !== "number" || !Number.isInteger(maxTokens)) { + throw new Error("maxTokens must be an integer"); + } + + if (maxTokens < 1) { + throw new Error("maxTokens must be positive (minimum: 1)"); + } + + if (maxTokens > MAX_TOKENS_SANITY_LIMIT) { + throw new Error( + `maxTokens implausibly large (client-side sanity limit: ${MAX_TOKENS_SANITY_LIMIT}). ` + + `This is not a model limit — the gateway enforces the real per-model ceiling and reports it.` + ); + } +} + +/** + * Client-side typo guard, NOT a model limit. The gateway already enforces the + * real per-model ceiling and rejects with that model's own number, so anything + * hardcoded here can only be wrong in one direction: too low. + * + * This was 100000, and it silently capped every SDK caller below what models + * actually support. Verified against the live gateway 2026-07-21 with the + * guard bypassed: zai/glm-5.2 accepts 262144, and the whole 128000 class + * accepts 128000 (claude-opus-4.8, claude-sonnet-5, claude-fable-5, + * gpt-5.6-sol/terra/luna, gpt-5.5, gpt-5.4, gpt-5.3-codex, glm-5/5.1/5-turbo). + * 19 models advertised above 100000; all 19 accepted their ceiling. Callers + * asking for them got an error that never reached the network and named a + * limit no provider had set. + * + * Keep a bound so an obvious mistake (1e9, a byte count, a timestamp) fails + * fast locally instead of becoming a payment quote. Set it far above any real + * model so it can never be the binding constraint again. + */ +export const MAX_TOKENS_SANITY_LIMIT = 1_000_000; + +/** + * Validates that temperature is a number between 0 and 2. + * + * @param temperature - Sampling temperature (0-2) + * @throws {Error} If temperature is invalid + * + * @example + * validateTemperature(0.7); + */ +export function validateTemperature(temperature?: number): void { + if (temperature === undefined || temperature === null) { + return; + } + + if (typeof temperature !== "number") { + throw new Error("temperature must be a number"); + } + + if (temperature < 0 || temperature > 2) { + throw new Error("temperature must be between 0 and 2"); + } +} + +/** + * Validates that top_p is a number between 0 and 1. + * + * @param topP - Top-p sampling parameter (0-1) + * @throws {Error} If topP is invalid + * + * @example + * validateTopP(0.9); + */ +export function validateTopP(topP?: number): void { + if (topP === undefined || topP === null) { + return; + } + + if (typeof topP !== "number") { + throw new Error("topP must be a number"); + } + + if (topP < 0 || topP > 1) { + throw new Error("topP must be between 0 and 1"); + } +} + +/** + * Validates that a private key is properly formatted. + * + * @param key - The private key to validate + * @throws {Error} If the key format is invalid + * + * @example + * validatePrivateKey("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); + */ +export function validatePrivateKey(key: string): void { + // Must be a string + if (typeof key !== "string") { + throw new Error("Private key must be a string"); + } + + // Must start with 0x + if (!key.startsWith("0x")) { + throw new Error("Private key must start with 0x"); + } + + // Must be exactly 66 characters (0x + 64 hex chars) + if (key.length !== 66) { + throw new Error( + "Private key must be 66 characters (0x + 64 hexadecimal characters)" + ); + } + + // Must contain only valid hexadecimal characters + if (!/^0x[0-9a-fA-F]{64}$/.test(key)) { + throw new Error( + "Private key must contain only hexadecimal characters (0-9, a-f, A-F)" + ); + } +} + +/** + * Validates that an API URL is secure and properly formatted. + * + * @param url - The API URL to validate + * @throws {Error} If the URL is invalid or insecure + * + * @example + * validateApiUrl("https://blockrun.ai/api"); + * validateApiUrl("http://localhost:3000"); // OK for development + */ +export function validateApiUrl(url: string): void { + let parsed: URL; + + try { + parsed = new URL(url); + } catch { + throw new Error("Invalid API URL format"); + } + + // Ensure we have a valid protocol first + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error( + `Invalid protocol: ${parsed.protocol}. Use http:// or https://` + ); + } + + // Check HTTPS requirement for non-localhost + const isLocalhost = LOCALHOST_DOMAINS.includes(parsed.hostname); + + if (parsed.protocol !== "https:" && !isLocalhost) { + throw new Error( + "API URL must use HTTPS for non-localhost endpoints. " + + `Use https:// instead of ${parsed.protocol}//` + ); + } +} + +/** + * Sanitizes API error responses to prevent information leakage. + * + * Only exposes safe error fields to the caller, filtering out: + * - Internal stack traces + * - Server-side paths + * - API keys or tokens + * - Debugging information + * + * @param errorBody - The raw error response from the API + * @returns Sanitized error object with only safe fields + * + * @example + * const sanitized = sanitizeErrorResponse({ + * error: "Invalid model", + * internal_stack: "/var/app/handler.js:123", + * api_key: "secret" + * }); + * // Returns: { message: "Invalid model", code: undefined } + */ +export function sanitizeErrorResponse(errorBody: unknown): unknown { + // If not an object, return generic error + if (typeof errorBody !== "object" || errorBody === null) { + return { message: "API request failed" }; + } + + const body = errorBody as Record; + + // Only expose safe fields + return { + message: + typeof body.error === "string" ? body.error : "API request failed", + code: typeof body.code === "string" ? body.code : undefined, + }; +} + +/** + * Validates a resource URL from the server to prevent redirection attacks. + * + * Ensures that the resource URL's hostname matches the API's hostname. + * If domains don't match, returns a safe default URL instead. + * + * @param url - The resource URL provided by the server + * @param baseUrl - The base API URL (trusted) + * @returns The validated URL or a safe default + * + * @example + * validateResourceUrl( + * "https://blockrun.ai/api/v1/chat", + * "https://blockrun.ai/api" + * ); + * // Returns: "https://blockrun.ai/api/v1/chat" + * + * validateResourceUrl( + * "https://malicious.com/steal", + * "https://blockrun.ai/api" + * ); + * // Returns: "https://blockrun.ai/api/v1/chat/completions" (safe default) + */ +export function validateResourceUrl(url: string, baseUrl: string): string { + try { + const parsed = new URL(url); + const baseParsed = new URL(baseUrl); + + // Resource URL hostname must match API hostname + if (parsed.hostname !== baseParsed.hostname) { + console.warn( + `Resource URL hostname mismatch: ${parsed.hostname} vs ${baseParsed.hostname}. ` + + `Using safe default instead.` + ); + return `${baseUrl}/v1/chat/completions`; + } + + // Ensure resource uses same protocol as base + if (parsed.protocol !== baseParsed.protocol) { + console.warn( + `Resource URL protocol mismatch: ${parsed.protocol} vs ${baseParsed.protocol}. ` + + `Using safe default instead.` + ); + return `${baseUrl}/v1/chat/completions`; + } + + return url; + } catch { + // Invalid URL format, return safe default + console.warn(`Invalid resource URL format: ${url}. Using safe default.`); + return `${baseUrl}/v1/chat/completions`; + } +} + +/** + * Safely extracts the private key from a viem Account object. + * + * Note: Modern viem versions (2.x+) do NOT expose the private key on the + * Account object - the 'source' property contains the account type name + * (e.g., "privateKey"), not the actual key. The BlockRun SDK stores the + * private key separately in the client constructors. + * + * @param account - The viem Account object + * @returns The private key as a hex string + * @throws {Error} If the private key cannot be extracted + * + * @internal + * @deprecated Use the private key stored in client instead + */ +export function extractPrivateKey(account: Account): `0x${string}` { + // Check 'source' property - must be a valid hex private key, not just a string + if ("source" in account && typeof account.source === "string") { + const source = account.source; + // Validate it looks like a private key (0x + 64 hex chars) + if (source.startsWith("0x") && source.length === 66 && /^0x[0-9a-fA-F]{64}$/.test(source)) { + return source as `0x${string}`; + } + } + + // Check 'key' property (older viem versions) + if ("key" in account && typeof account.key === "string") { + const key = account.key as string; + if (key.startsWith("0x") && key.length === 66 && /^0x[0-9a-fA-F]{64}$/.test(key)) { + return key as `0x${string}`; + } + } + + throw new Error( + "Unable to extract private key from account. " + + "This may indicate an incompatible viem version." + ); +} diff --git a/test/unit/validation-max-tokens.test.ts b/test/unit/validation-max-tokens.test.ts new file mode 100644 index 0000000..96b2e7c --- /dev/null +++ b/test/unit/validation-max-tokens.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { validateMaxTokens, MAX_TOKENS_SANITY_LIMIT } from "../../src/validation"; + +describe("validateMaxTokens", () => { + it("accepts the ceilings the gateway actually serves", () => { + // Regression. This bound was 100000, which rejected every ceiling above it + // *client-side* — the caller got an Error that never reached the network, + // naming a limit no provider had set. Probed against the live gateway on + // 2026-07-21 with the guard bypassed: 19 models advertise more than 100000 + // and all 19 accepted their advertised ceiling. + expect(() => validateMaxTokens(128_000)).not.toThrow(); // opus-4.8, sonnet-5, gpt-5.6, glm-5 + expect(() => validateMaxTokens(262_144)).not.toThrow(); // zai/glm-5.2, the highest served + }); + + it("still catches an obvious mistake", () => { + // The bound is a typo guard: a byte count, a timestamp, or a stray 1e9 + // should fail locally rather than become a payment quote. + expect(() => validateMaxTokens(2_000_000)).toThrow(/implausibly large/); + }); + + it("does not describe itself as a model limit", () => { + // The old message read "maxTokens too large (maximum: 100000)", which is + // what led a caller to record 100000 as an upstream model ceiling and + // propagate it into a downstream token table. The text has to make clear + // the number is the SDK's, not the model's. + let message = ""; + try { + validateMaxTokens(MAX_TOKENS_SANITY_LIMIT + 1); + } catch (e) { + message = (e as Error).message; + } + expect(message).toMatch(/not a model limit/); + expect(message).toMatch(/sanity limit/); + }); + + it("keeps the bound above every ceiling a real model could plausibly serve", () => { + expect(MAX_TOKENS_SANITY_LIMIT).toBeGreaterThan(262_144); + }); + + it("still rejects non-integers and non-positive values", () => { + expect(() => validateMaxTokens(1.5)).toThrow(/integer/); + expect(() => validateMaxTokens(0)).toThrow(/positive/); + expect(() => validateMaxTokens(-1)).toThrow(/positive/); + }); +}); From 796edc197de98a9622c2f1e26cb15aee85df4bb1 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Tue, 21 Jul 2026 00:13:59 -0500 Subject: [PATCH 2/2] test/docs: export the bound, cover the range, state the staleness limit Three gaps in the original commit, caught in review: - MAX_TOKENS_SANITY_LIMIT was exported from validation.ts but not re-exported from src/index.ts, so package consumers could not actually read it. - Nothing tested the range between the largest real model and the typo case. Lowering the constant back toward 262_144 would not have failed a single test, which is precisely the regression this file exists to catch. - The comment presented the probe results as if they were a maintained invariant. They are an as-of-date snapshot: this repo ships no model catalog, so CI cannot detect when those numbers go stale. Says so now. --- src/index.ts | 1 + src/validation.ts | 43 +++++++++++++------------ test/unit/validation-max-tokens.test.ts | 15 +++++++-- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5e40217..0a5b1c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -242,6 +242,7 @@ export type { BlockRunAnthropicOptions } from "./anthropic-compat"; // Validation utilities export { KNOWN_PROVIDERS, + MAX_TOKENS_SANITY_LIMIT, validateModel, validateMaxTokens, validateTemperature, diff --git a/src/validation.ts b/src/validation.ts index 38b9583..4cef3bd 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -50,7 +50,28 @@ export function validateModel(model: string): void { } /** - * Validates that max_tokens is an integer between 1 and 100,000. + * Client-side typo guard, NOT a model limit. The gateway already enforces the + * real per-model ceiling and rejects with that model's own number, so anything + * hardcoded here can only be wrong in one direction: too low. + * + * This was 100000, which rejected any caller asking for more than that before + * the request left the process. Verified against the live gateway 2026-07-21 + * with the guard bypassed: zai/glm-5.2 accepted 262144, and the whole 128000 + * class accepted 128000 (claude-opus-4.8, claude-sonnet-5, claude-fable-5, + * gpt-5.6-sol/terra/luna, gpt-5.5, gpt-5.4, gpt-5.3-codex, glm-5/5.1/5-turbo). + * 19 models advertised above 100000; all 19 accepted their ceiling. Those are + * as-of-date probe results, not a maintained invariant — nothing in this repo + * ships a model catalog, so CI cannot detect when they go stale. + * + * Keep a bound so an obvious mistake (1e9, a byte count, a timestamp) fails + * fast locally instead of becoming a payment quote. Set it far above any real + * model so it can never be the binding constraint again. + */ +export const MAX_TOKENS_SANITY_LIMIT = 1_000_000; + +/** + * Validates that max_tokens is a positive integer no larger than + * {@link MAX_TOKENS_SANITY_LIMIT}. * * @param maxTokens - Maximum number of tokens to generate * @throws {Error} If maxTokens is invalid @@ -79,26 +100,6 @@ export function validateMaxTokens(maxTokens?: number): void { } } -/** - * Client-side typo guard, NOT a model limit. The gateway already enforces the - * real per-model ceiling and rejects with that model's own number, so anything - * hardcoded here can only be wrong in one direction: too low. - * - * This was 100000, and it silently capped every SDK caller below what models - * actually support. Verified against the live gateway 2026-07-21 with the - * guard bypassed: zai/glm-5.2 accepts 262144, and the whole 128000 class - * accepts 128000 (claude-opus-4.8, claude-sonnet-5, claude-fable-5, - * gpt-5.6-sol/terra/luna, gpt-5.5, gpt-5.4, gpt-5.3-codex, glm-5/5.1/5-turbo). - * 19 models advertised above 100000; all 19 accepted their ceiling. Callers - * asking for them got an error that never reached the network and named a - * limit no provider had set. - * - * Keep a bound so an obvious mistake (1e9, a byte count, a timestamp) fails - * fast locally instead of becoming a payment quote. Set it far above any real - * model so it can never be the binding constraint again. - */ -export const MAX_TOKENS_SANITY_LIMIT = 1_000_000; - /** * Validates that temperature is a number between 0 and 2. * diff --git a/test/unit/validation-max-tokens.test.ts b/test/unit/validation-max-tokens.test.ts index 96b2e7c..d02a2b9 100644 --- a/test/unit/validation-max-tokens.test.ts +++ b/test/unit/validation-max-tokens.test.ts @@ -18,6 +18,15 @@ describe("validateMaxTokens", () => { expect(() => validateMaxTokens(2_000_000)).toThrow(/implausibly large/); }); + it("accepts everything up to and including the bound", () => { + // Without these the whole range between today's largest model and the + // typo case is untested, so lowering the constant back toward 262_144 + // would not fail a single test — which is the bug this file exists for. + expect(() => validateMaxTokens(999_999)).not.toThrow(); + expect(() => validateMaxTokens(MAX_TOKENS_SANITY_LIMIT)).not.toThrow(); + expect(() => validateMaxTokens(MAX_TOKENS_SANITY_LIMIT + 1)).toThrow(/implausibly large/); + }); + it("does not describe itself as a model limit", () => { // The old message read "maxTokens too large (maximum: 100000)", which is // what led a caller to record 100000 as an upstream model ceiling and @@ -25,7 +34,7 @@ describe("validateMaxTokens", () => { // the number is the SDK's, not the model's. let message = ""; try { - validateMaxTokens(MAX_TOKENS_SANITY_LIMIT + 1); + validateMaxTokens(1_000_001); } catch (e) { message = (e as Error).message; } @@ -34,7 +43,9 @@ describe("validateMaxTokens", () => { }); it("keeps the bound above every ceiling a real model could plausibly serve", () => { - expect(MAX_TOKENS_SANITY_LIMIT).toBeGreaterThan(262_144); + // Pin the exact value, not just "> today's largest". A bound of 262_145 + // satisfies `> 262_144` and reintroduces the defect one token higher. + expect(MAX_TOKENS_SANITY_LIMIT).toBe(1_000_000); }); it("still rejects non-integers and non-positive values", () => {