Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

99 changes: 98 additions & 1 deletion src/lib/ai-env-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// 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
}
})
67 changes: 61 additions & 6 deletions src/lib/ai.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -58,9 +59,60 @@ const PROVIDERS: Record<AIProvider, ModelFactory> = {
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<AIProvider, string> = {
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<AIProvider, (baseURL: string) => 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';
Expand All @@ -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];
Expand All @@ -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];
Expand All @@ -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];
Expand Down Expand Up @@ -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
Expand Down