diff --git a/packages/ai/README.md b/packages/ai/README.md index 7107fe95..4e8247ff 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -13,12 +13,12 @@ npm install @mariozechner/ai ```typescript import { AnthropicLLM } from '@mariozechner/ai/providers/anthropic'; import { OpenAICompletionsLLM } from '@mariozechner/ai/providers/openai-completions'; -import { GeminiLLM } from '@mariozechner/ai/providers/gemini'; +import { GoogleLLM } from '@mariozechner/ai/providers/gemini'; // Pick your provider - same API for all const llm = new AnthropicLLM('claude-sonnet-4-0'); // const llm = new OpenAICompletionsLLM('gpt-5-mini'); -// const llm = new GeminiLLM('gemini-2.5-flash'); +// const llm = new GoogleLLM('gemini-2.5-flash'); // Basic completion const response = await llm.complete({ diff --git a/packages/ai/package.json b/packages/ai/package.json index d173f604..d1140067 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -11,15 +11,11 @@ ], "scripts": { "clean": "rm -rf dist", - "models": "curl -s https://models.dev/api.json -o src/models.json", "generate-models": "npx tsx scripts/generate-models.ts", - "build": "npm run generate-models && tsc -p tsconfig.build.json && cp src/models.json dist/models.json", + "build": "npm run generate-models && tsc -p tsconfig.build.json", "check": "biome check --write .", - "test": "vitest", - "test:ui": "vitest --ui", - "test:old": "npx tsx --test test/providers.test.ts", - "extract-models": "npx tsx scripts/extract-openai-models.ts", - "prepublishOnly": "npm run clean && npm run models && npm run build" + "test": "vitest --run", + "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { "@anthropic-ai/sdk": "^0.60.0", @@ -48,7 +44,6 @@ }, "devDependencies": { "@types/node": "^24.3.0", - "@vitest/ui": "^3.2.4", "vitest": "^3.2.4" } } diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index b865a63b..defae115 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -3,263 +3,260 @@ import { readFileSync, writeFileSync } from "fs"; import { join } from "path"; -// Load the models.json file -const data = JSON.parse(readFileSync(join(process.cwd(), "src/models.json"), "utf-8")); - -// Categorize providers by their API type -const openaiModels: Record = {}; -const openaiCompatibleProviders: Record = {}; -const anthropicModels: Record = {}; -const geminiModels: Record = {}; - -for (const [providerId, provider] of Object.entries(data)) { - const p = provider as any; - - if (providerId === "openai") { - // All OpenAI models use the Responses API - openaiModels[providerId] = p; - } else if (providerId === "anthropic" || providerId === "google-vertex-anthropic") { - // Anthropic direct and via Vertex - anthropicModels[providerId] = p; - } else if (providerId === "google" || providerId === "google-vertex") { - // Google Gemini models - geminiModels[providerId] = p; - } else if (p.npm === "@ai-sdk/openai-compatible" || - p.npm === "@ai-sdk/groq" || - p.npm === "@ai-sdk/cerebras" || - p.npm === "@ai-sdk/fireworks" || - p.npm === "@ai-sdk/openrouter" || - p.npm === "@ai-sdk/openai" && providerId !== "openai" || - p.api?.includes("/v1") || - ["together", "ollama", "llama", "github-models", "groq", "cerebras", "openrouter", "fireworks"].includes(providerId)) { - // OpenAI-compatible providers - they all speak the OpenAI completions API - // Set default base URLs for known providers - if (!p.api) { - switch (providerId) { - case "groq": p.api = "https://api.groq.com/openai/v1"; break; - case "cerebras": p.api = "https://api.cerebras.com/v1"; break; - case "together": p.api = "https://api.together.xyz/v1"; break; - case "fireworks": p.api = "https://api.fireworks.ai/v1"; break; - } - } - openaiCompatibleProviders[providerId] = p; - } +interface ModelsDevModel { + id: string; + name: string; + tool_call?: boolean; + reasoning?: boolean; + limit?: { + context?: number; + output?: number; + }; + cost?: { + input?: number; + output?: number; + cache_read?: number; + cache_write?: number; + }; + modalities?: { + input?: string[]; + }; } -// Generate the TypeScript file -let output = `// This file is auto-generated by scripts/generate-models.ts +interface NormalizedModel { + id: string; + name: string; + provider: string; + reasoning: boolean; + input: ("text" | "image")[]; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; + contextWindow: number; + maxTokens: number; +} + +async function fetchOpenRouterModels(): Promise { + try { + console.log("🌐 Fetching models from OpenRouter API..."); + const response = await fetch("https://openrouter.ai/api/v1/models"); + const data = await response.json(); + + const models: NormalizedModel[] = []; + + for (const model of data.data) { + // Only include models that support tools + if (!model.supported_parameters?.includes("tools")) continue; + + // Parse provider from model ID + const [providerPrefix] = model.id.split("/"); + let provider = ""; + let modelKey = model.id; + + // Map provider prefixes to our provider names + if (model.id.startsWith("google/")) { + provider = "google"; + modelKey = model.id.replace("google/", ""); + } else if (model.id.startsWith("openai/")) { + provider = "openai"; + modelKey = model.id.replace("openai/", ""); + } else if (model.id.startsWith("anthropic/")) { + provider = "anthropic"; + modelKey = model.id.replace("anthropic/", ""); + } else if (model.id.startsWith("x-ai/")) { + provider = "xai"; + modelKey = model.id.replace("x-ai/", ""); + } else { + // All other models go through OpenRouter + provider = "openrouter"; + modelKey = model.id; // Keep full ID for OpenRouter + } + + // Skip if not one of our supported providers + if (!["google", "openai", "anthropic", "xai", "openrouter"].includes(provider)) { + continue; + } + + // Parse input modalities + const input: ("text" | "image")[] = ["text"]; + if (model.architecture?.modality?.includes("image")) { + input.push("image"); + } + + // Convert pricing from $/token to $/million tokens + const inputCost = parseFloat(model.pricing?.prompt || "0") * 1_000_000; + const outputCost = parseFloat(model.pricing?.completion || "0") * 1_000_000; + const cacheReadCost = parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000; + const cacheWriteCost = parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000; + + models.push({ + id: modelKey, + name: model.name, + provider, + reasoning: model.supported_parameters?.includes("reasoning") || false, + input, + cost: { + input: inputCost, + output: outputCost, + cacheRead: cacheReadCost, + cacheWrite: cacheWriteCost, + }, + contextWindow: model.context_length || 4096, + maxTokens: model.top_provider?.max_completion_tokens || 4096, + }); + } + + console.log(`āœ… Fetched ${models.length} tool-capable models from OpenRouter`); + return models; + } catch (error) { + console.error("āŒ Failed to fetch OpenRouter models:", error); + return []; + } +} + +function loadModelsDevData(): NormalizedModel[] { + try { + console.log("šŸ“ Loading models from models.json..."); + const data = JSON.parse(readFileSync(join(process.cwd(), "src/models.json"), "utf-8")); + + const models: NormalizedModel[] = []; + + // Process Groq models + if (data.groq?.models) { + for (const [modelId, model] of Object.entries(data.groq.models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + + models.push({ + id: modelId, + name: m.name || modelId, + provider: "groq", + reasoning: m.reasoning === true, + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + } + } + + // Process Cerebras models + if (data.cerebras?.models) { + for (const [modelId, model] of Object.entries(data.cerebras.models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + + models.push({ + id: modelId, + name: m.name || modelId, + provider: "cerebras", + reasoning: m.reasoning === true, + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + } + } + + console.log(`āœ… Loaded ${models.length} tool-capable models from models.dev`); + return models; + } catch (error) { + console.error("āŒ Failed to load models.dev data:", error); + return []; + } +} + +async function generateModels() { + // Fetch all models + const openRouterModels = await fetchOpenRouterModels(); + const modelsDevModels = loadModelsDevData(); + + // Combine models (models.dev takes priority for Groq/Cerebras) + const allModels = [...modelsDevModels, ...openRouterModels]; + + // Group by provider + const providers: Record = {}; + for (const model of allModels) { + if (!providers[model.provider]) { + providers[model.provider] = []; + } + providers[model.provider].push(model); + } + + // Generate TypeScript file + let output = `// This file is auto-generated by scripts/generate-models.ts // Do not edit manually - run 'npm run generate-models' to update -import type { ModalityInput, ModalityOutput } from "./models.js"; - -export interface ModelData { - id: string; - name: string; - reasoning: boolean; - tool_call: boolean; - attachment: boolean; - temperature: boolean; - knowledge?: string; - release_date: string; - last_updated: string; - modalities: { - input: ModalityInput[]; - output: ModalityOutput[]; - }; - open_weights: boolean; - limit: { - context: number; - output: number; - }; - cost?: { - input: number; - output: number; - cache_read?: number; - cache_write?: number; - }; -} - -export interface ProviderData { - id: string; - name: string; - baseUrl?: string; - env?: string[]; - models: Record; -} +import type { Model } from "./types.js"; +export const PROVIDERS = { `; -// Generate OpenAI models -output += `// OpenAI models - all use OpenAIResponsesLLM\n`; -output += `export const OPENAI_MODELS = {\n`; -for (const [providerId, provider] of Object.entries(openaiModels)) { - const p = provider as any; - for (const [modelId, model] of Object.entries(p.models || {})) { - const m = model as any; - output += ` "${modelId}": ${JSON.stringify(m, null, 8).split('\n').join('\n ')},\n`; - } -} -output += `} as const;\n\n`; + // Generate provider sections + for (const [providerId, models] of Object.entries(providers)) { + output += `\t${providerId}: {\n`; + output += `\t\tmodels: {\n`; -// Generate OpenAI-compatible providers -output += `// OpenAI-compatible providers - use OpenAICompletionsLLM\n`; -output += `export const OPENAI_COMPATIBLE_PROVIDERS = {\n`; -for (const [providerId, provider] of Object.entries(openaiCompatibleProviders)) { - const p = provider as any; - output += ` "${providerId}": {\n`; - output += ` id: "${providerId}",\n`; - output += ` name: "${p.name}",\n`; - if (p.api) { - output += ` baseUrl: "${p.api}",\n`; - } - if (p.env) { - output += ` env: ${JSON.stringify(p.env)},\n`; - } - output += ` models: {\n`; - for (const [modelId, model] of Object.entries(p.models || {})) { - const m = model as any; - output += ` "${modelId}": ${JSON.stringify(m, null, 12).split('\n').join('\n ')},\n`; - } - output += ` }\n`; - output += ` },\n`; -} -output += `} as const;\n\n`; + for (const model of models) { + output += `\t\t\t"${model.id}": {\n`; + output += `\t\t\t\tid: "${model.id}",\n`; + output += `\t\t\t\tname: "${model.name}",\n`; + output += `\t\t\t\tprovider: "${model.provider}",\n`; + output += `\t\t\t\treasoning: ${model.reasoning},\n`; + output += `\t\t\t\tinput: ${JSON.stringify(model.input)},\n`; + output += `\t\t\t\tcost: {\n`; + output += `\t\t\t\t\tinput: ${model.cost.input},\n`; + output += `\t\t\t\t\toutput: ${model.cost.output},\n`; + output += `\t\t\t\t\tcacheRead: ${model.cost.cacheRead},\n`; + output += `\t\t\t\t\tcacheWrite: ${model.cost.cacheWrite},\n`; + output += `\t\t\t\t},\n`; + output += `\t\t\t\tcontextWindow: ${model.contextWindow},\n`; + output += `\t\t\t\tmaxTokens: ${model.maxTokens},\n`; + output += `\t\t\t} satisfies Model,\n`; + } -// Generate Anthropic models (avoiding duplicates) -output += `// Anthropic models - use AnthropicLLM\n`; -output += `export const ANTHROPIC_MODELS = {\n`; -const seenAnthropicModels = new Set(); -for (const [providerId, provider] of Object.entries(anthropicModels)) { - const p = provider as any; - for (const [modelId, model] of Object.entries(p.models || {})) { - if (!seenAnthropicModels.has(modelId)) { - seenAnthropicModels.add(modelId); - const m = model as any; - output += ` "${modelId}": ${JSON.stringify(m, null, 8).split('\n').join('\n ')},\n`; - } - } -} -output += `} as const;\n\n`; + output += `\t\t}\n`; + output += `\t},\n`; + } -// Generate Gemini models (avoiding duplicates) -output += `// Gemini models - use GeminiLLM\n`; -output += `export const GEMINI_MODELS = {\n`; -const seenGeminiModels = new Set(); -for (const [providerId, provider] of Object.entries(geminiModels)) { - const p = provider as any; - for (const [modelId, model] of Object.entries(p.models || {})) { - if (!seenGeminiModels.has(modelId)) { - seenGeminiModels.add(modelId); - const m = model as any; - output += ` "${modelId}": ${JSON.stringify(m, null, 8).split('\n').join('\n ')},\n`; - } - } -} -output += `} as const;\n\n`; + output += `} as const; -// Generate type helpers -output += `// Type helpers\n`; -output += `export type OpenAIModel = keyof typeof OPENAI_MODELS;\n`; -output += `export type OpenAICompatibleProvider = keyof typeof OPENAI_COMPATIBLE_PROVIDERS;\n`; -output += `export type AnthropicModel = keyof typeof ANTHROPIC_MODELS;\n`; -output += `export type GeminiModel = keyof typeof GEMINI_MODELS;\n\n`; - -// Generate the factory function -output += `// Factory function implementation\n`; -output += `import { OpenAIResponsesLLM } from "./providers/openai-responses.js";\n`; -output += `import { OpenAICompletionsLLM } from "./providers/openai-completions.js";\n`; -output += `import { AnthropicLLM } from "./providers/anthropic.js";\n`; -output += `import { GeminiLLM } from "./providers/gemini.js";\n`; -output += `import type { LLM, LLMOptions } from "./types.js";\n\n`; - -output += `export interface CreateLLMOptions { - apiKey?: string; - baseUrl?: string; -} - -// Overloads for type safety -export function createLLM( - provider: "openai", - model: OpenAIModel, - options?: CreateLLMOptions -): OpenAIResponsesLLM; - -export function createLLM( - provider: OpenAICompatibleProvider, - model: string, // We'll validate at runtime - options?: CreateLLMOptions -): OpenAICompletionsLLM; - -export function createLLM( - provider: "anthropic", - model: AnthropicModel, - options?: CreateLLMOptions -): AnthropicLLM; - -export function createLLM( - provider: "gemini", - model: GeminiModel, - options?: CreateLLMOptions -): GeminiLLM; - -// Implementation -export function createLLM( - provider: string, - model: string, - options?: CreateLLMOptions -): LLM { - const apiKey = options?.apiKey || process.env[getEnvVar(provider)]; - - if (provider === "openai") { - return new OpenAIResponsesLLM(model, apiKey); - } - - if (provider === "anthropic") { - return new AnthropicLLM(model, apiKey); - } - - if (provider === "gemini") { - return new GeminiLLM(model, apiKey); - } - - // OpenAI-compatible providers - if (provider in OPENAI_COMPATIBLE_PROVIDERS) { - const providerData = OPENAI_COMPATIBLE_PROVIDERS[provider as OpenAICompatibleProvider]; - const baseUrl = options?.baseUrl || providerData.baseUrl; - return new OpenAICompletionsLLM(model, apiKey, baseUrl); - } - - throw new Error(\`Unknown provider: \${provider}\`); -} - -// Helper to get the default environment variable for a provider -function getEnvVar(provider: string): string { - switch (provider) { - case "openai": return "OPENAI_API_KEY"; - case "anthropic": return "ANTHROPIC_API_KEY"; - case "gemini": return "GEMINI_API_KEY"; - case "groq": return "GROQ_API_KEY"; - case "cerebras": return "CEREBRAS_API_KEY"; - case "together": return "TOGETHER_API_KEY"; - case "openrouter": return "OPENROUTER_API_KEY"; - default: return \`\${provider.toUpperCase()}_API_KEY\`; - } -} +// Helper type to extract models for each provider +export type ProviderModels = { + [K in keyof typeof PROVIDERS]: typeof PROVIDERS[K]["models"] +}; `; -// Write the generated file -writeFileSync(join(process.cwd(), "src/models.generated.ts"), output); -console.log("āœ… Generated src/models.generated.ts"); + // Write file + writeFileSync(join(process.cwd(), "src/models.generated.ts"), output); + console.log("āœ… Generated src/models.generated.ts"); -// Count statistics -const openaiCount = Object.values(openaiModels).reduce((acc, p: any) => acc + Object.keys(p.models || {}).length, 0); -const compatCount = Object.values(openaiCompatibleProviders).reduce((acc, p: any) => acc + Object.keys(p.models || {}).length, 0); -const anthropicCount = Object.values(anthropicModels).reduce((acc, p: any) => acc + Object.keys(p.models || {}).length, 0); -const geminiCount = Object.values(geminiModels).reduce((acc, p: any) => acc + Object.keys(p.models || {}).length, 0); + // Print statistics + const totalModels = allModels.length; + const reasoningModels = allModels.filter(m => m.reasoning).length; -console.log(`\nModel counts:`); -console.log(` OpenAI (Responses API): ${openaiCount} models`); -console.log(` OpenAI-compatible: ${compatCount} models across ${Object.keys(openaiCompatibleProviders).length} providers`); -console.log(` Anthropic: ${anthropicCount} models`); -console.log(` Gemini: ${geminiCount} models`); -console.log(` Total: ${openaiCount + compatCount + anthropicCount + geminiCount} models`); \ No newline at end of file + console.log(`\nšŸ“Š Model Statistics:`); + console.log(` Total tool-capable models: ${totalModels}`); + console.log(` Reasoning-capable models: ${reasoningModels}`); + + for (const [provider, models] of Object.entries(providers)) { + console.log(` ${provider}: ${models.length} models`); + } +} + +// Run the generator +generateModels().catch(console.error); \ No newline at end of file diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 0313d057..77953ce9 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -3,41 +3,30 @@ export const version = "0.5.8"; -// Export generated models and factory +// Export generated models data +export { PROVIDERS } from "./models.generated.js"; + +// Export models utilities and types export { - ANTHROPIC_MODELS, type AnthropicModel, - type CreateLLMOptions, + type CerebrasModel, createLLM, - GEMINI_MODELS, - type GeminiModel, - type ModelData, - OPENAI_COMPATIBLE_PROVIDERS, - OPENAI_MODELS, - type OpenAICompatibleProvider, + type GoogleModel, + type GroqModel, + type Model, type OpenAIModel, - type ProviderData, -} from "./models.generated.js"; -// Export models utilities -export { - getAllProviders, - getModelInfo, - getProviderInfo, - getProviderModels, - loadModels, - type ModalityInput, - type ModalityOutput, - type ModelInfo, - type ModelsData, - type ProviderInfo, - supportsThinking, - supportsTools, + type OpenRouterModel, + PROVIDER_CONFIG, + type ProviderModels, + type ProviderToLLM, + type XAIModel, } from "./models.js"; // Export providers export { AnthropicLLM } from "./providers/anthropic.js"; -export { GeminiLLM } from "./providers/gemini.js"; +export { GoogleLLM } from "./providers/gemini.js"; export { OpenAICompletionsLLM } from "./providers/openai-completions.js"; export { OpenAIResponsesLLM } from "./providers/openai-responses.js"; + // Export types export type * from "./types.js"; diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 68198078..e22b671a 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -1,133 +1,97 @@ -import { readFileSync } from "fs"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; +import { PROVIDERS } from "./models.generated.js"; +import { AnthropicLLM } from "./providers/anthropic.js"; +import { GoogleLLM } from "./providers/gemini.js"; +import { OpenAICompletionsLLM } from "./providers/openai-completions.js"; +import { OpenAIResponsesLLM } from "./providers/openai-responses.js"; +import type { Model } from "./types.js"; -export type ModalityInput = "text" | "image" | "audio" | "video" | "pdf"; -export type ModalityOutput = "text" | "image" | "audio"; +// Provider configuration with factory functions +export const PROVIDER_CONFIG = { + google: { + envKey: "GEMINI_API_KEY", + create: (model: string, apiKey: string) => new GoogleLLM(model, apiKey), + }, + openai: { + envKey: "OPENAI_API_KEY", + create: (model: string, apiKey: string) => new OpenAIResponsesLLM(model, apiKey), + }, + anthropic: { + envKey: "ANTHROPIC_API_KEY", + create: (model: string, apiKey: string) => new AnthropicLLM(model, apiKey), + }, + xai: { + envKey: "XAI_API_KEY", + create: (model: string, apiKey: string) => new OpenAICompletionsLLM(model, apiKey, "https://api.x.ai/v1"), + }, + groq: { + envKey: "GROQ_API_KEY", + create: (model: string, apiKey: string) => + new OpenAICompletionsLLM(model, apiKey, "https://api.groq.com/openai/v1"), + }, + cerebras: { + envKey: "CEREBRAS_API_KEY", + create: (model: string, apiKey: string) => new OpenAICompletionsLLM(model, apiKey, "https://api.cerebras.ai/v1"), + }, + openrouter: { + envKey: "OPENROUTER_API_KEY", + create: (model: string, apiKey: string) => + new OpenAICompletionsLLM(model, apiKey, "https://openrouter.ai/api/v1"), + }, +} as const; -export interface ModelInfo { - id: string; - name: string; - attachment: boolean; - reasoning: boolean; - temperature: boolean; - tool_call: boolean; - release_date: string; - last_updated: string; - modalities: { - input: ModalityInput[]; - output: ModalityOutput[]; - }; - open_weights: boolean; - limit: { - context: number; - output: number; - }; - knowledge?: string; // Optional - knowledge cutoff date - cost?: { - input: number; - output: number; - cache_read?: number; - cache_write?: number; - }; +// Type mapping from provider to LLM implementation +export type ProviderToLLM = { + google: GoogleLLM; + openai: OpenAIResponsesLLM; + anthropic: AnthropicLLM; + xai: OpenAICompletionsLLM; + groq: OpenAICompletionsLLM; + cerebras: OpenAICompletionsLLM; + openrouter: OpenAICompletionsLLM; +}; + +// Extract model types for each provider +export type GoogleModel = keyof typeof PROVIDERS.google.models; +export type OpenAIModel = keyof typeof PROVIDERS.openai.models; +export type AnthropicModel = keyof typeof PROVIDERS.anthropic.models; +export type XAIModel = keyof typeof PROVIDERS.xai.models; +export type GroqModel = keyof typeof PROVIDERS.groq.models; +export type CerebrasModel = keyof typeof PROVIDERS.cerebras.models; +export type OpenRouterModel = keyof typeof PROVIDERS.openrouter.models; + +// Map providers to their model types +export type ProviderModels = { + google: GoogleModel; + openai: OpenAIModel; + anthropic: AnthropicModel; + xai: XAIModel; + groq: GroqModel; + cerebras: CerebrasModel; + openrouter: OpenRouterModel; +}; + +// Single generic factory function +export function createLLM

( + provider: P, + model: M, + apiKey?: string, +): ProviderToLLM[P] { + const config = PROVIDER_CONFIG[provider as keyof typeof PROVIDER_CONFIG]; + if (!config) throw new Error(`Unknown provider: ${provider}`); + + const providerData = PROVIDERS[provider]; + if (!providerData) throw new Error(`Unknown provider: ${provider}`); + + // Type-safe model lookup + const models = providerData.models as Record; + const modelData = models[model as string]; + if (!modelData) throw new Error(`Unknown model: ${String(model)} for provider ${provider}`); + + const key = apiKey || process.env[config.envKey]; + if (!key) throw new Error(`No API key provided for ${provider}. Set ${config.envKey} or pass apiKey.`); + + return config.create(model as string, key) as ProviderToLLM[P]; } -export interface ProviderInfo { - id: string; - env?: string[]; - npm?: string; - api?: string; - name: string; - doc?: string; - models: Record; -} - -export type ModelsData = Record; - -let cachedModels: ModelsData | null = null; - -/** - * Load models data from models.json - * The file is loaded relative to this module's location - */ -export function loadModels(): ModelsData { - if (cachedModels) { - return cachedModels; - } - - try { - // Get the directory of this module - const currentDir = dirname(fileURLToPath(import.meta.url)); - const modelsPath = join(currentDir, "models.json"); - - const data = readFileSync(modelsPath, "utf-8"); - cachedModels = JSON.parse(data); - return cachedModels!; - } catch (error) { - console.error("Failed to load models.json:", error); - // Return empty providers object as fallback - return {}; - } -} - -/** - * Get information about a specific model - */ -export function getModelInfo(modelId: string): ModelInfo | undefined { - const data = loadModels(); - - // Search through all providers - for (const provider of Object.values(data)) { - if (provider.models && provider.models[modelId]) { - return provider.models[modelId]; - } - } - - return undefined; -} - -/** - * Get all models for a specific provider - */ -export function getProviderModels(providerId: string): ModelInfo[] { - const data = loadModels(); - const provider = data[providerId]; - - if (!provider || !provider.models) { - return []; - } - - return Object.values(provider.models); -} - -/** - * Get provider information - */ -export function getProviderInfo(providerId: string): ProviderInfo | undefined { - const data = loadModels(); - return data[providerId]; -} - -/** - * Check if a model supports thinking/reasoning - */ -export function supportsThinking(modelId: string): boolean { - const model = getModelInfo(modelId); - return model?.reasoning === true; -} - -/** - * Check if a model supports tool calling - */ -export function supportsTools(modelId: string): boolean { - const model = getModelInfo(modelId); - return model?.tool_call === true; -} - -/** - * Get all available providers - */ -export function getAllProviders(): ProviderInfo[] { - const data = loadModels(); - return Object.values(data); -} +// Re-export Model type for convenience +export type { Model }; diff --git a/packages/ai/src/providers/gemini.ts b/packages/ai/src/providers/gemini.ts index d81dcc15..c05d5267 100644 --- a/packages/ai/src/providers/gemini.ts +++ b/packages/ai/src/providers/gemini.ts @@ -17,7 +17,7 @@ import type { ToolCall, } from "../types.js"; -export interface GeminiLLMOptions extends LLMOptions { +export interface GoogleLLMOptions extends LLMOptions { toolChoice?: "auto" | "none" | "any"; thinking?: { enabled: boolean; @@ -25,7 +25,7 @@ export interface GeminiLLMOptions extends LLMOptions { }; } -export class GeminiLLM implements LLM { +export class GoogleLLM implements LLM { private client: GoogleGenAI; private model: string; @@ -42,7 +42,7 @@ export class GeminiLLM implements LLM { this.model = model; } - async complete(context: Context, options?: GeminiLLMOptions): Promise { + async complete(context: Context, options?: GoogleLLMOptions): Promise { try { const contents = this.convertMessages(context.messages); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 74bb095f..40ccae21 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -106,3 +106,20 @@ export interface TokenUsage { } export type StopReason = "stop" | "length" | "toolUse" | "safety" | "error"; + +// Model interface for the unified model system +export interface Model { + id: string; + name: string; + provider: string; + reasoning: boolean; + input: ("text" | "image")[]; + cost: { + input: number; // $/million tokens + output: number; // $/million tokens + cacheRead: number; // $/million tokens + cacheWrite: number; // $/million tokens + }; + contextWindow: number; + maxTokens: number; +} diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index b96975a5..e312e0ce 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -1,10 +1,11 @@ import { describe, it, beforeAll, afterAll, expect } from "vitest"; -import { GeminiLLM } from "../src/providers/gemini.js"; +import { GoogleLLM } from "../src/providers/gemini.js"; import { OpenAICompletionsLLM } from "../src/providers/openai-completions.js"; import { OpenAIResponsesLLM } from "../src/providers/openai-responses.js"; import { AnthropicLLM } from "../src/providers/anthropic.js"; import type { LLM, LLMOptions, Context, Tool, AssistantMessage } from "../src/types.js"; import { spawn, ChildProcess, execSync } from "child_process"; +import { createLLM } from "../src/models.js"; // Calculator tool definition (same as examples) const calculatorTool: Tool = { @@ -213,10 +214,10 @@ async function multiTurn(llm: LLM, thinkingOptions: T) describe("AI Providers E2E Tests", () => { describe.skipIf(!process.env.GEMINI_API_KEY)("Gemini Provider", () => { - let llm: GeminiLLM; + let llm: GoogleLLM; beforeAll(() => { - llm = new GeminiLLM("gemini-2.5-flash", process.env.GEMINI_API_KEY!); + llm = new GoogleLLM("gemini-2.5-flash", process.env.GEMINI_API_KEY!); }); it("should complete basic text generation", async () => { @@ -316,11 +317,11 @@ describe("AI Providers E2E Tests", () => { }); }); - describe.skipIf(!process.env.GROK_API_KEY)("Grok Provider (via OpenAI Completions)", () => { + describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (via OpenAI Completions)", () => { let llm: OpenAICompletionsLLM; beforeAll(() => { - llm = new OpenAICompletionsLLM("grok-code-fast-1", process.env.GROK_API_KEY!, "https://api.x.ai/v1"); + llm = new OpenAICompletionsLLM("grok-code-fast-1", process.env.XAI_API_KEY!, "https://api.x.ai/v1"); }); it("should complete basic text generation", async () => { @@ -509,4 +510,32 @@ describe("AI Providers E2E Tests", () => { await multiTurn(llm, {reasoningEffort: "medium"}); }); }); + + describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter Provider (Kimi K2)", () => { + let llm: OpenAICompletionsLLM; + + beforeAll(() => { + llm = createLLM("openrouter", "moonshotai/kimi-k2", process.env.OPENROUTER_API_KEY!); + }); + + it("should complete basic text generation", async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", async () => { + await handleThinking(llm, {reasoningEffort: "medium"}, false); + }); + + it("should handle multi-turn with thinking and tools", async () => { + await multiTurn(llm, {reasoningEffort: "medium"}); + }); + }); }); \ No newline at end of file