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 c87b225..4cef3bd 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,324 +1,348 @@ -/** - * 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"); + } +} + +/** + * 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 + * + * @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.` + ); + } +} + +/** + * 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..d02a2b9 --- /dev/null +++ b/test/unit/validation-max-tokens.test.ts @@ -0,0 +1,56 @@ +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("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 + // 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(1_000_001); + } 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", () => { + // 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", () => { + expect(() => validateMaxTokens(1.5)).toThrow(/integer/); + expect(() => validateMaxTokens(0)).toThrow(/positive/); + expect(() => validateMaxTokens(-1)).toThrow(/positive/); + }); +});