From 8f044ab6c868593532380d8c1c1a3dc603513ebe Mon Sep 17 00:00:00 2001 From: kundeng Date: Wed, 22 Jul 2026 16:28:36 -0400 Subject: [PATCH 1/3] feat(ai): honor per-provider base URL from env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai() and assistant() resolve models through the bare provider singletons (`PROVIDERS[p](modelId)`), and those singletons hardcode the vendor endpoint — @ai-sdk/openai pins https://api.openai.com/v1 and reads no base-URL env var. KIT_AI_DEFAULT_PROVIDER and KIT_AI_DEFAULT_MODEL select *who* and *what*, but there is no way to select *where*. That makes Kit's built-in AI helpers unusable with any OpenAI-compatible endpoint that isn't the vendor's own: LiteLLM, Ollama, vLLM, a self-hosted router, or a corporate proxy. The only workaround today is to bypass ai() and assistant() entirely and hand-construct a provider per script, which loses the conversation-state bookkeeping assistant() exists to provide. This reads an optional per-provider base URL from the environment and, when set, builds the provider with the matching create* factory instead: OPENAI_BASE_URL / ANTHROPIC_BASE_URL / GOOGLE_BASE_URL / XAI_BASE_URL Names follow each SDK's own convention, alongside the existing *_API_KEY vars. When unset, PROVIDERS is used exactly as before, so behaviour is unchanged for everyone using the vendor endpoints. The env var is read at call time rather than module load so a script that sets it via env() before its first ai() call is still honored. Verified: tsc --noEmit is clean before and after. Co-Authored-By: Claude Opus 4.8 --- src/lib/ai-env-integration.test.ts | 53 +++++++++++++++++++++++++++++- src/lib/ai.ts | 39 +++++++++++++++++++--- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/lib/ai-env-integration.test.ts b/src/lib/ai-env-integration.test.ts index 5f67f892..504acce3 100644 --- a/src/lib/ai-env-integration.test.ts +++ b/src/lib/ai-env-integration.test.ts @@ -119,4 +119,55 @@ 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 + } + // A custom-baseURL provider still reports the same provider/model ids; + // the point is that construction succeeds rather than falling back to + // the pinned api.openai.com singleton. + t.is(model.provider, 'openai.chat') + 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.is(model.provider, 'openai.chat') + } 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 + } +}) diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 6a520821..a92f5f96 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,7 +1,7 @@ -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 { 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'; @@ -61,6 +61,35 @@ const PROVIDERS: Record = { // openrouter: openrouter // TODO: Re-enable when v5-compatible }; +// 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', + // 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 }), + // 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'; @@ -154,7 +183,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 From a632de9228e06c9bc2e464f0c377c49f57b98db4 Mon Sep 17 00:00:00 2001 From: kundeng Date: Wed, 22 Jul 2026 16:33:28 -0400 Subject: [PATCH 2/3] test(ai): don't pin exact openai provider id @ai-sdk/openai reports 'openai.responses' or 'openai.chat' depending on which model path is used; asserting the exact string made the new tests fail against a live resolve. Assert the prefix instead. Co-Authored-By: Claude Opus 4.8 --- src/lib/ai-env-integration.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/lib/ai-env-integration.test.ts b/src/lib/ai-env-integration.test.ts index 504acce3..20f62e3b 100644 --- a/src/lib/ai-env-integration.test.ts +++ b/src/lib/ai-env-integration.test.ts @@ -135,10 +135,11 @@ ava('resolveModel honours OPENAI_BASE_URL when set', async t => { t.fail('Expected model object, got string') return } - // A custom-baseURL provider still reports the same provider/model ids; - // the point is that construction succeeds rather than falling back to - // the pinned api.openai.com singleton. - t.is(model.provider, 'openai.chat') + // 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 @@ -163,7 +164,7 @@ ava('resolveModel is unchanged when no base URL is set', async t => { t.fail('Expected model object, got string') return } - t.is(model.provider, 'openai.chat') + t.true(model.provider.startsWith('openai')) } finally { if (originalBase === undefined) delete process.env.OPENAI_BASE_URL else process.env.OPENAI_BASE_URL = originalBase From 675f7b9bcceb0969593c1fcc1bbeeea486368950 Mon Sep 17 00:00:00 2001 From: kundeng Date: Wed, 22 Jul 2026 16:49:18 -0400 Subject: [PATCH 3/3] feat(ai): add a 'custom' OpenAI-compatible provider Per-provider *_BASE_URL covers "I reach a vendor through a gateway", but not the common case where the endpoint is its own provider serving many vendors' models. Setting OPENAI_BASE_URL to a router that answers for Claude, Gemini and GPT under one namespace works, but misnames what it is. Adds a 'custom' provider backed by @ai-sdk/openai-compatible (already present transitively; promoted to a direct dependency): KIT_AI_DEFAULT_PROVIDER=custom KIT_AI_CUSTOM_BASE_URL=http://localhost:4000/v1 KIT_AI_CUSTOM_API_KEY=... KIT_AI_DEFAULT_MODEL=whatever/the-endpoint-serves It also works with the prefix form: resolveModel('custom:some-model'). Unlike the vendor providers, 'custom' has no default endpoint, so selecting it without KIT_AI_CUSTOM_BASE_URL throws naming the missing variable rather than failing later against an undefined URL. Verified: tsc --noEmit clean; resolveModel(undefined) with provider=custom returns custom.chat and completes a live generateText call against a local OpenAI-compatible router. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + pnpm-lock.yaml | 3 ++ src/lib/ai-env-integration.test.ts | 45 ++++++++++++++++++++++++++++++ src/lib/ai.ts | 28 ++++++++++++++++++- 4 files changed, 76 insertions(+), 1 deletion(-) 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 20f62e3b..15370602 100644 --- a/src/lib/ai-env-integration.test.ts +++ b/src/lib/ai-env-integration.test.ts @@ -172,3 +172,48 @@ ava('resolveModel is unchanged when no base URL is set', async t => { 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 a92f5f96..721ffd9e 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -2,6 +2,7 @@ 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,29 @@ 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. @@ -70,6 +91,7 @@ const getProviderBaseUrlEnvVar = (provider: AIProvider): string => { 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]; @@ -80,6 +102,7 @@ const CUSTOM_PROVIDERS: Record ModelFactory> = 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 }; @@ -104,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]; @@ -116,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]; @@ -128,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];