diff --git a/package.json b/package.json index 2fc14b4b..800d6023 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@ai-sdk/anthropic": "2.0.53", "@ai-sdk/google": "2.0.44", "@ai-sdk/openai": "2.0.77", + "@ai-sdk/openai-compatible": "1.0.28", "@ai-sdk/react": "2.0.106", "@ai-sdk/xai": "2.0.39", "@johnlindquist/open": "^10.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c11bb4c..c5a9f824 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@ai-sdk/openai': specifier: 2.0.77 version: 2.0.77(zod@4.1.13) + '@ai-sdk/openai-compatible': + specifier: 1.0.28 + version: 1.0.28(zod@4.1.13) '@ai-sdk/react': specifier: 2.0.106 version: 2.0.106(react@19.2.1)(zod@4.1.13) diff --git a/src/lib/ai-env-integration.test.ts b/src/lib/ai-env-integration.test.ts index 5f67f892..15370602 100644 --- a/src/lib/ai-env-integration.test.ts +++ b/src/lib/ai-env-integration.test.ts @@ -119,4 +119,101 @@ ava('resolveModel should handle explicit provider parameter', async t => { // 3. Potentially refactoring the ai.ts module to support dependency injection // // For now, these integration tests verify the basic functionality -// when API keys are already set. \ No newline at end of file +// when API keys are already set. +ava('resolveModel honours OPENAI_BASE_URL when set', async t => { + const originalBase = process.env.OPENAI_BASE_URL + const originalKey = process.env.OPENAI_API_KEY + + try { + process.env.OPENAI_BASE_URL = 'http://localhost:20128/v1' + process.env.OPENAI_API_KEY = 'test-key' + + const model = await resolveModel('gpt-4') + + t.truthy(model) + if (typeof model === 'string') { + t.fail('Expected model object, got string') + return + } + // The point is that construction succeeds against the custom baseURL + // rather than falling back to the pinned api.openai.com singleton. + // Don't pin the exact provider id — @ai-sdk/openai reports + // 'openai.responses' or 'openai.chat' depending on the model path. + t.true(model.provider.startsWith('openai')) + t.is(model.modelId, 'gpt-4') + } finally { + if (originalBase === undefined) delete process.env.OPENAI_BASE_URL + else process.env.OPENAI_BASE_URL = originalBase + if (originalKey === undefined) delete process.env.OPENAI_API_KEY + else process.env.OPENAI_API_KEY = originalKey + } +}) + +ava('resolveModel is unchanged when no base URL is set', async t => { + const originalBase = process.env.OPENAI_BASE_URL + const originalKey = process.env.OPENAI_API_KEY + + try { + delete process.env.OPENAI_BASE_URL + process.env.OPENAI_API_KEY = 'test-key' + + const model = await resolveModel('gpt-4') + + t.truthy(model) + if (typeof model === 'string') { + t.fail('Expected model object, got string') + return + } + t.true(model.provider.startsWith('openai')) + } finally { + if (originalBase === undefined) delete process.env.OPENAI_BASE_URL + else process.env.OPENAI_BASE_URL = originalBase + if (originalKey === undefined) delete process.env.OPENAI_API_KEY + else process.env.OPENAI_API_KEY = originalKey + } +}) + +ava('custom provider resolves against KIT_AI_CUSTOM_BASE_URL', async t => { + const saved = { + provider: process.env.KIT_AI_DEFAULT_PROVIDER, + base: process.env.KIT_AI_CUSTOM_BASE_URL, + key: process.env.KIT_AI_CUSTOM_API_KEY + } + + try { + process.env.KIT_AI_CUSTOM_BASE_URL = 'http://localhost:20128/v1' + process.env.KIT_AI_CUSTOM_API_KEY = 'test-key' + + const model = await resolveModel('custom:some-model') + + t.truthy(model) + if (typeof model === 'string') { + t.fail('Expected model object, got string') + return + } + t.true(model.provider.startsWith('custom')) + t.is(model.modelId, 'some-model') + } finally { + for (const [k, v] of Object.entries({ + KIT_AI_DEFAULT_PROVIDER: saved.provider, + KIT_AI_CUSTOM_BASE_URL: saved.base, + KIT_AI_CUSTOM_API_KEY: saved.key + })) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + } +}) + +ava('custom provider without a base URL throws a helpful error', async t => { + const saved = process.env.KIT_AI_CUSTOM_BASE_URL + try { + delete process.env.KIT_AI_CUSTOM_BASE_URL + process.env.KIT_AI_CUSTOM_API_KEY = 'test-key' + await t.throwsAsync(() => resolveModel('custom:some-model'), + { message: /KIT_AI_CUSTOM_BASE_URL/ }) + } finally { + if (saved === undefined) delete process.env.KIT_AI_CUSTOM_BASE_URL + else process.env.KIT_AI_CUSTOM_BASE_URL = saved + } +}) diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 6a520821..721ffd9e 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,7 +1,8 @@ -import { openai } from '@ai-sdk/openai' -import { anthropic } from '@ai-sdk/anthropic' -import { google } from '@ai-sdk/google' -import { xai } from '@ai-sdk/xai' +import { openai, createOpenAI } from '@ai-sdk/openai' +import { anthropic, createAnthropic } from '@ai-sdk/anthropic' +import { google, createGoogleGenerativeAI } from '@ai-sdk/google' +import { xai, createXai } from '@ai-sdk/xai' +import { createOpenAICompatible } from '@ai-sdk/openai-compatible' // import { openrouter } from '@openrouter/ai-sdk-provider' // TODO: Update when v5-compatible version is available // Import AI SDK functions from our local wrapper for mocking import * as aiSdk from 'ai'; @@ -49,7 +50,7 @@ export interface AiObservabilityEvents { export const aiObservability = new EventEmitter(); // Type for supported AI providers -type AIProvider = 'openai' | 'anthropic' | 'google' | 'xai'; // | 'openrouter'; // TODO: Re-enable when v5-compatible +type AIProvider = 'openai' | 'anthropic' | 'google' | 'xai' | 'custom'; // | 'openrouter'; // TODO: Re-enable when v5-compatible // ModelFactory type and PROVIDERS map type ModelFactory = (id: string) => LanguageModel; @@ -58,9 +59,60 @@ const PROVIDERS: Record = { anthropic: anthropic, google: google, xai: xai, + custom: () => { + throw new Error( + `Provider "custom" requires ${CUSTOM_BASE_URL_ENV} to be set in ~/.kenv/.env` + ); + }, // openrouter: openrouter // TODO: Re-enable when v5-compatible }; +// 'custom' is a user-defined OpenAI-compatible endpoint (LiteLLM, Ollama, +// vLLM, a self-hosted router). Unlike the vendor providers it has no default +// endpoint, so KIT_AI_CUSTOM_BASE_URL is required when it is selected. +const CUSTOM_BASE_URL_ENV = 'KIT_AI_CUSTOM_BASE_URL'; +const CUSTOM_API_KEY_ENV = 'KIT_AI_CUSTOM_API_KEY'; + +const createCustomProvider = (baseURL: string): ModelFactory => { + const provider = createOpenAICompatible({ + name: 'custom', + baseURL, + apiKey: process.env[CUSTOM_API_KEY_ENV] + }); + return (id: string) => provider(id); +}; + +// Optional per-provider base URL overrides, for OpenAI-compatible gateways +// (LiteLLM, OpenRouter proxies, Ollama, vLLM, corporate proxies). When unset, +// the default provider singleton is used and behaviour is unchanged. +const getProviderBaseUrlEnvVar = (provider: AIProvider): string => { + const envVars: Record = { + openai: 'OPENAI_BASE_URL', + anthropic: 'ANTHROPIC_BASE_URL', + google: 'GOOGLE_BASE_URL', + xai: 'XAI_BASE_URL', + custom: CUSTOM_BASE_URL_ENV, + // openrouter: 'OPENROUTER_BASE_URL' // TODO: Re-enable when v5-compatible + }; + return envVars[provider]; +}; + +const CUSTOM_PROVIDERS: Record ModelFactory> = { + openai: (baseURL) => createOpenAI({ baseURL }), + anthropic: (baseURL) => createAnthropic({ baseURL }), + google: (baseURL) => createGoogleGenerativeAI({ baseURL }), + xai: (baseURL) => createXai({ baseURL }), + custom: (baseURL) => createCustomProvider(baseURL), + // openrouter: (baseURL) => createOpenRouter({ baseURL }) // TODO: Re-enable when v5-compatible +}; + +// Read the env var at call time, not module load, so a script that sets it +// via env() before its first ai()/assistant() call is still honoured. +const getProviderFactory = (provider: AIProvider): ModelFactory => { + const baseURL = process.env[getProviderBaseUrlEnvVar(provider)]; + return baseURL ? CUSTOM_PROVIDERS[provider](baseURL) : PROVIDERS[provider]; +}; + // Cache environment variables at module load const ENV_PROVIDER = (process.env.KIT_AI_DEFAULT_PROVIDER ?? 'openai') as AIProvider; const ENV_MODEL = process.env.KIT_AI_DEFAULT_MODEL ?? 'gpt-4o'; @@ -75,6 +127,7 @@ const getProviderEnvVar = (provider: AIProvider): string => { anthropic: 'ANTHROPIC_API_KEY', google: 'GOOGLE_API_KEY', xai: 'XAI_API_KEY', + custom: CUSTOM_API_KEY_ENV, // openrouter: 'OPENROUTER_API_KEY' // TODO: Re-enable when v5-compatible }; return envVars[provider]; @@ -87,6 +140,7 @@ const getProviderUrl = (provider: AIProvider): string => { anthropic: 'https://console.anthropic.com/settings/keys', google: 'https://makersuite.google.com/app/apikey', xai: 'https://console.xai.com', + custom: '', // openrouter: 'https://openrouter.ai/keys' // TODO: Re-enable when v5-compatible }; return urls[provider]; @@ -99,6 +153,7 @@ const getProviderInstructions = (provider: AIProvider): string => { anthropic: 'Generate an API key in the Anthropic Console under Settings > Keys', google: 'Create an API key in Google AI Studio', xai: 'Get your API key from the xAI console', + custom: `The API key for your endpoint at ${process.env[CUSTOM_BASE_URL_ENV] ?? 'your custom base URL'}`, // openrouter: 'Create an API key at OpenRouter.ai/keys' // TODO: Re-enable when v5-compatible }; return instructions[provider]; @@ -154,7 +209,7 @@ export const resolveModel = async ( await ensureApiKey(targetProvider); // Create and return the model - return PROVIDERS[targetProvider](modelId); + return getProviderFactory(targetProvider)(modelId); }; // Interface for injectable SDK functions for testability