`claude login` alone does not authorize SDK / non-interactive (`claude -p`) usage. Anthropic gates that behind a separate long-lived token minted by `claude setup-token`. Without it, subscription users hit `billing_error` from the SDK even though interactive Claude Code works fine. Changes: - ClaudeCodeConfig.oauthToken: new optional field. When set, the provider exports it as CLAUDE_CODE_OAUTH_TOKEN before invoking the SDK, which makes the SDK bill against the user's Pro/Max subscription instead of API credits. - apiKey continues to work for users with a console API key. If both are present, oauthToken takes precedence. - billing_error / authentication_failed messages now point users at `claude setup-token` so the fix is obvious from the error alone. - README: rewrite the Claude Code section to document both modes and the `claude setup-token` step explicitly. - examples/claude-code.ts: read CLAUDE_CODE_OAUTH_TOKEN from env so the smoke test actually works for subscribers.
10 KiB
Simple AI Provider
A type-safe TypeScript library with a single API surface for Claude (Anthropic), OpenAI, Google Gemini, OpenWebUI, and Claude Code (subscription-friendly via local CLI).
The same complete() / stream() interface works across every provider, with consistent error types, streaming, and optional structured (typed JSON) output.
Install
npm install simple-ai-provider
# or
bun add simple-ai-provider
Requires Node ≥ 18 (or Bun). TypeScript ≥ 5 is recommended as a peer dependency.
Quick start
import { ClaudeProvider } from 'simple-ai-provider';
const claude = new ClaudeProvider({ apiKey: process.env.ANTHROPIC_API_KEY! });
await claude.initialize();
const response = await claude.complete({
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Explain TypeScript in one sentence.' }
],
maxTokens: 200
});
console.log(response.content);
console.log(response.usage); // { promptTokens, completionTokens, totalTokens }
Every provider follows the same pattern: construct, initialize(), then complete() or stream().
Providers
Claude (Anthropic API)
import { ClaudeProvider } from 'simple-ai-provider';
const claude = new ClaudeProvider({
apiKey: process.env.ANTHROPIC_API_KEY!,
defaultModel: 'claude-3-5-sonnet-20241022', // optional
version: '2023-06-01', // optional
timeout: 30_000, // optional, ms
maxRetries: 3 // optional
});
Claude Code (subscription or API key via local CLI)
ClaudeCodeProvider wraps @anthropic-ai/claude-agent-sdk, which spawns the local claude CLI under the hood. It supports two billing modes:
Mode A — Claude Pro/Max subscription (billed against subscription credits):
# One-time: install the CLI, then mint a long-lived OAuth token.
# `claude login` alone is NOT enough — Anthropic gates SDK use behind setup-token.
claude setup-token
import { ClaudeCodeProvider } from 'simple-ai-provider';
const claude = new ClaudeCodeProvider({
oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN // from `claude setup-token`
});
await claude.initialize();
const response = await claude.complete({
messages: [{ role: 'user', content: 'Hello!' }]
});
Mode B — Console API key (billed per-token):
const claude = new ClaudeCodeProvider({
apiKey: process.env.ANTHROPIC_API_KEY
});
If both are present, oauthToken wins. Either field can also be picked up from the environment (CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY) without being passed explicitly.
Optional config:
new ClaudeCodeProvider({
oauthToken: '...',
defaultModel: 'sonnet', // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID
maxTurns: 1, // 1 for plain completion; raise for agent/tool loops
allowedTools: [], // tool names to enable (default: none)
cwd: process.cwd() // working directory for the agent
});
Trade-offs to know:
- Requires
claudeCLI installed on the host. Not ideal for typical server deployments. - Higher latency than the direct API (spawns a CLI process per request).
- Streaming yields text as the SDK emits successive assistant messages, not token-by-token deltas.
OpenAI
import { OpenAIProvider } from 'simple-ai-provider';
const openai = new OpenAIProvider({
apiKey: process.env.OPENAI_API_KEY!,
defaultModel: 'gpt-4o',
organization: 'org-...', // optional
project: 'proj-...' // optional
});
baseUrl is supported for OpenAI-compatible endpoints.
Gemini (Google)
import { GeminiProvider } from 'simple-ai-provider';
const gemini = new GeminiProvider({
apiKey: process.env.GOOGLE_AI_API_KEY!,
defaultModel: 'gemini-2.5-flash',
safetySettings: [/* SafetySetting[] from @google/genai */],
generationConfig: {
temperature: 0.7,
topP: 0.8,
topK: 40,
maxOutputTokens: 1000
}
});
Backed by @google/genai (the successor to the deprecated @google/generative-ai).
OpenWebUI (local / self-hosted)
import { OpenWebUIProvider } from 'simple-ai-provider';
const openwebui = new OpenWebUIProvider({
apiKey: 'your-bearer-token', // from Settings > Account in OpenWebUI
baseUrl: 'http://localhost:3000',
defaultModel: 'llama3.1:latest',
useOllamaProxy: false, // false: OpenWebUI chat API (default)
// true: direct Ollama proxy
dangerouslyAllowInsecureConnections: true
});
useOllamaProxy flips between two internal strategies — the OpenAI-compatible chat completions endpoint and the direct Ollama generate endpoint.
Streaming
Identical shape across providers:
for await (const chunk of provider.stream({ messages, maxTokens: 200 })) {
if (!chunk.isComplete) {
process.stdout.write(chunk.content);
} else {
console.log('\n', chunk.usage);
}
}
Structured output
Ask any provider for a typed JSON response. The library injects a system prompt describing the expected shape and parses the result.
import { createResponseType } from 'simple-ai-provider';
interface UserProfile {
name: string;
age: number;
hobbies: string[];
}
const profileType = createResponseType<UserProfile>(
'A user profile with name, age, and hobbies',
{ name: 'Alice', age: 30, hobbies: ['climbing', 'photography'] }
);
const response = await claude.complete({
messages: [{ role: 'user', content: 'Generate a fictional user profile.' }],
responseType: profileType
});
// response.content is typed as UserProfile
// response.rawContent is the original string
console.log(response.content.name);
This also works with stream() — chunks deliver text, then the final chunk parses.
Factory functions
If you prefer a single entry point:
import { createProvider } from 'simple-ai-provider';
const claude = createProvider('claude', { apiKey: '…' });
const openai = createProvider('openai', { apiKey: '…' });
const gemini = createProvider('gemini', { apiKey: '…' });
const openwebui = createProvider('openwebui', { apiKey: '…', baseUrl: '…' });
const claudeCode = createProvider('claude-code', {});
Per-provider shortcut factories also exist: createClaudeProvider, createOpenAIProvider, createGeminiProvider, createOpenWebUIProvider, createClaudeCodeProvider.
Error handling
Every error thrown by the library is an AIProviderError with a typed .type and optional .statusCode:
import { AIProviderError, AIErrorType } from 'simple-ai-provider';
try {
await provider.complete({ messages: [...] });
} catch (error) {
if (error instanceof AIProviderError) {
switch (error.type) {
case AIErrorType.AUTHENTICATION: /* bad API key, expired token */ break;
case AIErrorType.RATE_LIMIT: /* slow down */ break;
case AIErrorType.MODEL_NOT_FOUND: /* wrong model name */ break;
case AIErrorType.INVALID_REQUEST: /* bad input */ break;
case AIErrorType.NETWORK: /* transient connectivity */ break;
case AIErrorType.TIMEOUT: /* slow request */ break;
case AIErrorType.UNKNOWN: /* fall back */ break;
}
console.error(error.statusCode, error.originalError);
}
}
Configuration reference
All providers share these base options:
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey |
string |
required¹ | ¹ Optional for ClaudeCodeProvider |
baseUrl |
string |
provider default | Custom or self-hosted endpoint |
timeout |
number |
30000 |
Request timeout in ms |
maxRetries |
number |
3 |
SDK-level retry attempts |
Each provider adds its own config (see the sections above).
TypeScript
Full type definitions ship with the package. The main types you'll use:
import type {
AIMessage,
CompletionParams,
CompletionResponse,
CompletionChunk,
TokenUsage,
ProviderInfo,
ResponseType,
// Per-provider config interfaces
ClaudeConfig,
ClaudeCodeConfig,
OpenAIConfig,
GeminiConfig,
OpenWebUIConfig
} from 'simple-ai-provider';
CompletionResponse<T> is generic — when you pass responseType, content is T (and the original string is preserved on rawContent).
Provider metadata
Every provider exposes getInfo():
const info = provider.getInfo();
// { name, version, models, maxContextLength, supportsStreaming, capabilities }
Model lists in info.models are example values — refer to each vendor's docs for the current authoritative list. The library accepts any model string the underlying SDK supports.
Architecture (brief)
The library uses a template-method base class (BaseAIProvider) that owns the public lifecycle (initialize, complete, stream), input validation, response-type parsing, and error normalization. Each provider supplies:
doInitialize()/doComplete()/doStream()— the actual SDK callsgetModelNamePatterns()— regexes for naming-convention warningssendValidationProbe()— minimal request used duringinitialize()mapProviderError()/providerErrorMessages()— provider-specific error translation
OpenWebUIProvider additionally uses an internal strategy split (OpenWebUIChatStrategy vs OpenWebUIOllamaStrategy) selected by useOllamaProxy.
Development
git clone https://gitea.jleibl.net/jleibl/simple-ai-provider.git
cd simple-ai-provider
bun install
bun run build
Examples live in examples/:
bun run examples/basic-usage.ts
bun run examples/multi-provider.ts
bun run examples/structured-response-types.ts
Tests in tests/ use Bun's test runner (bun test). Note: post-refactor some tests need updating before they pass cleanly.
License
MIT — see LICENSE.