diff --git a/.changeset/add-litellm-adapter.md b/.changeset/add-litellm-adapter.md new file mode 100644 index 000000000..cd27642cb --- /dev/null +++ b/.changeset/add-litellm-adapter.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-litellm': minor +--- + +feat: add LiteLLM AI gateway adapter + +New `@tanstack/ai-litellm` package that provides a tree-shakeable text adapter for the LiteLLM proxy. Extends `OpenAIBaseChatCompletionsTextAdapter` (same pattern as `ai-groq` and `ai-grok`), giving access to 100+ LLM providers through a single adapter. diff --git a/packages/ai-litellm/package.json b/packages/ai-litellm/package.json new file mode 100644 index 000000000..bda415bc5 --- /dev/null +++ b/packages/ai-litellm/package.json @@ -0,0 +1,60 @@ +{ + "name": "@tanstack/ai-litellm", + "version": "0.0.1", + "type": "module", + "description": "LiteLLM AI gateway adapter for TanStack AI. Access 100+ LLM providers through a single proxy.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-litellm" + }, + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:build": "publint --strict", + "test:eslint": "eslint ./src", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "litellm", + "adapter", + "llm", + "gateway", + "multi-provider", + "proxy" + ], + "devDependencies": { + "@vitest/coverage-v8": "4.0.14", + "vite": "^7.3.3" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "zod": "^4.0.0" + }, + "dependencies": { + "@tanstack/ai-utils": "workspace:*", + "@tanstack/openai-base": "workspace:*", + "openai": "^6.41.0" + } +} diff --git a/packages/ai-litellm/src/adapters/text.ts b/packages/ai-litellm/src/adapters/text.ts new file mode 100644 index 000000000..600cdd271 --- /dev/null +++ b/packages/ai-litellm/src/adapters/text.ts @@ -0,0 +1,69 @@ +import OpenAI from 'openai' +import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base' +import { getLiteLLMApiKeyFromEnv, withLiteLLMDefaults } from '../utils/client' +import type { LiteLLMClientConfig } from '../utils' + +/** + * Configuration for the LiteLLM text adapter. + */ +export interface LiteLLMTextConfig extends LiteLLMClientConfig {} + +/** + * LiteLLM Text (Chat) Adapter + * + * Tree-shakeable adapter for LiteLLM AI gateway. LiteLLM exposes an + * OpenAI-compatible Chat Completions endpoint, so we drive it with the + * OpenAI SDK via a baseURL override (the same pattern as ai-groq and + * ai-grok). + * + * LiteLLM supports 100+ providers (OpenAI, Anthropic, Google, Azure, + * AWS Bedrock, Ollama, Groq, Mistral, and more) through a single proxy. + * The model string determines the provider routing, e.g. + * "anthropic/claude-sonnet-4-6" or "openai/gpt-4o". + */ +export class LiteLLMTextAdapter extends OpenAIBaseChatCompletionsTextAdapter< + string, + Record, + readonly [], + Record, + readonly [] +> { + override readonly kind = 'text' as const + override readonly name = 'litellm' as const + + constructor(config: LiteLLMTextConfig, model: string) { + super(model, 'litellm', new OpenAI(withLiteLLMDefaults(config))) + } +} + +/** + * Creates a LiteLLM text adapter with explicit API key. + * + * @example + * ```typescript + * const adapter = createLitellmText('anthropic/claude-sonnet-4-6', 'sk-...'); + * ``` + */ +export function createLitellmText( + model: string, + apiKey: string, + config?: Omit, +): LiteLLMTextAdapter { + return new LiteLLMTextAdapter({ apiKey, ...config }, model) +} + +/** + * Creates a LiteLLM text adapter with API key from `LITELLM_API_KEY`. + * + * @example + * ```typescript + * const adapter = litellmText('anthropic/claude-sonnet-4-6'); + * ``` + */ +export function litellmText( + model: string, + config?: Omit, +): LiteLLMTextAdapter { + const apiKey = getLiteLLMApiKeyFromEnv() + return createLitellmText(model, apiKey, config) +} diff --git a/packages/ai-litellm/src/index.ts b/packages/ai-litellm/src/index.ts new file mode 100644 index 000000000..850de7d16 --- /dev/null +++ b/packages/ai-litellm/src/index.ts @@ -0,0 +1,22 @@ +/** + * @module @tanstack/ai-litellm + * + * LiteLLM AI gateway adapter for TanStack AI. + * Provides a tree-shakeable adapter for LiteLLM's OpenAI-compatible proxy, + * giving access to 100+ LLM providers through a single interface. + */ + +// Text (Chat) adapter +export { + LiteLLMTextAdapter, + createLitellmText, + litellmText, + type LiteLLMTextConfig, +} from './adapters/text' + +// Utilities +export { + getLiteLLMApiKeyFromEnv, + withLiteLLMDefaults, + type LiteLLMClientConfig, +} from './utils' diff --git a/packages/ai-litellm/src/utils/client.ts b/packages/ai-litellm/src/utils/client.ts new file mode 100644 index 000000000..61a674a66 --- /dev/null +++ b/packages/ai-litellm/src/utils/client.ts @@ -0,0 +1,35 @@ +import { getApiKeyFromEnv } from '@tanstack/ai-utils' +import type { ClientOptions } from 'openai' + +export interface LiteLLMClientConfig extends Omit { + apiKey?: string +} + +const DEFAULT_LITELLM_BASE_URL = 'http://localhost:4000/v1' + +/** + * Gets LiteLLM API key from environment variables. + * @throws Error if LITELLM_API_KEY is not found + */ +export function getLiteLLMApiKeyFromEnv(): string { + try { + return getApiKeyFromEnv('LITELLM_API_KEY') + } catch { + throw new Error( + 'LITELLM_API_KEY is required. Please set it in your environment variables or use createLitellmText() with an explicit API key.', + ) + } +} + +/** + * Returns an OpenAI client config pointing at the LiteLLM proxy. + * Defaults to http://localhost:4000/v1 when no baseURL is provided. + */ +export function withLiteLLMDefaults( + config: LiteLLMClientConfig, +): LiteLLMClientConfig { + return { + ...config, + baseURL: config.baseURL || DEFAULT_LITELLM_BASE_URL, + } +} diff --git a/packages/ai-litellm/src/utils/index.ts b/packages/ai-litellm/src/utils/index.ts new file mode 100644 index 000000000..fb34ea388 --- /dev/null +++ b/packages/ai-litellm/src/utils/index.ts @@ -0,0 +1,5 @@ +export { + getLiteLLMApiKeyFromEnv, + withLiteLLMDefaults, + type LiteLLMClientConfig, +} from './client' diff --git a/packages/ai-litellm/tsconfig.json b/packages/ai-litellm/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-litellm/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-litellm/vite.config.ts b/packages/ai-litellm/vite.config.ts new file mode 100644 index 000000000..77bcc2e60 --- /dev/null +++ b/packages/ai-litellm/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 6d6b950bd..65890433c 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -17,6 +17,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), 'one-shot-text': new Set([ 'openai', @@ -27,6 +28,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), reasoning: new Set(['openai', 'anthropic', 'gemini']), 'multi-turn': new Set([ @@ -38,6 +40,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), 'tool-calling': new Set([ 'openai', @@ -48,6 +51,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), 'parallel-tool-calls': new Set([ 'openai', @@ -57,6 +61,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), // Gemini excluded: approval flow timing issues with Gemini's streaming format 'tool-approval': new Set([ @@ -67,6 +72,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), // Ollama excluded: aimock doesn't support content+toolCalls for /api/chat format 'text-tool-text': new Set([ @@ -77,6 +83,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), 'structured-output': new Set([ 'openai', @@ -87,6 +94,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), // Streaming structured output: only providers with native streaming JSON // schema support are listed here. Other providers fall back to the @@ -98,6 +106,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), // Multi-turn structured output: every turn produces its own typed // `structured-output` part on the assistant message, and historical @@ -125,6 +134,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), 'agentic-structured': new Set([ 'openai', @@ -135,6 +145,7 @@ export const matrix: Record> = { 'grok', 'openrouter', 'openai-compatible', + 'litellm', ]), // Native-combined-mode adapters only. Each provider's default test model // (or per-feature override in `features.ts`) must opt into combined mode diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts index 018e7744f..314d5a267 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -10,6 +10,7 @@ export type Provider = | 'openrouter' | 'openrouter-responses' | 'openai-compatible' + | 'litellm' | 'elevenlabs' export type Feature = @@ -48,6 +49,7 @@ export const ALL_PROVIDERS: Provider[] = [ 'openrouter', 'openrouter-responses', 'openai-compatible', + 'litellm', 'elevenlabs', ] diff --git a/testing/e2e/tests/test-matrix.ts b/testing/e2e/tests/test-matrix.ts index 58a166a3a..cd0cfe956 100644 --- a/testing/e2e/tests/test-matrix.ts +++ b/testing/e2e/tests/test-matrix.ts @@ -23,6 +23,7 @@ export const providers: Provider[] = [ 'openrouter', 'openrouter-responses', 'openai-compatible', + 'litellm', 'elevenlabs', ]