`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.
335 lines
10 KiB
Markdown
335 lines
10 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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
|
|
|
|
```typescript
|
|
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)
|
|
|
|
```typescript
|
|
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):
|
|
|
|
```bash
|
|
# 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
|
|
```
|
|
|
|
```typescript
|
|
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):
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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 `claude` CLI 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
|
|
|
|
```typescript
|
|
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)
|
|
|
|
```typescript
|
|
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)
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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.
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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()`:
|
|
|
|
```typescript
|
|
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 calls
|
|
- `getModelNamePatterns()` — regexes for naming-convention warnings
|
|
- `sendValidationProbe()` — minimal request used during `initialize()`
|
|
- `mapProviderError()` / `providerErrorMessages()` — provider-specific error translation
|
|
|
|
`OpenWebUIProvider` additionally uses an internal strategy split (`OpenWebUIChatStrategy` vs `OpenWebUIOllamaStrategy`) selected by `useOllamaProxy`.
|
|
|
|
## Development
|
|
|
|
```bash
|
|
git clone https://gitea.jleibl.net/jleibl/simple-ai-provider.git
|
|
cd simple-ai-provider
|
|
bun install
|
|
bun run build
|
|
```
|
|
|
|
Examples live in `examples/`:
|
|
|
|
```bash
|
|
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](LICENSE).
|
|
|
|
## Links
|
|
|
|
- [Anthropic Claude API](https://docs.anthropic.com/claude/reference/)
|
|
- [Claude Code SDK](https://docs.anthropic.com/claude/docs/claude-code-sdk)
|
|
- [OpenAI API](https://platform.openai.com/docs/)
|
|
- [Google Gen AI SDK](https://ai.google.dev/)
|
|
- [OpenWebUI](https://openwebui.com/)
|
|
- [Repository](https://gitea.jleibl.net/jleibl/simple-ai-provider)
|