Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b99707c629
|
|||
|
b935209142
|
|||
|
d4871d22fe
|
|||
|
9cb7ee8f9e
|
|||
|
a0d181787c
|
|||
|
93ef2611c0
|
|||
|
c8c579da8b
|
|||
|
e10ce7f53a
|
|||
|
6298a0027d
|
|||
|
8e430b2659
|
|||
|
3d985a95a8
|
|||
|
eacd76d259
|
|||
|
b36d57711b
|
|||
|
8e73296ac2
|
|||
|
bb37e61eaf
|
747
README.md
747
README.md
@@ -1,35 +1,10 @@
|
||||
# Simple AI Provider
|
||||
|
||||
A professional, type-safe TypeScript package that provides a unified interface for multiple AI providers. Currently supports **Claude (Anthropic)**, **OpenAI**, **Google Gemini**, and **OpenWebUI** with a consistent API across all providers.
|
||||
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).
|
||||
|
||||
## ✨ Features
|
||||
The same `complete()` / `stream()` interface works across every provider, with consistent error types, streaming, and optional structured (typed JSON) output.
|
||||
|
||||
- 🔗 **Unified Interface**: Same API for Claude, OpenAI, Gemini, and OpenWebUI
|
||||
- 🎯 **Type Safety**: Full TypeScript support with comprehensive type definitions
|
||||
- 🚀 **Streaming Support**: Real-time response streaming for all providers
|
||||
- 🛡️ **Error Handling**: Standardized error types with provider-specific details
|
||||
- 🏭 **Factory Pattern**: Easy provider creation and management
|
||||
- 🔧 **Configurable**: Extensive configuration options for each provider
|
||||
- 📦 **Zero Dependencies**: Lightweight with minimal external dependencies
|
||||
- 🌐 **Local Support**: OpenWebUI integration for local/private AI models
|
||||
- 🎨 **Structured Output**: Define custom response types for type-safe AI outputs
|
||||
- 🏗️ **Provider Registry**: Dynamic provider registration and creation system
|
||||
- ✅ **Comprehensive Testing**: Full test coverage with Bun test framework
|
||||
- 🔍 **Advanced Validation**: Input validation with detailed error messages
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The library is built on solid design principles:
|
||||
|
||||
- **Template Method Pattern**: Base provider defines the workflow, subclasses implement specifics
|
||||
- **Factory Pattern**: Clean provider creation and management
|
||||
- **Strategy Pattern**: Unified interface across different AI providers
|
||||
- **Type Safety**: Comprehensive TypeScript support throughout
|
||||
- **Error Normalization**: Consistent error handling across all providers
|
||||
- **Validation First**: Input validation before processing
|
||||
- **Extensibility**: Easy to add new providers via registry system
|
||||
|
||||
## 🚀 Quick Start
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install simple-ai-provider
|
||||
@@ -37,37 +12,14 @@ npm install simple-ai-provider
|
||||
bun add simple-ai-provider
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
Requires Node ≥ 18 (or Bun). TypeScript ≥ 5 is recommended as a peer dependency.
|
||||
|
||||
## Quick start
|
||||
|
||||
```typescript
|
||||
import { ClaudeProvider, OpenAIProvider, GeminiProvider, OpenWebUIProvider } from 'simple-ai-provider';
|
||||
import { ClaudeProvider } from 'simple-ai-provider';
|
||||
|
||||
// Claude
|
||||
const claude = new ClaudeProvider({
|
||||
apiKey: process.env.ANTHROPIC_API_KEY!,
|
||||
defaultModel: 'claude-3-5-sonnet-20241022'
|
||||
});
|
||||
|
||||
// OpenAI
|
||||
const openai = new OpenAIProvider({
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
defaultModel: 'gpt-4o'
|
||||
});
|
||||
|
||||
// Google Gemini
|
||||
const gemini = new GeminiProvider({
|
||||
apiKey: process.env.GOOGLE_AI_API_KEY!,
|
||||
defaultModel: 'gemini-1.5-flash'
|
||||
});
|
||||
|
||||
// OpenWebUI (local)
|
||||
const openwebui = new OpenWebUIProvider({
|
||||
apiKey: 'ollama', // Often not required
|
||||
baseUrl: 'http://localhost:3000',
|
||||
defaultModel: 'llama2'
|
||||
});
|
||||
|
||||
// Initialize and use any provider
|
||||
const claude = new ClaudeProvider({ apiKey: process.env.ANTHROPIC_API_KEY! });
|
||||
await claude.initialize();
|
||||
|
||||
const response = await claude.complete({
|
||||
@@ -75,183 +27,107 @@ const response = await claude.complete({
|
||||
{ role: 'system', content: 'You are a helpful assistant.' },
|
||||
{ role: 'user', content: 'Explain TypeScript in one sentence.' }
|
||||
],
|
||||
maxTokens: 100,
|
||||
temperature: 0.7
|
||||
maxTokens: 200
|
||||
});
|
||||
|
||||
console.log(response.content);
|
||||
console.log(response.usage); // { promptTokens, completionTokens, totalTokens }
|
||||
```
|
||||
|
||||
## 🏭 Factory Functions
|
||||
Every provider follows the same pattern: construct, `initialize()`, then `complete()` or `stream()`.
|
||||
|
||||
Create providers using factory functions for cleaner code:
|
||||
## Providers
|
||||
|
||||
### Claude (Anthropic API)
|
||||
|
||||
```typescript
|
||||
import { createProvider, createClaudeProvider, createOpenAIProvider, createGeminiProvider, createOpenWebUIProvider } from 'simple-ai-provider';
|
||||
import { ClaudeProvider } from 'simple-ai-provider';
|
||||
|
||||
// Method 1: Specific factory functions
|
||||
const claude = createClaudeProvider('your-key', { defaultModel: 'claude-3-5-sonnet-20241022' });
|
||||
const openai = createOpenAIProvider('your-key', { defaultModel: 'gpt-4o' });
|
||||
const gemini = createGeminiProvider('your-key', { defaultModel: 'gemini-1.5-flash' });
|
||||
const openwebui = createOpenWebUIProvider({ apiKey: 'your-key', baseUrl: 'http://localhost:3000' });
|
||||
|
||||
// Method 2: Generic factory
|
||||
const provider = createProvider('claude', { apiKey: 'your-key' });
|
||||
```
|
||||
|
||||
### Provider Registry
|
||||
|
||||
For dynamic provider creation and registration:
|
||||
|
||||
```typescript
|
||||
import { ProviderRegistry, ClaudeProvider } from 'simple-ai-provider';
|
||||
|
||||
// Register a custom provider
|
||||
ProviderRegistry.register('my-claude', ClaudeProvider);
|
||||
|
||||
// Create provider dynamically
|
||||
const provider = ProviderRegistry.create('my-claude', { apiKey: 'your-key' });
|
||||
|
||||
// Check available providers
|
||||
const availableProviders = ProviderRegistry.getRegisteredProviders();
|
||||
console.log(availableProviders); // ['claude', 'openai', 'gemini', 'openwebui', 'my-claude']
|
||||
```
|
||||
|
||||
## 🎨 Structured Response Types
|
||||
|
||||
Define custom response types for type-safe, structured AI outputs. The library automatically parses the AI's response into your desired type.
|
||||
|
||||
```typescript
|
||||
import { createResponseType, createClaudeProvider } from 'simple-ai-provider';
|
||||
|
||||
// 1. Define your response type
|
||||
interface ProductAnalysis {
|
||||
productName: string;
|
||||
priceRange: 'budget' | 'mid-range' | 'premium';
|
||||
pros: string[];
|
||||
cons: string[];
|
||||
overallRating: number; // 1-10 scale
|
||||
recommendation: 'buy' | 'consider' | 'avoid';
|
||||
}
|
||||
|
||||
// 2. Create a ResponseType object
|
||||
const productAnalysisType = createResponseType<ProductAnalysis>(
|
||||
'A comprehensive product analysis with pros, cons, rating, and recommendation'
|
||||
);
|
||||
|
||||
// 3. Use with any provider
|
||||
const claude = createClaudeProvider({ apiKey: 'your-key' });
|
||||
await claude.initialize();
|
||||
|
||||
const response = await claude.complete<ProductAnalysis>({
|
||||
messages: [
|
||||
{ role: 'user', content: 'Analyze the iPhone 15 Pro from a consumer perspective.' }
|
||||
],
|
||||
responseType: productAnalysisType,
|
||||
maxTokens: 800
|
||||
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
|
||||
});
|
||||
|
||||
// 4. Get the fully typed and parsed response
|
||||
const analysis = response.content;
|
||||
console.log(`Product: ${analysis.productName}`);
|
||||
console.log(`Recommendation: ${analysis.recommendation}`);
|
||||
console.log(`Rating: ${analysis.overallRating}/10`);
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
### Claude Code (subscription or API key via local CLI)
|
||||
|
||||
- **Automatic Parsing**: The AI's JSON response is automatically parsed into your specified type.
|
||||
- **Type Safety**: Get fully typed responses from AI providers with IntelliSense.
|
||||
- **Automatic Prompting**: System prompts are automatically generated to guide the AI.
|
||||
- **Validation**: Built-in response validation and parsing logic.
|
||||
- **Consistency**: Ensures AI outputs match your expected format.
|
||||
- **Developer Experience**: Catch errors at compile-time instead of runtime.
|
||||
`ClaudeCodeProvider` wraps `@anthropic-ai/claude-agent-sdk`, which spawns the local `claude` CLI under the hood. It supports two billing modes:
|
||||
|
||||
### Streaming with Response Types
|
||||
|
||||
You can also use response types with streaming. The raw stream provides real-time text, and you can parse the final string once the stream is complete.
|
||||
|
||||
```typescript
|
||||
import { parseAndValidateResponseType } from 'simple-ai-provider';
|
||||
|
||||
const stream = claude.stream({
|
||||
messages: [{ role: 'user', content: 'Analyze the Tesla Model 3.' }],
|
||||
responseType: productAnalysisType,
|
||||
maxTokens: 600
|
||||
});
|
||||
|
||||
let fullResponse = '';
|
||||
for await (const chunk of stream) {
|
||||
if (!chunk.isComplete) {
|
||||
process.stdout.write(chunk.content);
|
||||
fullResponse += chunk.content;
|
||||
} else {
|
||||
console.log('\n\nStream complete!');
|
||||
// Validate the complete streamed response
|
||||
try {
|
||||
const analysis = parseAndValidateResponseType(fullResponse, productAnalysisType);
|
||||
console.log('Validation successful!');
|
||||
console.log(`Product: ${analysis.productName}`);
|
||||
} catch (e) {
|
||||
console.error('Validation failed:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📝 Environment Variables
|
||||
|
||||
Set up your API keys:
|
||||
**Mode A — Claude Pro/Max subscription** (billed against subscription credits):
|
||||
|
||||
```bash
|
||||
# Required for respective providers
|
||||
export ANTHROPIC_API_KEY="your-claude-api-key"
|
||||
export OPENAI_API_KEY="your-openai-api-key"
|
||||
export GOOGLE_AI_API_KEY="your-gemini-api-key"
|
||||
|
||||
# OpenWebUI Bearer Token (get from Settings > Account in OpenWebUI)
|
||||
export OPENWEBUI_API_KEY="your-bearer-token"
|
||||
# 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
|
||||
```
|
||||
|
||||
## 🔧 Provider-Specific Configuration
|
||||
|
||||
### Claude Configuration
|
||||
|
||||
```typescript
|
||||
const claude = new ClaudeProvider({
|
||||
apiKey: 'your-api-key',
|
||||
defaultModel: 'claude-3-5-sonnet-20241022',
|
||||
version: '2023-06-01',
|
||||
maxRetries: 3,
|
||||
timeout: 30000
|
||||
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!' }]
|
||||
});
|
||||
```
|
||||
|
||||
### OpenAI Configuration
|
||||
**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: 'your-api-key',
|
||||
apiKey: process.env.OPENAI_API_KEY!,
|
||||
defaultModel: 'gpt-4o',
|
||||
organization: 'your-org-id',
|
||||
project: 'your-project-id',
|
||||
maxRetries: 3,
|
||||
timeout: 30000
|
||||
organization: 'org-...', // optional
|
||||
project: 'proj-...' // optional
|
||||
});
|
||||
```
|
||||
|
||||
### Gemini Configuration
|
||||
`baseUrl` is supported for OpenAI-compatible endpoints.
|
||||
|
||||
### Gemini (Google)
|
||||
|
||||
```typescript
|
||||
import { GeminiProvider } from 'simple-ai-provider';
|
||||
|
||||
const gemini = new GeminiProvider({
|
||||
apiKey: 'your-api-key',
|
||||
defaultModel: 'gemini-1.5-flash',
|
||||
safetySettings: [
|
||||
{
|
||||
category: 'HARM_CATEGORY_HARASSMENT',
|
||||
threshold: 'BLOCK_MEDIUM_AND_ABOVE'
|
||||
}
|
||||
],
|
||||
apiKey: process.env.GOOGLE_AI_API_KEY!,
|
||||
defaultModel: 'gemini-2.5-flash',
|
||||
safetySettings: [/* SafetySetting[] from @google/genai */],
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
topP: 0.8,
|
||||
@@ -261,441 +137,198 @@ const gemini = new GeminiProvider({
|
||||
});
|
||||
```
|
||||
|
||||
### OpenWebUI Configuration
|
||||
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', // Get from OpenWebUI Settings > Account
|
||||
baseUrl: 'http://localhost:3000', // Your OpenWebUI instance
|
||||
defaultModel: 'llama3.1',
|
||||
useOllamaProxy: false, // Use OpenWebUI's chat API (recommended)
|
||||
// useOllamaProxy: true, // Use Ollama API proxy for direct model access
|
||||
dangerouslyAllowInsecureConnections: true, // For local HTTPS
|
||||
timeout: 60000, // Longer timeout for local inference
|
||||
maxRetries: 2
|
||||
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
|
||||
});
|
||||
```
|
||||
|
||||
## 🌊 Streaming Support
|
||||
`useOllamaProxy` flips between two internal strategies — the OpenAI-compatible chat completions endpoint and the direct Ollama generate endpoint.
|
||||
|
||||
All providers support real-time streaming:
|
||||
## Streaming
|
||||
|
||||
Identical shape across providers:
|
||||
|
||||
```typescript
|
||||
const stream = provider.stream({
|
||||
messages: [{ role: 'user', content: 'Count from 1 to 10' }],
|
||||
maxTokens: 100
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
for await (const chunk of provider.stream({ messages, maxTokens: 200 })) {
|
||||
if (!chunk.isComplete) {
|
||||
process.stdout.write(chunk.content);
|
||||
} else {
|
||||
console.log('\nDone! Usage:', chunk.usage);
|
||||
console.log('\n', chunk.usage);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔀 Multi-Provider Usage
|
||||
## Structured output
|
||||
|
||||
Use multiple providers seamlessly:
|
||||
Ask any provider for a typed JSON response. The library injects a system prompt describing the expected shape and parses the result.
|
||||
|
||||
```typescript
|
||||
const providers = {
|
||||
claude: new ClaudeProvider({ apiKey: process.env.ANTHROPIC_API_KEY! }),
|
||||
openai: new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY! }),
|
||||
gemini: new GeminiProvider({ apiKey: process.env.GOOGLE_AI_API_KEY! }),
|
||||
openwebui: new OpenWebUIProvider({
|
||||
apiKey: 'ollama',
|
||||
baseUrl: 'http://localhost:3000'
|
||||
})
|
||||
};
|
||||
import { createResponseType } from 'simple-ai-provider';
|
||||
|
||||
// Initialize all providers
|
||||
await Promise.all(Object.values(providers).map(p => p.initialize()));
|
||||
|
||||
// Use the same interface for all
|
||||
const prompt = {
|
||||
messages: [{ role: 'user', content: 'Hello!' }],
|
||||
maxTokens: 50
|
||||
};
|
||||
|
||||
for (const [name, provider] of Object.entries(providers)) {
|
||||
try {
|
||||
const response = await provider.complete(prompt);
|
||||
console.log(`${name}: ${response.content}`);
|
||||
} catch (error) {
|
||||
console.log(`${name} failed: ${error.message}`);
|
||||
}
|
||||
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);
|
||||
```
|
||||
|
||||
## 📊 Provider Comparison
|
||||
This also works with `stream()` — chunks deliver text, then the final chunk parses.
|
||||
|
||||
| Provider | Context Length | Streaming | Vision | Function Calling | Local Execution | Best For |
|
||||
|----------|---------------|-----------|--------|------------------|-----------------|----------|
|
||||
| **Claude** | 200K tokens | ✅ | ✅ | ✅ | ❌ | Reasoning, Analysis, Code Review |
|
||||
| **OpenAI** | 128K tokens | ✅ | ✅ | ✅ | ❌ | General Purpose, Function Calling |
|
||||
| **Gemini** | 1M tokens | ✅ | ✅ | ✅ | ❌ | Large Documents, Multimodal |
|
||||
| **OpenWebUI** | 32K tokens | ✅ | ❌ | ❌ | ✅ | Privacy, Custom Models, Local |
|
||||
## Factory functions
|
||||
|
||||
### Detailed Capabilities
|
||||
|
||||
Each provider offers unique capabilities:
|
||||
|
||||
#### Claude (Anthropic)
|
||||
|
||||
- Advanced reasoning and analysis
|
||||
- Excellent code review capabilities
|
||||
- Strong safety features
|
||||
- System message support
|
||||
|
||||
#### OpenAI
|
||||
|
||||
- Broad model selection
|
||||
- Function calling support
|
||||
- JSON mode for structured outputs
|
||||
- Vision capabilities
|
||||
|
||||
#### Gemini (Google)
|
||||
|
||||
- Largest context window (1M tokens)
|
||||
- Multimodal capabilities
|
||||
- Cost-effective pricing
|
||||
- Strong multilingual support
|
||||
|
||||
#### OpenWebUI
|
||||
|
||||
- Complete privacy (local execution)
|
||||
- Custom model support
|
||||
- No API costs
|
||||
- RAG (Retrieval Augmented Generation) support
|
||||
|
||||
## 🎯 Model Selection
|
||||
|
||||
### Getting Available Models
|
||||
|
||||
Instead of maintaining a static list, you can programmatically get available models:
|
||||
If you prefer a single entry point:
|
||||
|
||||
```typescript
|
||||
// Get provider information including available models
|
||||
const info = provider.getInfo();
|
||||
console.log('Available models:', info.models);
|
||||
import { createProvider } from 'simple-ai-provider';
|
||||
|
||||
// Example output:
|
||||
// Claude: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', ...]
|
||||
// OpenAI: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', ...]
|
||||
// Gemini: ['gemini-1.5-flash', 'gemini-1.5-pro', ...]
|
||||
// OpenWebUI: ['llama3.1:latest', 'mistral:latest', ...]
|
||||
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', {});
|
||||
```
|
||||
|
||||
### Model Selection Guidelines
|
||||
Per-provider shortcut factories also exist: `createClaudeProvider`, `createOpenAIProvider`, `createGeminiProvider`, `createOpenWebUIProvider`, `createClaudeCodeProvider`.
|
||||
|
||||
**For Claude (Anthropic):**
|
||||
- Check [Anthropic's model documentation](https://docs.anthropic.com/claude/docs/models-overview) for latest models
|
||||
## Error handling
|
||||
|
||||
**For OpenAI:**
|
||||
- Check [OpenAI's model documentation](https://platform.openai.com/docs/models) for latest models
|
||||
|
||||
**For Gemini (Google):**
|
||||
- Check [Google AI's model documentation](https://ai.google.dev/docs/models) for latest models
|
||||
|
||||
**For OpenWebUI:**
|
||||
- Models depend on your local installation
|
||||
- Check your OpenWebUI instance for available models
|
||||
|
||||
## 🚨 Error Handling
|
||||
|
||||
The package provides comprehensive, standardized error handling with detailed error types:
|
||||
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 {
|
||||
const response = await provider.complete({
|
||||
messages: [{ role: 'user', content: 'Hello' }]
|
||||
});
|
||||
await provider.complete({ messages: [...] });
|
||||
} catch (error) {
|
||||
if (error instanceof AIProviderError) {
|
||||
switch (error.type) {
|
||||
case AIErrorType.AUTHENTICATION:
|
||||
console.log('Invalid API key or authentication failed');
|
||||
break;
|
||||
case AIErrorType.RATE_LIMIT:
|
||||
console.log('Rate limited, try again later');
|
||||
break;
|
||||
case AIErrorType.MODEL_NOT_FOUND:
|
||||
console.log('Model not available or not found');
|
||||
break;
|
||||
case AIErrorType.INVALID_REQUEST:
|
||||
console.log('Invalid request parameters');
|
||||
break;
|
||||
case AIErrorType.NETWORK:
|
||||
console.log('Network/connection issue');
|
||||
break;
|
||||
case AIErrorType.TIMEOUT:
|
||||
console.log('Request timed out');
|
||||
break;
|
||||
case AIErrorType.UNKNOWN:
|
||||
console.log('Unknown error:', error.message);
|
||||
break;
|
||||
default:
|
||||
console.log('Error:', error.message);
|
||||
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;
|
||||
}
|
||||
|
||||
// Access additional error details
|
||||
console.log('Status Code:', error.statusCode);
|
||||
console.log('Original Error:', error.originalError);
|
||||
console.error(error.statusCode, error.originalError);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Types
|
||||
## Configuration reference
|
||||
|
||||
- **AUTHENTICATION**: Invalid API keys or authentication failures
|
||||
- **RATE_LIMIT**: API rate limits exceeded
|
||||
- **INVALID_REQUEST**: Malformed requests or invalid parameters
|
||||
- **MODEL_NOT_FOUND**: Requested model is not available
|
||||
- **NETWORK**: Connection issues or server errors
|
||||
- **TIMEOUT**: Request timeout exceeded
|
||||
- **UNKNOWN**: Unclassified errors
|
||||
All providers share these base options:
|
||||
|
||||
## 🔧 Advanced Usage
|
||||
| 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 |
|
||||
|
||||
### Custom Base URLs
|
||||
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
|
||||
// OpenAI-compatible endpoint
|
||||
const customOpenAI = new OpenAIProvider({
|
||||
apiKey: 'your-key',
|
||||
baseUrl: 'https://api.custom-provider.com/v1'
|
||||
});
|
||||
import type {
|
||||
AIMessage,
|
||||
CompletionParams,
|
||||
CompletionResponse,
|
||||
CompletionChunk,
|
||||
TokenUsage,
|
||||
ProviderInfo,
|
||||
ResponseType,
|
||||
|
||||
// Custom OpenWebUI instance
|
||||
const remoteOpenWebUI = new OpenWebUIProvider({
|
||||
apiKey: 'your-key',
|
||||
baseUrl: 'https://my-openwebui.example.com',
|
||||
apiPath: '/api/v1'
|
||||
});
|
||||
// Per-provider config interfaces
|
||||
ClaudeConfig,
|
||||
ClaudeCodeConfig,
|
||||
OpenAIConfig,
|
||||
GeminiConfig,
|
||||
OpenWebUIConfig
|
||||
} from 'simple-ai-provider';
|
||||
```
|
||||
|
||||
### Provider Information
|
||||
`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();
|
||||
console.log(`Provider: ${info.name} v${info.version}`);
|
||||
console.log(`Models: ${info.models.join(', ')}`);
|
||||
console.log(`Max Context: ${info.maxContextLength} tokens`);
|
||||
console.log(`Supports Streaming: ${info.supportsStreaming}`);
|
||||
console.log('Capabilities:', info.capabilities);
|
||||
// { name, version, models, maxContextLength, supportsStreaming, capabilities }
|
||||
```
|
||||
|
||||
### OpenWebUI-Specific Features
|
||||
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.
|
||||
|
||||
OpenWebUI offers unique advantages for local AI deployment:
|
||||
## Architecture (brief)
|
||||
|
||||
```typescript
|
||||
const openwebui = new OpenWebUIProvider({
|
||||
apiKey: 'your-bearer-token', // Get from OpenWebUI Settings > Account
|
||||
baseUrl: 'http://localhost:3000',
|
||||
defaultModel: 'llama3.1',
|
||||
useOllamaProxy: false, // Use chat completions API (recommended)
|
||||
// Longer timeout for local inference
|
||||
timeout: 120000,
|
||||
// Allow self-signed certificates for local development
|
||||
dangerouslyAllowInsecureConnections: true
|
||||
});
|
||||
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:
|
||||
|
||||
// Test connection and list available models
|
||||
try {
|
||||
await openwebui.initialize();
|
||||
console.log('Connected to local OpenWebUI instance');
|
||||
|
||||
// Use either chat completions or Ollama proxy
|
||||
const response = await openwebui.complete({
|
||||
messages: [{ role: 'user', content: 'Hello!' }],
|
||||
maxTokens: 100
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('OpenWebUI not available:', error.message);
|
||||
// Gracefully fallback to cloud providers
|
||||
}
|
||||
```
|
||||
- `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
|
||||
|
||||
**OpenWebUI API Modes:**
|
||||
`OpenWebUIProvider` additionally uses an internal strategy split (`OpenWebUIChatStrategy` vs `OpenWebUIOllamaStrategy`) selected by `useOllamaProxy`.
|
||||
|
||||
- **Chat Completions** (`useOllamaProxy: false`): OpenWebUI's native API with full features
|
||||
- **Ollama Proxy** (`useOllamaProxy: true`): Direct access to Ollama API for raw model interaction
|
||||
|
||||
## 📦 TypeScript Support
|
||||
|
||||
Full TypeScript support with comprehensive type definitions:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
CompletionParams,
|
||||
CompletionResponse,
|
||||
CompletionChunk,
|
||||
ProviderInfo,
|
||||
ClaudeConfig,
|
||||
OpenAIConfig,
|
||||
GeminiConfig,
|
||||
OpenWebUIConfig,
|
||||
AIMessage,
|
||||
ResponseType,
|
||||
TokenUsage
|
||||
} from 'simple-ai-provider';
|
||||
|
||||
// Type-safe configuration
|
||||
const config: ClaudeConfig = {
|
||||
apiKey: 'your-key',
|
||||
defaultModel: 'claude-3-5-sonnet-20241022',
|
||||
// TypeScript will validate all options
|
||||
};
|
||||
|
||||
// Type-safe responses
|
||||
const response: CompletionResponse = await provider.complete(params);
|
||||
|
||||
// Type-safe messages with metadata
|
||||
const messages: AIMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
metadata: { timestamp: Date.now() }
|
||||
}
|
||||
];
|
||||
|
||||
// Type-safe response types
|
||||
interface UserProfile {
|
||||
name: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
const responseType: ResponseType<UserProfile> = createResponseType(
|
||||
'A user profile with name and age',
|
||||
{ name: 'John', age: 30 }
|
||||
);
|
||||
```
|
||||
|
||||
### Advanced Type Features
|
||||
|
||||
- **Generic Response Types**: Type-safe structured outputs
|
||||
- **Message Metadata**: Support for custom message properties
|
||||
- **Provider-Specific Configs**: Type-safe configuration for each provider
|
||||
- **Error Types**: Comprehensive error type definitions
|
||||
- **Factory Functions**: Type-safe provider creation
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
The package includes comprehensive tests using Bun test framework:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
bun test
|
||||
|
||||
# Run tests for specific provider
|
||||
bun test tests/claude.test.ts
|
||||
bun test tests/openai.test.ts
|
||||
bun test tests/gemini.test.ts
|
||||
bun test tests/openwebui.test.ts
|
||||
|
||||
# Run tests with coverage
|
||||
bun test --coverage
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- ✅ Provider initialization and configuration
|
||||
- ✅ Message validation and conversion
|
||||
- ✅ Error handling and normalization
|
||||
- ✅ Response formatting
|
||||
- ✅ Streaming functionality
|
||||
- ✅ Structured response types
|
||||
- ✅ Factory functions
|
||||
- ✅ Provider registry
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18.0.0 or higher
|
||||
- Bun (recommended) or npm/yarn
|
||||
- TypeScript 5.0 or higher
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://gitea.jleibl.net/jleibl/simple-ai-provider.git
|
||||
cd simple-ai-provider
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Build the project
|
||||
bun run build
|
||||
```
|
||||
|
||||
# Run tests
|
||||
bun test
|
||||
Examples live in `examples/`:
|
||||
|
||||
# Run examples
|
||||
```bash
|
||||
bun run examples/basic-usage.ts
|
||||
bun run examples/structured-response-types.ts
|
||||
bun run examples/multi-provider.ts
|
||||
bun run examples/structured-response-types.ts
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
Tests in `tests/` use Bun's test runner (`bun test`). Note: post-refactor some tests need updating before they pass cleanly.
|
||||
|
||||
```text
|
||||
src/
|
||||
├── index.ts # Main entry point
|
||||
├── types/
|
||||
│ └── index.ts # Type definitions and utilities
|
||||
├── providers/
|
||||
│ ├── base.ts # Abstract base provider
|
||||
│ ├── claude.ts # Claude provider implementation
|
||||
│ ├── openai.ts # OpenAI provider implementation
|
||||
│ ├── gemini.ts # Gemini provider implementation
|
||||
│ ├── openwebui.ts # OpenWebUI provider implementation
|
||||
│ └── index.ts # Provider exports
|
||||
└── utils/
|
||||
└── factory.ts # Factory functions and registry
|
||||
## License
|
||||
|
||||
examples/
|
||||
├── basic-usage.ts # Basic usage examples
|
||||
├── structured-response-types.ts # Structured output examples
|
||||
└── multi-provider.ts # Multi-provider examples
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
tests/
|
||||
├── claude.test.ts # Claude provider tests
|
||||
├── openai.test.ts # OpenAI provider tests
|
||||
├── gemini.test.ts # Gemini provider tests
|
||||
└── openwebui.test.ts # OpenWebUI provider tests
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
1. **Code Style**: Follow the existing TypeScript patterns
|
||||
2. **Testing**: Add tests for new features
|
||||
3. **Documentation**: Update README for new features
|
||||
4. **Type Safety**: Maintain comprehensive type definitions
|
||||
5. **Error Handling**: Use standardized error types
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🔗 Links
|
||||
## 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 Gemini API](https://ai.google.dev/)
|
||||
- [Google Gen AI SDK](https://ai.google.dev/)
|
||||
- [OpenWebUI](https://openwebui.com/)
|
||||
- [Gitea Repository](https://gitea.jleibl.net/jleibl/simple-ai-provider)
|
||||
|
||||
---
|
||||
|
||||
⭐ **Star this repo if you find it helpful!**
|
||||
- [Repository](https://gitea.jleibl.net/jleibl/simple-ai-provider)
|
||||
|
||||
199
bun.lock
199
bun.lock
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "simple-ai-provider",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.146",
|
||||
"@anthropic-ai/sdk": "^0.97.0",
|
||||
"@google/genai": "^2.5.0",
|
||||
"openai": "^6.0.0",
|
||||
@@ -19,12 +20,34 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.146", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.146", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.146", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.146", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.146", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.146", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.146", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.146", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.146" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-hK9/Ng+hOyexUemTxdIUsSWJ9o2LFi2YNWzHwz8/YMCohUYOnFMZkBiENvUAb0WIc5hieOyBZrOIlg5OewuJMg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.146", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0IIvlEaenq2CRSVx5Bo5BaCtHQXS87GancM35WKEYveGVLn6DI+5G7ikYuTE4AKRPkMnogFtY4BJt6LulWGj+A=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.146", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dk5xJ03Ff1JXbMRP1t2wc/TyfY6xF/2Ysp31wMhFPjoNiKSPHMWaIg242+T3CHdxLWmJ8plWHL1HL5cyZ/LCkw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.146", "", { "os": "linux", "cpu": "arm64" }, "sha512-mzBXDDWWBAC/vDtAYpO1G/dq5QvJtYSPXsqcb+sNdcDhiuf4IYnYp7ytRncYlsUNDkLmX6Gk2jkWAHUUA2Lozg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.146", "", { "os": "linux", "cpu": "arm64" }, "sha512-QlCid0ucdrmhUAOewfQjaofN2wlokWcfFTxSFePTSj1umk35JO7TDFP700F7jU49r1fPWIdvJpPwWGyB0DeFPA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.146", "", { "os": "linux", "cpu": "x64" }, "sha512-B2baXU1tCBT5CVlD7jJMKjpC4xdO45NUIWpqImmwuOfKvlM/PITjyTXyTY662mGZf1dBmdqBBsqirwFH/jhi8Q=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.146", "", { "os": "linux", "cpu": "x64" }, "sha512-E3coK1ThQT08KIX80RLcsq7DWXFllCKOzoOe32it/bdtY56TBgPY9xemwXhIJ+cVBHTI9/MpBSIlKBcFCt+yQA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.146", "", { "os": "win32", "cpu": "arm64" }, "sha512-CIwQxGX2r/yWpjCJ6ahB3smKXhghWgGTxL98+LGW52TUwqTiBnlNrH9DPqqgv1/+Hyquw6xfLrKU+StyfMgiLw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.146", "", { "os": "win32", "cpu": "x64" }, "sha512-qmxrsyaqA8s4HShqJls7ZCRjdoqN66Jo/hbjQNB3uHepD8tEO1iD19aPV4+osdLT7feMkhDBfLT07Q30R2NB5w=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.97.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-wOf7AUeJPitcVpvKO4UMu63mWH5SaVipkGd7OOQJt/G6VYGlV8D2Gp9dLxOrttDJh/9gqPqdaBwDGcBevumeAg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@google/genai": ["@google/genai@2.5.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-qDi3LLh9I3llJK0f9uV8kZ8EdT9oHPxGJJ9yOJ/i5YXYrVwRCs8jHo9x4e99uOeKYDvD3TZwT70p/H/LS3BixQ=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
@@ -53,80 +76,256 @@
|
||||
|
||||
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"hono": ["hono@4.12.21", "", {}, "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="],
|
||||
|
||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.6.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||
|
||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
}
|
||||
}
|
||||
|
||||
61
examples/claude-code.ts
Normal file
61
examples/claude-code.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Quick smoke test for ClaudeCodeProvider.
|
||||
*
|
||||
* Prereqs (pick one):
|
||||
* - Claude Pro/Max subscription: run `claude setup-token`, then set
|
||||
* CLAUDE_CODE_OAUTH_TOKEN=<the token it prints> in your environment
|
||||
* (or pass it as `oauthToken` below).
|
||||
* - Console API key: set ANTHROPIC_API_KEY (or pass `apiKey`).
|
||||
*
|
||||
* `claude login` alone is NOT sufficient for SDK use — Anthropic gates
|
||||
* non-interactive (`claude -p`) invocations behind a separate token.
|
||||
*
|
||||
* Run: bun run examples/claude-code.ts
|
||||
*/
|
||||
|
||||
import { ClaudeCodeProvider } from '../src/index.js';
|
||||
|
||||
async function main() {
|
||||
const claude = new ClaudeCodeProvider({
|
||||
defaultModel: 'sonnet',
|
||||
oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||
});
|
||||
|
||||
console.log('Initializing…');
|
||||
await claude.initialize();
|
||||
console.log('Ready.\n');
|
||||
|
||||
// ── Non-streaming completion ───────────────────────────────────────────
|
||||
console.log('--- complete() ---');
|
||||
const response = await claude.complete({
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are concise. Reply in one sentence.' },
|
||||
{ role: 'user', content: 'What is the capital of Japan?' }
|
||||
],
|
||||
maxTokens: 100
|
||||
});
|
||||
|
||||
console.log(response.content);
|
||||
console.log(`tokens: ${response.usage.totalTokens} | cost: $${response.metadata?.costUsd ?? '?'}\n`);
|
||||
|
||||
// ── Streaming ──────────────────────────────────────────────────────────
|
||||
console.log('--- stream() ---');
|
||||
let totalTokens = 0;
|
||||
for await (const chunk of claude.stream({
|
||||
messages: [{ role: 'user', content: 'Count from 1 to 5, one per line.' }],
|
||||
maxTokens: 100
|
||||
})) {
|
||||
if (!chunk.isComplete) {
|
||||
process.stdout.write(chunk.content);
|
||||
} else {
|
||||
totalTokens = chunk.usage?.totalTokens ?? 0;
|
||||
}
|
||||
}
|
||||
console.log(`\ntokens: ${totalTokens}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Failed:', err.message);
|
||||
if (err.type) console.error('Type:', err.type);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "simple-ai-provider",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "A simple and extensible AI provider package for easy integration of multiple AI services",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
@@ -21,8 +21,8 @@
|
||||
"build": "bun run build:clean && bun run build:types && bun run build:esm && bun run build:cjs",
|
||||
"build:clean": "rm -rf dist",
|
||||
"build:types": "tsc --project tsconfig.build.json",
|
||||
"build:esm": "bun build src/index.ts --outfile dist/index.mjs --format esm",
|
||||
"build:cjs": "bun build src/index.ts --outfile dist/index.js --format cjs",
|
||||
"build:esm": "bun build src/index.ts --outfile dist/index.mjs --format esm --target node",
|
||||
"build:cjs": "bun build src/index.ts --outfile dist/index.js --format cjs --target node",
|
||||
"dev": "bun run src/index.ts",
|
||||
"test": "bun test",
|
||||
"prepublishOnly": "bun run build"
|
||||
@@ -52,6 +52,7 @@
|
||||
"url": "https://gitea.jleibl.net/jleibl/simple-ai-provider.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.146",
|
||||
"@anthropic-ai/sdk": "^0.97.0",
|
||||
"@google/genai": "^2.5.0",
|
||||
"openai": "^6.0.0"
|
||||
|
||||
36
src/constants.ts
Normal file
36
src/constants.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared defaults used across all providers.
|
||||
*/
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
export const DEFAULT_MAX_RETRIES = 3;
|
||||
export const DEFAULT_MAX_TOKENS = 1000;
|
||||
export const DEFAULT_TEMPERATURE = 0.7;
|
||||
|
||||
/**
|
||||
* Minimal prompt used to validate API credentials on initialization.
|
||||
*/
|
||||
export const VALIDATION_PROMPT = 'Hi';
|
||||
|
||||
export const DEFAULT_MODELS = {
|
||||
claude: 'claude-3-5-sonnet-20241022',
|
||||
openai: 'gpt-4o',
|
||||
gemini: 'gemini-2.5-flash',
|
||||
openwebui: 'llama3.1:latest',
|
||||
'claude-code': 'sonnet'
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_OPENWEBUI_BASE_URL = 'http://localhost:3000';
|
||||
|
||||
export const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
|
||||
|
||||
/**
|
||||
* Bounds for configuration value validation.
|
||||
*/
|
||||
export const CONFIG_BOUNDS = {
|
||||
timeoutMs: { min: 1000, max: 300_000 },
|
||||
maxRetries: { min: 0, max: 10 },
|
||||
temperature: { min: 0, max: 1 },
|
||||
topP: { min: 0, max: 1, exclusiveMin: true },
|
||||
maxTokens: { min: 1 }
|
||||
} as const;
|
||||
@@ -38,6 +38,7 @@ export { ClaudeProvider, type ClaudeConfig } from './providers/claude.js';
|
||||
export { OpenAIProvider, type OpenAIConfig } from './providers/openai.js';
|
||||
export { GeminiProvider, type GeminiConfig } from './providers/gemini.js';
|
||||
export { OpenWebUIProvider, type OpenWebUIConfig } from './providers/openwebui.js';
|
||||
export { ClaudeCodeProvider, type ClaudeCodeConfig } from './providers/claude-code.js';
|
||||
|
||||
// Factory utilities
|
||||
export {
|
||||
@@ -46,6 +47,7 @@ export {
|
||||
createOpenAIProvider,
|
||||
createGeminiProvider,
|
||||
createOpenWebUIProvider,
|
||||
createClaudeCodeProvider,
|
||||
type ProviderType,
|
||||
PROVIDER_REGISTRY
|
||||
} from './utils/factory.js';
|
||||
@@ -53,9 +55,9 @@ export {
|
||||
/**
|
||||
* List of all supported providers
|
||||
*/
|
||||
export const SUPPORTED_PROVIDERS = ['claude', 'openai', 'gemini', 'openwebui'] as const;
|
||||
export const SUPPORTED_PROVIDERS = ['claude', 'openai', 'gemini', 'openwebui', 'claude-code'] as const;
|
||||
|
||||
/**
|
||||
* Package version
|
||||
*/
|
||||
export const VERSION = '1.3.1';
|
||||
export const VERSION = '2.1.0';
|
||||
|
||||
@@ -23,6 +23,11 @@ import type {
|
||||
ResponseType
|
||||
} from '../types/index.js';
|
||||
import { AIProviderError, AIErrorType, generateResponseTypePrompt, parseAndValidateResponseType } from '../types/index.js';
|
||||
import {
|
||||
CONFIG_BOUNDS,
|
||||
DEFAULT_MAX_RETRIES,
|
||||
DEFAULT_TIMEOUT_MS
|
||||
} from '../constants.js';
|
||||
|
||||
// ============================================================================
|
||||
// ABSTRACT BASE PROVIDER CLASS
|
||||
@@ -283,10 +288,104 @@ export abstract class BaseAIProvider {
|
||||
*/
|
||||
protected abstract doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk>;
|
||||
|
||||
// ========================================================================
|
||||
// PROTECTED HOOKS (subclass overrides; safe defaults provided)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Regex patterns that valid model names should match.
|
||||
* Empty array (the default) skips validation entirely.
|
||||
*/
|
||||
protected getModelNamePatterns(): RegExp[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a minimal request to verify the API key and connectivity.
|
||||
* The default does nothing; SDK-based providers override this and the
|
||||
* base `validateConnection` handles error mapping.
|
||||
*/
|
||||
protected async sendValidationProbe(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a provider-specific error to a normalized AIProviderError, or
|
||||
* return null to let the base error mapping handle it generically.
|
||||
* Override to recognize provider-specific message patterns (e.g. "API
|
||||
* key", "quota", "model not found").
|
||||
*/
|
||||
protected mapProviderError(_error: any): AIProviderError | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-HTTP-status message overrides used by `normalizeError` when no
|
||||
* provider-specific mapping applies. Lets providers add brand-flavored
|
||||
* hints (console URLs, etc.) without re-implementing the status switch.
|
||||
*/
|
||||
protected providerErrorMessages(): Partial<Record<number, string>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable provider name used in warning messages.
|
||||
* Default derives from getInfo(); override if construction order makes
|
||||
* getInfo() unsafe to call here.
|
||||
*/
|
||||
protected get providerName(): string {
|
||||
try {
|
||||
return this.getInfo().name;
|
||||
} catch {
|
||||
return 'Provider';
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PROTECTED UTILITY METHODS
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Validates a model name against the patterns returned by
|
||||
* `getModelNamePatterns()`. Throws for invalid input, warns for
|
||||
* non-matching names (since model lists change frequently).
|
||||
*/
|
||||
protected validateModelName(modelName: string): void {
|
||||
if (!modelName || typeof modelName !== 'string') {
|
||||
throw new AIProviderError(
|
||||
'Model name must be a non-empty string',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
const patterns = this.getModelNamePatterns();
|
||||
if (patterns.length === 0) return;
|
||||
|
||||
const matches = patterns.some(pattern => pattern.test(modelName));
|
||||
if (!matches) {
|
||||
console.warn(
|
||||
`Model name '${modelName}' doesn't match expected ${this.providerName} naming patterns. This may cause API errors.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps `sendValidationProbe()` with consistent error handling: throws
|
||||
* AIProviderError for provider-recognized failures, warns and continues
|
||||
* for transient ones.
|
||||
*/
|
||||
protected async validateConnection(): Promise<void> {
|
||||
try {
|
||||
await this.sendValidationProbe();
|
||||
} catch (error: any) {
|
||||
const mapped = this.mapProviderError(error);
|
||||
if (mapped) {
|
||||
throw mapped;
|
||||
}
|
||||
console.warn(`${this.providerName} connection validation warning:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes provider configuration.
|
||||
*
|
||||
@@ -314,60 +413,78 @@ export abstract class BaseAIProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// Apply defaults and return normalized config
|
||||
this.validateNumberInRange('timeout', config.timeout, {
|
||||
...CONFIG_BOUNDS.timeoutMs,
|
||||
label: `${CONFIG_BOUNDS.timeoutMs.min}ms and ${CONFIG_BOUNDS.timeoutMs.max}ms`
|
||||
});
|
||||
this.validateNumberInRange('maxRetries', config.maxRetries, {
|
||||
...CONFIG_BOUNDS.maxRetries,
|
||||
integer: true
|
||||
});
|
||||
|
||||
return {
|
||||
...config,
|
||||
timeout: this.validateTimeout(config.timeout),
|
||||
maxRetries: this.validateMaxRetries(config.maxRetries)
|
||||
timeout: config.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates timeout configuration value.
|
||||
*
|
||||
* @private
|
||||
* @param timeout - Timeout value to validate
|
||||
* @returns Validated timeout value with default if needed
|
||||
* Validates an optional numeric parameter against a range.
|
||||
* Skips validation when value is undefined so callers can apply defaults afterward.
|
||||
*/
|
||||
private validateTimeout(timeout?: number): number {
|
||||
const defaultTimeout = 30000; // 30 seconds
|
||||
|
||||
if (timeout === undefined) {
|
||||
return defaultTimeout;
|
||||
private validateNumberInRange(
|
||||
name: string,
|
||||
value: number | undefined,
|
||||
bounds: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
exclusiveMin?: boolean;
|
||||
integer?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
): void {
|
||||
if (value === undefined) return;
|
||||
|
||||
if (typeof timeout !== 'number' || timeout < 1000 || timeout > 300000) {
|
||||
const { min, max, exclusiveMin, integer, label } = bounds;
|
||||
const range = label ?? this.describeRange(min, max, exclusiveMin);
|
||||
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
throw new AIProviderError(
|
||||
'Timeout must be a number between 1000ms (1s) and 300000ms (5min)',
|
||||
`${name} must be a number between ${range}`,
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return timeout;
|
||||
if (integer && !Number.isInteger(value)) {
|
||||
throw new AIProviderError(
|
||||
`${name} must be an integer`,
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
if (min !== undefined && (exclusiveMin ? value <= min : value < min)) {
|
||||
throw new AIProviderError(
|
||||
`${name} must be ${exclusiveMin ? 'greater than' : 'at least'} ${min}`,
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
if (max !== undefined && value > max) {
|
||||
throw new AIProviderError(
|
||||
`${name} must be at most ${max}`,
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates max retries configuration value.
|
||||
*
|
||||
* @private
|
||||
* @param maxRetries - Max retries value to validate
|
||||
* @returns Validated max retries value with default if needed
|
||||
*/
|
||||
private validateMaxRetries(maxRetries?: number): number {
|
||||
const defaultMaxRetries = 3;
|
||||
|
||||
if (maxRetries === undefined) {
|
||||
return defaultMaxRetries;
|
||||
private describeRange(min?: number, max?: number, exclusiveMin?: boolean): string {
|
||||
if (min !== undefined && max !== undefined) {
|
||||
return `${exclusiveMin ? '>' : '>='}${min} and <=${max}`;
|
||||
}
|
||||
|
||||
if (typeof maxRetries !== 'number' || maxRetries < 0 || maxRetries > 10) {
|
||||
throw new AIProviderError(
|
||||
'Max retries must be a number between 0 and 10',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return maxRetries;
|
||||
if (min !== undefined) return `${exclusiveMin ? '>' : '>='}${min}`;
|
||||
if (max !== undefined) return `<=${max}`;
|
||||
return 'a valid number';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -403,13 +520,11 @@ export abstract class BaseAIProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate messages array
|
||||
this.validateMessages(params.messages);
|
||||
|
||||
// Validate optional parameters
|
||||
this.validateTemperature(params.temperature);
|
||||
this.validateTopP(params.topP);
|
||||
this.validateMaxTokens(params.maxTokens);
|
||||
this.validateNumberInRange('temperature', params.temperature, CONFIG_BOUNDS.temperature);
|
||||
this.validateNumberInRange('topP', params.topP, CONFIG_BOUNDS.topP);
|
||||
this.validateNumberInRange('maxTokens', params.maxTokens, { ...CONFIG_BOUNDS.maxTokens, integer: true });
|
||||
this.validateStopSequences(params.stopSequences);
|
||||
}
|
||||
|
||||
@@ -456,67 +571,6 @@ export abstract class BaseAIProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates temperature parameter.
|
||||
*
|
||||
* @private
|
||||
* @param temperature - Temperature value to validate
|
||||
* @throws {AIProviderError} If temperature is invalid
|
||||
*/
|
||||
private validateTemperature(temperature?: number): void {
|
||||
if (temperature !== undefined) {
|
||||
if (typeof temperature !== 'number' || temperature < 0 || temperature > 1) {
|
||||
throw new AIProviderError(
|
||||
'Temperature must be a number between 0.0 and 1.0',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates top-p parameter.
|
||||
*
|
||||
* @private
|
||||
* @param topP - Top-p value to validate
|
||||
* @throws {AIProviderError} If top-p is invalid
|
||||
*/
|
||||
private validateTopP(topP?: number): void {
|
||||
if (topP !== undefined) {
|
||||
if (typeof topP !== 'number' || topP <= 0 || topP > 1) {
|
||||
throw new AIProviderError(
|
||||
'Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates max tokens parameter.
|
||||
*
|
||||
* @private
|
||||
* @param maxTokens - Max tokens value to validate
|
||||
* @throws {AIProviderError} If max tokens is invalid
|
||||
*/
|
||||
private validateMaxTokens(maxTokens?: number): void {
|
||||
if (maxTokens !== undefined) {
|
||||
if (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens < 1) {
|
||||
throw new AIProviderError(
|
||||
'Max tokens must be a positive integer',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates stop sequences parameter.
|
||||
*
|
||||
* @private
|
||||
* @param stopSequences - Stop sequences to validate
|
||||
* @throws {AIProviderError} If stop sequences are invalid
|
||||
*/
|
||||
private validateStopSequences(stopSequences?: string[]): void {
|
||||
if (stopSequences !== undefined) {
|
||||
if (!Array.isArray(stopSequences)) {
|
||||
@@ -587,65 +641,52 @@ export abstract class BaseAIProvider {
|
||||
* @returns Normalized AIProviderError with appropriate type and context
|
||||
*/
|
||||
protected normalizeError(error: Error): AIProviderError {
|
||||
// If already normalized, return as-is
|
||||
if (error instanceof AIProviderError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Extract status code if available
|
||||
const status = (error as any).status || (error as any).statusCode;
|
||||
const message = error.message || 'Unknown error occurred';
|
||||
|
||||
// Map HTTP status codes to error types
|
||||
if (status) {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return new AIProviderError(
|
||||
`Bad request: ${message}`,
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
return new AIProviderError(
|
||||
'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 404:
|
||||
return new AIProviderError(
|
||||
'The specified model or endpoint was not found. Please check the model name and availability.',
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 429:
|
||||
return new AIProviderError(
|
||||
'Rate limit exceeded. Please reduce your request frequency and try again later.',
|
||||
AIErrorType.RATE_LIMIT,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return new AIProviderError(
|
||||
'Service temporarily unavailable. Please try again in a few moments.',
|
||||
AIErrorType.NETWORK,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
const providerError = this.mapProviderError(error);
|
||||
if (providerError) {
|
||||
return providerError;
|
||||
}
|
||||
|
||||
const status = (error as any).status || (error as any).statusCode;
|
||||
const message = error.message || 'Unknown error occurred';
|
||||
const overrides = this.providerErrorMessages();
|
||||
|
||||
const statusTypeMap: Record<number, AIErrorType> = {
|
||||
400: AIErrorType.INVALID_REQUEST,
|
||||
401: AIErrorType.AUTHENTICATION,
|
||||
403: AIErrorType.AUTHENTICATION,
|
||||
404: AIErrorType.MODEL_NOT_FOUND,
|
||||
429: AIErrorType.RATE_LIMIT,
|
||||
500: AIErrorType.NETWORK,
|
||||
502: AIErrorType.NETWORK,
|
||||
503: AIErrorType.NETWORK,
|
||||
504: AIErrorType.NETWORK
|
||||
};
|
||||
|
||||
const defaultMessages: Record<number, string> = {
|
||||
400: `Bad request: ${message}`,
|
||||
401: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
|
||||
403: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
|
||||
404: 'The specified model or endpoint was not found. Please check the model name and availability.',
|
||||
429: 'Rate limit exceeded. Please reduce your request frequency and try again later.',
|
||||
500: 'Service temporarily unavailable. Please try again in a few moments.',
|
||||
502: 'Service temporarily unavailable. Please try again in a few moments.',
|
||||
503: 'Service temporarily unavailable. Please try again in a few moments.',
|
||||
504: 'Service temporarily unavailable. Please try again in a few moments.'
|
||||
};
|
||||
|
||||
if (status && statusTypeMap[status]) {
|
||||
return new AIProviderError(
|
||||
overrides[status] ?? defaultMessages[status]!,
|
||||
statusTypeMap[status]!,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Map common error patterns
|
||||
if (message.includes('timeout') || message.includes('ETIMEDOUT')) {
|
||||
return new AIProviderError(
|
||||
'Request timed out. The operation took longer than expected.',
|
||||
@@ -664,9 +705,8 @@ export abstract class BaseAIProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// Default to unknown error type
|
||||
return new AIProviderError(
|
||||
`Provider error: ${message}`,
|
||||
`${this.providerName} error: ${message}`,
|
||||
AIErrorType.UNKNOWN,
|
||||
status,
|
||||
error
|
||||
|
||||
403
src/providers/claude-code.ts
Normal file
403
src/providers/claude-code.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Claude Code Provider
|
||||
*
|
||||
* Wraps `@anthropic-ai/claude-agent-sdk` so consumers can use a local
|
||||
* Claude Code installation — including Claude Pro/Max subscription
|
||||
* accounts authenticated via `claude login` — through the same
|
||||
* BaseAIProvider interface as the other providers.
|
||||
*
|
||||
* Tradeoffs vs the direct Anthropic API provider:
|
||||
* - Authenticates via the local CLI, so subscription users (no API key)
|
||||
* can use it.
|
||||
* - Requires `claude` to be installed and logged in on the host.
|
||||
* - Higher latency (shells out to a CLI process per request).
|
||||
* - Designed for agent workflows, but used here in single-turn mode
|
||||
* (maxTurns: 1, no tools) for plain text completion.
|
||||
*
|
||||
* @see https://docs.anthropic.com/claude/docs/claude-code-sdk
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type {
|
||||
AIMessage,
|
||||
AIProviderConfig,
|
||||
CompletionChunk,
|
||||
CompletionParams,
|
||||
CompletionResponse,
|
||||
ProviderInfo
|
||||
} from '../types/index.js';
|
||||
import { BaseAIProvider } from './base.js';
|
||||
import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||
import { DEFAULT_MODELS } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Configuration for the Claude Code provider.
|
||||
*
|
||||
* Authentication options, in priority order:
|
||||
*
|
||||
* 1. `oauthToken` — long-lived subscription token generated by
|
||||
* `claude setup-token` (recommended for Pro/Max subscribers using
|
||||
* the SDK programmatically). Billed against the subscription.
|
||||
* 2. `apiKey` — standard Anthropic console API key. Billed per-token.
|
||||
* 3. Environment fallback — the SDK reads `CLAUDE_CODE_OAUTH_TOKEN` or
|
||||
* `ANTHROPIC_API_KEY` from the environment if neither field is set.
|
||||
*
|
||||
* Note: `claude login` alone is not sufficient for SDK use because
|
||||
* Anthropic gates non-interactive (`claude -p`) invocations behind a
|
||||
* separate token. Run `claude setup-token` once to mint one.
|
||||
*/
|
||||
export interface ClaudeCodeConfig extends Omit<AIProviderConfig, 'apiKey'> {
|
||||
/**
|
||||
* Long-lived OAuth token from `claude setup-token`. This is the
|
||||
* supported path for Claude Pro/Max subscribers to use the SDK
|
||||
* programmatically — billed against the subscription instead of
|
||||
* per-token.
|
||||
*/
|
||||
oauthToken?: string;
|
||||
|
||||
/** Standard Anthropic console API key. Mutually compatible with oauthToken (oauthToken takes precedence). */
|
||||
apiKey?: string;
|
||||
|
||||
/**
|
||||
* Default model. Accepts SDK aliases (`'sonnet'`, `'opus'`, `'haiku'`,
|
||||
* `'inherit'`) or any full Claude model ID.
|
||||
* @default 'sonnet'
|
||||
*/
|
||||
defaultModel?: string;
|
||||
|
||||
/**
|
||||
* Working directory for the Claude Code session.
|
||||
* @default process.cwd()
|
||||
*/
|
||||
cwd?: string;
|
||||
|
||||
/**
|
||||
* Tool names the model is allowed to call. Defaults to none, which
|
||||
* makes the provider behave as a pure text completion endpoint.
|
||||
*/
|
||||
allowedTools?: string[];
|
||||
|
||||
/**
|
||||
* Maximum agent turns. Defaults to 1 for plain completion; raise this
|
||||
* if you also pass `allowedTools` and want tool-use loops.
|
||||
* @default 1
|
||||
*/
|
||||
maxTurns?: number;
|
||||
}
|
||||
|
||||
export class ClaudeCodeProvider extends BaseAIProvider {
|
||||
private readonly defaultModel: string;
|
||||
private readonly cwd: string | undefined;
|
||||
private readonly allowedTools: string[];
|
||||
private readonly maxTurns: number;
|
||||
private readonly oauthToken: string | undefined;
|
||||
|
||||
constructor(config: ClaudeCodeConfig) {
|
||||
super(config as AIProviderConfig);
|
||||
|
||||
this.defaultModel = config.defaultModel || DEFAULT_MODELS['claude-code'];
|
||||
this.cwd = config.cwd;
|
||||
this.allowedTools = config.allowedTools ?? [];
|
||||
this.maxTurns = config.maxTurns ?? 1;
|
||||
this.oauthToken = config.oauthToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* apiKey is optional for this provider — the SDK reads
|
||||
* ANTHROPIC_API_KEY from the environment or falls back to the local
|
||||
* Claude Code credentials. We override the base validator to skip the
|
||||
* non-empty apiKey requirement.
|
||||
*/
|
||||
protected override validateAndNormalizeConfig(config: AIProviderConfig): AIProviderConfig {
|
||||
if (!config) {
|
||||
throw new AIProviderError(
|
||||
'Configuration object is required',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
if (config.apiKey !== undefined && typeof config.apiKey !== 'string') {
|
||||
throw new AIProviderError(
|
||||
'API key, when provided, must be a string',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
const oauthToken = (config as ClaudeCodeConfig).oauthToken;
|
||||
if (oauthToken !== undefined && typeof oauthToken !== 'string') {
|
||||
throw new AIProviderError(
|
||||
'oauthToken, when provided, must be a string',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
apiKey: config.apiKey ?? '',
|
||||
timeout: config.timeout,
|
||||
maxRetries: config.maxRetries
|
||||
};
|
||||
}
|
||||
|
||||
protected async doInitialize(): Promise<void> {
|
||||
// The SDK lazy-spawns the CLI per query; no connection probe is
|
||||
// necessary (and a probe would cost a real billed turn). We defer
|
||||
// auth/model errors to the first complete()/stream() call.
|
||||
if (this.oauthToken) {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = this.oauthToken;
|
||||
}
|
||||
if (this.config.apiKey) {
|
||||
process.env.ANTHROPIC_API_KEY = this.config.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
protected async doComplete(params: CompletionParams): Promise<CompletionResponse<string>> {
|
||||
const { systemPrompt, prompt } = this.buildPrompt(params.messages);
|
||||
const model = params.model || this.defaultModel;
|
||||
|
||||
let collectedText = '';
|
||||
let sessionId = '';
|
||||
let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
||||
|
||||
for await (const message of query({
|
||||
prompt,
|
||||
options: this.buildOptions(model, systemPrompt)
|
||||
})) {
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
sessionId = message.session_id;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'assistant') {
|
||||
if (message.error) {
|
||||
throw this.errorForSdkAssistantError(message.error);
|
||||
}
|
||||
collectedText += extractText(message.message.content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
usage = {
|
||||
promptTokens: message.usage?.input_tokens ?? 0,
|
||||
completionTokens: message.usage?.output_tokens ?? 0,
|
||||
totalTokens: (message.usage?.input_tokens ?? 0) + (message.usage?.output_tokens ?? 0)
|
||||
};
|
||||
|
||||
if (message.subtype !== 'success') {
|
||||
throw new AIProviderError(
|
||||
`Claude Code session ended without a successful result: ${message.subtype}`,
|
||||
AIErrorType.UNKNOWN
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: collectedText || message.result,
|
||||
model,
|
||||
usage,
|
||||
id: sessionId || message.uuid,
|
||||
metadata: {
|
||||
costUsd: message.total_cost_usd,
|
||||
durationMs: message.duration_ms,
|
||||
numTurns: message.num_turns,
|
||||
stopReason: message.stop_reason
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new AIProviderError(
|
||||
'Claude Code stream ended without a result message',
|
||||
AIErrorType.UNKNOWN
|
||||
);
|
||||
}
|
||||
|
||||
protected async *doStream<T = any>(
|
||||
params: CompletionParams<T>
|
||||
): AsyncIterable<CompletionChunk> {
|
||||
const { systemPrompt, prompt } = this.buildPrompt(params.messages);
|
||||
const model = params.model || this.defaultModel;
|
||||
|
||||
let sessionId = '';
|
||||
let yieldedText = '';
|
||||
|
||||
for await (const message of query({
|
||||
prompt,
|
||||
options: this.buildOptions(model, systemPrompt)
|
||||
})) {
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
sessionId = message.session_id;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'assistant') {
|
||||
if (message.error) {
|
||||
throw this.errorForSdkAssistantError(message.error);
|
||||
}
|
||||
const fullText = extractText(message.message.content);
|
||||
const delta = fullText.slice(yieldedText.length);
|
||||
if (delta) {
|
||||
yield { content: delta, isComplete: false, id: sessionId };
|
||||
yieldedText = fullText;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
const usage = {
|
||||
promptTokens: message.usage?.input_tokens ?? 0,
|
||||
completionTokens: message.usage?.output_tokens ?? 0,
|
||||
totalTokens: (message.usage?.input_tokens ?? 0) + (message.usage?.output_tokens ?? 0)
|
||||
};
|
||||
|
||||
if (message.subtype !== 'success') {
|
||||
throw new AIProviderError(
|
||||
`Claude Code session ended without a successful result: ${message.subtype}`,
|
||||
AIErrorType.UNKNOWN
|
||||
);
|
||||
}
|
||||
|
||||
yield { content: '', isComplete: true, id: sessionId, usage };
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getInfo(): ProviderInfo {
|
||||
return {
|
||||
name: 'Claude Code',
|
||||
version: '1.0.0',
|
||||
models: ['sonnet', 'opus', 'haiku', 'inherit'],
|
||||
maxContextLength: 200000,
|
||||
supportsStreaming: true,
|
||||
capabilities: {
|
||||
vision: true,
|
||||
functionCalling: true,
|
||||
systemMessages: true,
|
||||
reasoning: true,
|
||||
codeGeneration: true,
|
||||
subscriptionAuth: true,
|
||||
requiresLocalCli: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected override mapProviderError(error: any): AIProviderError | null {
|
||||
if (!error) return null;
|
||||
const message: string = error.message || '';
|
||||
|
||||
if (message.includes('ENOENT') && message.toLowerCase().includes('claude')) {
|
||||
return new AIProviderError(
|
||||
'Claude Code CLI not found. Install it from https://docs.anthropic.com/claude/docs/claude-code and run `claude login` for subscription accounts.',
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
undefined,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('not logged in') || message.includes('authentication')) {
|
||||
return new AIProviderError(
|
||||
'Claude Code is not authenticated. Run `claude login` or set ANTHROPIC_API_KEY in the environment.',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
undefined,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildPrompt(messages: AIMessage[]): { systemPrompt: string | undefined; prompt: string } {
|
||||
let systemPrompt: string | undefined;
|
||||
const turns: AIMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'system') {
|
||||
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${msg.content}` : msg.content;
|
||||
} else {
|
||||
turns.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (turns.length === 0) {
|
||||
throw new AIProviderError(
|
||||
'At least one user or assistant message is required',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
// Single user turn: pass the content directly. Multi-turn history is
|
||||
// flattened with role labels — the Agent SDK doesn't accept a
|
||||
// messages array in stateless mode, so this is the simplest faithful
|
||||
// representation of the conversation.
|
||||
if (turns.length === 1 && turns[0]!.role === 'user') {
|
||||
return { systemPrompt, prompt: turns[0]!.content };
|
||||
}
|
||||
|
||||
const flattened = turns
|
||||
.map(t => `${t.role === 'assistant' ? 'Assistant' : 'Human'}: ${t.content}`)
|
||||
.join('\n\n');
|
||||
return { systemPrompt, prompt: `${flattened}\n\nAssistant:` };
|
||||
}
|
||||
|
||||
private buildOptions(model: string, systemPrompt: string | undefined) {
|
||||
const options: Record<string, unknown> = {
|
||||
model,
|
||||
maxTurns: this.maxTurns,
|
||||
allowedTools: this.allowedTools
|
||||
};
|
||||
if (systemPrompt) options.systemPrompt = systemPrompt;
|
||||
if (this.cwd) options.cwd = this.cwd;
|
||||
return options as any;
|
||||
}
|
||||
|
||||
private errorForSdkAssistantError(code: string): AIProviderError {
|
||||
const map: Record<string, { type: AIErrorType; message: string }> = {
|
||||
authentication_failed: {
|
||||
type: AIErrorType.AUTHENTICATION,
|
||||
message: 'Claude Code authentication failed. For subscriptions run `claude setup-token` and pass the result as `oauthToken`. Otherwise set ANTHROPIC_API_KEY or pass `apiKey`.'
|
||||
},
|
||||
oauth_org_not_allowed: {
|
||||
type: AIErrorType.AUTHENTICATION,
|
||||
message: 'Your organization is not allowed by the Claude Code OAuth policy.'
|
||||
},
|
||||
billing_error: {
|
||||
type: AIErrorType.AUTHENTICATION,
|
||||
message: 'Billing error. For Claude Pro/Max subscribers using the SDK: run `claude setup-token` and pass the resulting token as `oauthToken` (interactive `claude login` alone is not sufficient for non-interactive SDK calls).'
|
||||
},
|
||||
rate_limit: {
|
||||
type: AIErrorType.RATE_LIMIT,
|
||||
message: 'Rate limit exceeded.'
|
||||
},
|
||||
invalid_request: {
|
||||
type: AIErrorType.INVALID_REQUEST,
|
||||
message: 'Invalid request to Claude Code.'
|
||||
},
|
||||
model_not_found: {
|
||||
type: AIErrorType.MODEL_NOT_FOUND,
|
||||
message: 'Requested model is not available to this Claude Code installation.'
|
||||
},
|
||||
server_error: {
|
||||
type: AIErrorType.NETWORK,
|
||||
message: 'Claude Code server error. Try again shortly.'
|
||||
},
|
||||
max_output_tokens: {
|
||||
type: AIErrorType.INVALID_REQUEST,
|
||||
message: 'Response exceeded the configured maxOutputTokens.'
|
||||
}
|
||||
};
|
||||
|
||||
const entry = map[code] ?? { type: AIErrorType.UNKNOWN, message: `Claude Code error: ${code}` };
|
||||
return new AIProviderError(entry.message, entry.type);
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(content: unknown): string {
|
||||
if (!Array.isArray(content)) return '';
|
||||
let out = '';
|
||||
for (const block of content) {
|
||||
if (block && typeof block === 'object' && (block as any).type === 'text') {
|
||||
out += (block as any).text ?? '';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -34,6 +34,13 @@ import type {
|
||||
} from '../types/index.js';
|
||||
import { BaseAIProvider } from './base.js';
|
||||
import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||
import {
|
||||
DEFAULT_ANTHROPIC_VERSION,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_TEMPERATURE,
|
||||
VALIDATION_PROMPT
|
||||
} from '../constants.js';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES AND INTERFACES
|
||||
@@ -153,9 +160,8 @@ export class ClaudeProvider extends BaseAIProvider {
|
||||
constructor(config: ClaudeConfig) {
|
||||
super(config);
|
||||
|
||||
// Set Claude-specific defaults
|
||||
this.defaultModel = config.defaultModel || 'claude-3-5-sonnet-20241022';
|
||||
this.version = config.version || '2023-06-01';
|
||||
this.defaultModel = config.defaultModel || DEFAULT_MODELS.claude;
|
||||
this.version = config.version || DEFAULT_ANTHROPIC_VERSION;
|
||||
|
||||
// Validate model name format
|
||||
this.validateModelName(this.defaultModel);
|
||||
@@ -178,7 +184,6 @@ export class ClaudeProvider extends BaseAIProvider {
|
||||
*/
|
||||
protected async doInitialize(): Promise<void> {
|
||||
try {
|
||||
// Create Anthropic client with optimized configuration
|
||||
this.client = new Anthropic({
|
||||
apiKey: this.config.apiKey,
|
||||
baseURL: this.config.baseUrl,
|
||||
@@ -186,17 +191,14 @@ export class ClaudeProvider extends BaseAIProvider {
|
||||
maxRetries: this.config.maxRetries,
|
||||
defaultHeaders: {
|
||||
'anthropic-version': this.version,
|
||||
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', // Enable extended context
|
||||
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15',
|
||||
}
|
||||
});
|
||||
|
||||
// Validate connection and permissions
|
||||
await this.validateConnection();
|
||||
|
||||
} catch (error) {
|
||||
// Clean up on failure
|
||||
this.client = null;
|
||||
|
||||
|
||||
throw new AIProviderError(
|
||||
`Failed to initialize Claude provider: ${(error as Error).message}`,
|
||||
AIErrorType.AUTHENTICATION,
|
||||
@@ -225,22 +227,10 @@ export class ClaudeProvider extends BaseAIProvider {
|
||||
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Process messages for Claude's format requirements
|
||||
const { system, messages } = this.processMessages(params.messages);
|
||||
|
||||
// Build optimized request parameters
|
||||
const requestParams = this.buildRequestParams(params, system, messages, false);
|
||||
|
||||
// Make API request
|
||||
const response = await this.client.messages.create(requestParams);
|
||||
|
||||
// Format and return response
|
||||
return this.formatCompletionResponse(response);
|
||||
|
||||
} catch (error) {
|
||||
throw this.handleAnthropicError(error as Error);
|
||||
}
|
||||
const { system, messages } = this.processMessages(params.messages);
|
||||
const requestParams = this.buildRequestParams(params, system, messages, false);
|
||||
const response = await this.client.messages.create(requestParams);
|
||||
return this.formatCompletionResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,22 +252,10 @@ export class ClaudeProvider extends BaseAIProvider {
|
||||
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Process messages for Claude's format requirements
|
||||
const { system, messages } = this.processMessages(params.messages);
|
||||
|
||||
// Build streaming request parameters
|
||||
const requestParams = this.buildRequestParams(params, system, messages, true);
|
||||
|
||||
// Create streaming request
|
||||
const stream = await this.client.messages.create(requestParams);
|
||||
|
||||
// Process stream chunks
|
||||
yield* this.processStreamChunks(stream);
|
||||
|
||||
} catch (error) {
|
||||
throw this.handleAnthropicError(error as Error);
|
||||
}
|
||||
const { system, messages } = this.processMessages(params.messages);
|
||||
const requestParams = this.buildRequestParams(params, system, messages, true);
|
||||
const stream = await this.client.messages.create(requestParams);
|
||||
yield* this.processStreamChunks(stream);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@@ -317,76 +295,62 @@ export class ClaudeProvider extends BaseAIProvider {
|
||||
// PRIVATE UTILITY METHODS
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Validates the connection by making a minimal test request.
|
||||
*
|
||||
* @private
|
||||
* @throws {AIProviderError} If connection validation fails
|
||||
*/
|
||||
private async validateConnection(): Promise<void> {
|
||||
protected override getModelNamePatterns(): RegExp[] {
|
||||
return [
|
||||
/^claude-3(?:-5)?-(?:opus|sonnet|haiku)-\d{8}$/,
|
||||
/^claude-instant-[0-9.]+$/,
|
||||
/^claude-[0-9.]+$/
|
||||
];
|
||||
}
|
||||
|
||||
protected override async sendValidationProbe(): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Make minimal request to test connection and permissions
|
||||
await this.client.messages.create({
|
||||
model: this.defaultModel,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'Hi' }]
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
// Handle specific validation errors
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
throw new AIProviderError(
|
||||
'Invalid Anthropic API key. Please verify your API key from https://console.anthropic.com/',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
error.status
|
||||
);
|
||||
}
|
||||
|
||||
if (error.status === 404 && error.message?.includes('model')) {
|
||||
throw new AIProviderError(
|
||||
`Model '${this.defaultModel}' is not available. Please check the model name or your API access.`,
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
error.status
|
||||
);
|
||||
}
|
||||
|
||||
// For other errors during validation, log but don't fail initialization
|
||||
// They might be temporary network issues
|
||||
console.warn('Claude connection validation warning:', error.message);
|
||||
}
|
||||
await this.client.messages.create({
|
||||
model: this.defaultModel,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: VALIDATION_PROMPT }]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates model name format for Claude models.
|
||||
*
|
||||
* @private
|
||||
* @param modelName - Model name to validate
|
||||
* @throws {AIProviderError} If model name format is invalid
|
||||
*/
|
||||
private validateModelName(modelName: string): void {
|
||||
if (!modelName || typeof modelName !== 'string') {
|
||||
throw new AIProviderError(
|
||||
'Model name must be a non-empty string',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
protected override providerErrorMessages(): Partial<Record<number, string>> {
|
||||
return {
|
||||
401: 'Authentication failed. Please check your Anthropic API key from https://console.anthropic.com/',
|
||||
403: 'Access forbidden. Your API key may not have permission for this model or feature.',
|
||||
404: 'Model not found. The specified Claude model may not be available to your account.',
|
||||
429: 'Rate limit exceeded. Claude APIs have usage limits. Please wait before retrying.',
|
||||
500: 'Anthropic service temporarily unavailable. Please try again in a few moments.',
|
||||
502: 'Anthropic service temporarily unavailable. Please try again in a few moments.',
|
||||
503: 'Anthropic service temporarily unavailable. Please try again in a few moments.'
|
||||
};
|
||||
}
|
||||
|
||||
protected override mapProviderError(error: any): AIProviderError | null {
|
||||
if (!error) return null;
|
||||
const message: string = error.message || '';
|
||||
const status = error.status || error.statusCode;
|
||||
|
||||
if (status === 400) {
|
||||
if (message.includes('max_tokens')) {
|
||||
return new AIProviderError(
|
||||
'Max tokens value is invalid. Must be between 1 and model limit.',
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
if (message.includes('model')) {
|
||||
return new AIProviderError(
|
||||
'Invalid model specified. Please check model availability.',
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Claude model names follow specific patterns
|
||||
const validPatterns = [
|
||||
/^claude-3(?:-5)?-(?:opus|sonnet|haiku)-\d{8}$/, // e.g., claude-3-5-sonnet-20241022
|
||||
/^claude-instant-[0-9.]+$/, // e.g., claude-instant-1.2
|
||||
/^claude-[0-9.]+$/ // e.g., claude-2.1
|
||||
];
|
||||
|
||||
const isValid = validPatterns.some(pattern => pattern.test(modelName));
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`Model name '${modelName}' doesn't match expected Claude naming patterns. This may cause API errors.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,8 +413,8 @@ export class ClaudeProvider extends BaseAIProvider {
|
||||
) {
|
||||
return {
|
||||
model: params.model || this.defaultModel,
|
||||
max_tokens: params.maxTokens || 1000,
|
||||
temperature: params.temperature ?? 0.7,
|
||||
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
|
||||
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
|
||||
top_p: params.topP,
|
||||
stop_sequences: params.stopSequences,
|
||||
system: system || undefined,
|
||||
@@ -564,118 +528,4 @@ export class ClaudeProvider extends BaseAIProvider {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles and transforms Anthropic-specific errors.
|
||||
*
|
||||
* This method maps Anthropic's error responses to our standardized
|
||||
* error format, providing helpful context and suggestions.
|
||||
*
|
||||
* @private
|
||||
* @param error - Original error from Anthropic API
|
||||
* @returns Normalized AIProviderError
|
||||
*/
|
||||
private handleAnthropicError(error: any): AIProviderError {
|
||||
if (error instanceof AIProviderError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const message = error.message || 'Unknown Anthropic API error';
|
||||
const status = error.status || error.statusCode;
|
||||
|
||||
// Map Anthropic-specific error codes
|
||||
switch (status) {
|
||||
case 400:
|
||||
if (message.includes('max_tokens')) {
|
||||
return new AIProviderError(
|
||||
'Max tokens value is invalid. Must be between 1 and model limit.',
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
if (message.includes('model')) {
|
||||
return new AIProviderError(
|
||||
'Invalid model specified. Please check model availability.',
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
return new AIProviderError(
|
||||
`Invalid request: ${message}`,
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 401:
|
||||
return new AIProviderError(
|
||||
'Authentication failed. Please check your Anthropic API key from https://console.anthropic.com/',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 403:
|
||||
return new AIProviderError(
|
||||
'Access forbidden. Your API key may not have permission for this model or feature.',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 404:
|
||||
return new AIProviderError(
|
||||
'Model not found. The specified Claude model may not be available to your account.',
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 429:
|
||||
return new AIProviderError(
|
||||
'Rate limit exceeded. Claude APIs have usage limits. Please wait before retrying.',
|
||||
AIErrorType.RATE_LIMIT,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
return new AIProviderError(
|
||||
'Anthropic service temporarily unavailable. Please try again in a few moments.',
|
||||
AIErrorType.NETWORK,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
default:
|
||||
// Handle timeout and network errors
|
||||
if (message.includes('timeout') || error.code === 'ETIMEDOUT') {
|
||||
return new AIProviderError(
|
||||
'Request timed out. Claude may be experiencing high load.',
|
||||
AIErrorType.TIMEOUT,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('network') || error.code === 'ECONNREFUSED') {
|
||||
return new AIProviderError(
|
||||
'Network error connecting to Anthropic servers.',
|
||||
AIErrorType.NETWORK,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return new AIProviderError(
|
||||
`Claude API error: ${message}`,
|
||||
AIErrorType.UNKNOWN,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ import type {
|
||||
} from '../types/index.js';
|
||||
import { BaseAIProvider } from './base.js';
|
||||
import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||
import {
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_TEMPERATURE,
|
||||
VALIDATION_PROMPT
|
||||
} from '../constants.js';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES AND INTERFACES
|
||||
@@ -95,7 +101,7 @@ export class GeminiProvider extends BaseAIProvider {
|
||||
constructor(config: GeminiConfig) {
|
||||
super(config);
|
||||
|
||||
this.defaultModel = config.defaultModel || 'gemini-2.5-flash';
|
||||
this.defaultModel = config.defaultModel || DEFAULT_MODELS.gemini;
|
||||
this.safetySettings = config.safetySettings;
|
||||
this.defaultGenerationConfig = config.generationConfig;
|
||||
|
||||
@@ -128,20 +134,16 @@ export class GeminiProvider extends BaseAIProvider {
|
||||
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
||||
const model = params.model || this.defaultModel;
|
||||
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
||||
const model = params.model || this.defaultModel;
|
||||
|
||||
const response = await this.client.models.generateContent({
|
||||
model,
|
||||
contents,
|
||||
config: this.buildConfig(params, systemInstruction)
|
||||
});
|
||||
const response = await this.client.models.generateContent({
|
||||
model,
|
||||
contents,
|
||||
config: this.buildConfig(params, systemInstruction)
|
||||
});
|
||||
|
||||
return this.formatCompletionResponse(response, model);
|
||||
} catch (error) {
|
||||
throw this.handleGeminiError(error as Error);
|
||||
}
|
||||
return this.formatCompletionResponse(response, model);
|
||||
}
|
||||
|
||||
protected async *doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> {
|
||||
@@ -149,20 +151,16 @@ export class GeminiProvider extends BaseAIProvider {
|
||||
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
||||
const model = params.model || this.defaultModel;
|
||||
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
||||
const model = params.model || this.defaultModel;
|
||||
|
||||
const stream = await this.client.models.generateContentStream({
|
||||
model,
|
||||
contents,
|
||||
config: this.buildConfig(params, systemInstruction)
|
||||
});
|
||||
const stream = await this.client.models.generateContentStream({
|
||||
model,
|
||||
contents,
|
||||
config: this.buildConfig(params, systemInstruction)
|
||||
});
|
||||
|
||||
yield* this.processStreamChunks(stream);
|
||||
} catch (error) {
|
||||
throw this.handleGeminiError(error as Error);
|
||||
}
|
||||
yield* this.processStreamChunks(stream);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@@ -199,55 +197,8 @@ export class GeminiProvider extends BaseAIProvider {
|
||||
// PRIVATE UTILITY METHODS
|
||||
// ========================================================================
|
||||
|
||||
private async validateConnection(): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.models.generateContent({
|
||||
model: this.defaultModel,
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hi' }] }],
|
||||
config: { maxOutputTokens: 1 }
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('API key')) {
|
||||
throw new AIProviderError(
|
||||
'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
error.status
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message?.includes('quota') || error.message?.includes('billing')) {
|
||||
throw new AIProviderError(
|
||||
'API quota exceeded or billing issue. Please check your Google Cloud billing and API quotas.',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
error.status
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message?.includes('model')) {
|
||||
throw new AIProviderError(
|
||||
`Model '${this.defaultModel}' is not available. Please check the model name or your API access level.`,
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
error.status
|
||||
);
|
||||
}
|
||||
|
||||
console.warn('Gemini connection validation warning:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private validateModelName(modelName: string): void {
|
||||
if (!modelName || typeof modelName !== 'string') {
|
||||
throw new AIProviderError(
|
||||
'Model name must be a non-empty string',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
const validPatterns = [
|
||||
protected override getModelNamePatterns(): RegExp[] {
|
||||
return [
|
||||
/^gemini-2\.5-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||
/^gemini-2\.0-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||
/^gemini-1\.5-pro(?:-latest|-vision)?$/,
|
||||
@@ -256,12 +207,72 @@ export class GeminiProvider extends BaseAIProvider {
|
||||
/^gemini-pro(?:-vision)?$/,
|
||||
/^models\/gemini-.+$/
|
||||
];
|
||||
}
|
||||
|
||||
const isValid = validPatterns.some(pattern => pattern.test(modelName));
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`Model name '${modelName}' doesn't match expected Gemini naming patterns. This may cause API errors.`);
|
||||
protected override async sendValidationProbe(): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
await this.client.models.generateContent({
|
||||
model: this.defaultModel,
|
||||
contents: [{ role: 'user', parts: [{ text: VALIDATION_PROMPT }] }],
|
||||
config: { maxOutputTokens: 1 }
|
||||
});
|
||||
}
|
||||
|
||||
protected override providerErrorMessages(): Partial<Record<number, string>> {
|
||||
return {
|
||||
401: 'Authentication failed with Gemini API. Please check your API key and permissions.',
|
||||
403: 'Authentication failed with Gemini API. Please check your API key and permissions.',
|
||||
404: 'Gemini API endpoint not found. Please check the model name and API version.',
|
||||
429: 'Rate limit exceeded. Please reduce request frequency.',
|
||||
500: 'Gemini service temporarily unavailable. Please try again in a few moments.',
|
||||
502: 'Gemini service temporarily unavailable. Please try again in a few moments.',
|
||||
503: 'Gemini service temporarily unavailable. Please try again in a few moments.'
|
||||
};
|
||||
}
|
||||
|
||||
protected override mapProviderError(error: any): AIProviderError | null {
|
||||
if (!error) return null;
|
||||
const message: string = error.message || '';
|
||||
const status = error.status || error.statusCode;
|
||||
|
||||
if (message.includes('API key')) {
|
||||
return new AIProviderError(
|
||||
'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey',
|
||||
AIErrorType.AUTHENTICATION, status, error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('quota') || message.includes('limit exceeded')) {
|
||||
return new AIProviderError(
|
||||
'API quota exceeded. Please check your Google Cloud quotas and billing.',
|
||||
AIErrorType.RATE_LIMIT, status, error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('model not found') || message.includes('invalid model')) {
|
||||
return new AIProviderError(
|
||||
'Model not found. The specified Gemini model may not exist or be available to your API key.',
|
||||
AIErrorType.MODEL_NOT_FOUND, status, error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('safety') || message.includes('blocked')) {
|
||||
return new AIProviderError(
|
||||
'Content blocked by safety filters. Please modify your input or adjust safety settings.',
|
||||
AIErrorType.INVALID_REQUEST, status, error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('length') || message.includes('token')) {
|
||||
return new AIProviderError(
|
||||
'Input too long or exceeds token limits. Please reduce input size or max tokens.',
|
||||
AIErrorType.INVALID_REQUEST, status, error
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private convertMessages(messages: AIMessage[]): ProcessedMessages {
|
||||
@@ -291,8 +302,8 @@ export class GeminiProvider extends BaseAIProvider {
|
||||
const defaults = this.defaultGenerationConfig;
|
||||
|
||||
const config: GenerateContentConfig = {
|
||||
temperature: params.temperature ?? defaults?.temperature ?? 0.7,
|
||||
maxOutputTokens: params.maxTokens ?? defaults?.maxOutputTokens ?? 1000
|
||||
temperature: params.temperature ?? defaults?.temperature ?? DEFAULT_TEMPERATURE,
|
||||
maxOutputTokens: params.maxTokens ?? defaults?.maxOutputTokens ?? DEFAULT_MAX_TOKENS
|
||||
};
|
||||
|
||||
const topP = params.topP ?? defaults?.topP;
|
||||
@@ -383,128 +394,4 @@ export class GeminiProvider extends BaseAIProvider {
|
||||
};
|
||||
}
|
||||
|
||||
private handleGeminiError(error: any): AIProviderError {
|
||||
if (error instanceof AIProviderError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const message = error.message || 'Unknown Gemini API error';
|
||||
const status = error.status || error.statusCode;
|
||||
|
||||
if (message.includes('API key')) {
|
||||
return new AIProviderError(
|
||||
'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('quota') || message.includes('limit exceeded')) {
|
||||
return new AIProviderError(
|
||||
'API quota exceeded. Please check your Google Cloud quotas and billing.',
|
||||
AIErrorType.RATE_LIMIT,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('model not found') || message.includes('invalid model')) {
|
||||
return new AIProviderError(
|
||||
'Model not found. The specified Gemini model may not exist or be available to your API key.',
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('safety') || message.includes('blocked')) {
|
||||
return new AIProviderError(
|
||||
'Content blocked by safety filters. Please modify your input or adjust safety settings.',
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('length') || message.includes('token')) {
|
||||
return new AIProviderError(
|
||||
'Input too long or exceeds token limits. Please reduce input size or max tokens.',
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('timeout')) {
|
||||
return new AIProviderError(
|
||||
'Request timed out. Gemini may be experiencing high load.',
|
||||
AIErrorType.TIMEOUT,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('network') || message.includes('connection')) {
|
||||
return new AIProviderError(
|
||||
'Network error connecting to Gemini servers.',
|
||||
AIErrorType.NETWORK,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
return new AIProviderError(
|
||||
`Invalid request to Gemini API: ${message}`,
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 401:
|
||||
case 403:
|
||||
return new AIProviderError(
|
||||
'Authentication failed with Gemini API. Please check your API key and permissions.',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 404:
|
||||
return new AIProviderError(
|
||||
'Gemini API endpoint not found. Please check the model name and API version.',
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 429:
|
||||
return new AIProviderError(
|
||||
'Rate limit exceeded. Please reduce request frequency.',
|
||||
AIErrorType.RATE_LIMIT,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
return new AIProviderError(
|
||||
'Gemini service temporarily unavailable. Please try again in a few moments.',
|
||||
AIErrorType.NETWORK,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
default:
|
||||
return new AIProviderError(
|
||||
`Gemini API error: ${message}`,
|
||||
AIErrorType.UNKNOWN,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ export { BaseAIProvider } from './base.js';
|
||||
export { ClaudeProvider, type ClaudeConfig } from './claude.js';
|
||||
export { OpenAIProvider, type OpenAIConfig } from './openai.js';
|
||||
export { GeminiProvider, type GeminiConfig } from './gemini.js';
|
||||
export { OpenWebUIProvider, type OpenWebUIConfig } from './openwebui.js';
|
||||
export { OpenWebUIProvider, type OpenWebUIConfig } from './openwebui.js';
|
||||
export { ClaudeCodeProvider, type ClaudeCodeConfig } from './claude-code.js';
|
||||
|
||||
@@ -35,6 +35,12 @@ import type {
|
||||
} from '../types/index.js';
|
||||
import { BaseAIProvider } from './base.js';
|
||||
import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||
import {
|
||||
DEFAULT_MAX_TOKENS,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_TEMPERATURE,
|
||||
VALIDATION_PROMPT
|
||||
} from '../constants.js';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES AND INTERFACES
|
||||
@@ -173,7 +179,7 @@ export class OpenAIProvider extends BaseAIProvider {
|
||||
super(config);
|
||||
|
||||
// Set OpenAI-specific defaults
|
||||
this.defaultModel = config.defaultModel || 'gpt-4o';
|
||||
this.defaultModel = config.defaultModel || DEFAULT_MODELS.openai;
|
||||
this.organization = config.organization;
|
||||
this.project = config.project;
|
||||
|
||||
@@ -249,19 +255,9 @@ export class OpenAIProvider extends BaseAIProvider {
|
||||
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Build optimized request parameters
|
||||
const requestParams = this.buildRequestParams(params, false);
|
||||
|
||||
// Make API request - explicitly type as ChatCompletion for non-streaming
|
||||
const response = await this.client.chat.completions.create(requestParams) as OpenAI.Chat.Completions.ChatCompletion;
|
||||
|
||||
// Format and return response
|
||||
return this.formatCompletionResponse(response);
|
||||
|
||||
} catch (error) {
|
||||
throw this.handleOpenAIError(error as Error);
|
||||
}
|
||||
const requestParams = this.buildRequestParams(params, false);
|
||||
const response = await this.client.chat.completions.create(requestParams) as OpenAI.Chat.Completions.ChatCompletion;
|
||||
return this.formatCompletionResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,19 +279,9 @@ export class OpenAIProvider extends BaseAIProvider {
|
||||
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
// Build streaming request parameters
|
||||
const requestParams = this.buildRequestParams(params, true);
|
||||
|
||||
// Create streaming request
|
||||
const stream = this.client.chat.completions.create(requestParams);
|
||||
|
||||
// Process stream chunks
|
||||
yield* this.processStreamChunks(stream);
|
||||
|
||||
} catch (error) {
|
||||
throw this.handleOpenAIError(error as Error);
|
||||
}
|
||||
const requestParams = this.buildRequestParams(params, true);
|
||||
const stream = this.client.chat.completions.create(requestParams);
|
||||
yield* this.processStreamChunks(stream);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
@@ -340,87 +326,82 @@ export class OpenAIProvider extends BaseAIProvider {
|
||||
// PRIVATE UTILITY METHODS
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Validates the connection by making a minimal test request.
|
||||
*
|
||||
* @private
|
||||
* @throws {AIProviderError} If connection validation fails
|
||||
*/
|
||||
private async validateConnection(): Promise<void> {
|
||||
protected override getModelNamePatterns(): RegExp[] {
|
||||
return [
|
||||
/^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||
/^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||
/^gpt-3\.5-turbo(?:-\d{4})?$/,
|
||||
/^gpt-4-\d{4}-preview$/,
|
||||
/^text-davinci-\d{3}$/,
|
||||
/^ft:.+$/
|
||||
];
|
||||
}
|
||||
|
||||
protected override async sendValidationProbe(): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error('Client not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Make minimal request to test connection and permissions
|
||||
await this.client.chat.completions.create({
|
||||
model: this.defaultModel,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
max_tokens: 1
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
// Handle specific validation errors
|
||||
if (error.status === 401) {
|
||||
throw new AIProviderError(
|
||||
'Invalid OpenAI API key. Please verify your API key from https://platform.openai.com/api-keys',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
error.status
|
||||
);
|
||||
}
|
||||
|
||||
if (error.status === 403) {
|
||||
throw new AIProviderError(
|
||||
'Access forbidden. Your API key may not have permission for this model or your account may have insufficient credits.',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
error.status
|
||||
);
|
||||
}
|
||||
|
||||
if (error.status === 404 && error.message?.includes('model')) {
|
||||
throw new AIProviderError(
|
||||
`Model '${this.defaultModel}' is not available. Please check the model name or your API access level.`,
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
error.status
|
||||
);
|
||||
}
|
||||
|
||||
// For other errors during validation, log but don't fail initialization
|
||||
// They might be temporary network issues
|
||||
console.warn('OpenAI connection validation warning:', error.message);
|
||||
}
|
||||
await this.client.chat.completions.create({
|
||||
model: this.defaultModel,
|
||||
messages: [{ role: 'user', content: VALIDATION_PROMPT }],
|
||||
max_tokens: 1
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates model name format for OpenAI models.
|
||||
*
|
||||
* @private
|
||||
* @param modelName - Model name to validate
|
||||
* @throws {AIProviderError} If model name format is invalid
|
||||
*/
|
||||
private validateModelName(modelName: string): void {
|
||||
if (!modelName || typeof modelName !== 'string') {
|
||||
throw new AIProviderError(
|
||||
'Model name must be a non-empty string',
|
||||
AIErrorType.INVALID_REQUEST
|
||||
protected override providerErrorMessages(): Partial<Record<number, string>> {
|
||||
return {
|
||||
401: 'Authentication failed. Please verify your OpenAI API key from https://platform.openai.com/api-keys',
|
||||
403: 'Access forbidden. Your API key may not have permission for this model or feature.',
|
||||
404: 'Model not found. The specified OpenAI model may not exist or be available to your account.',
|
||||
429: 'Too many requests. Please slow down and try again.',
|
||||
500: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
|
||||
502: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
|
||||
503: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
|
||||
504: 'OpenAI service temporarily unavailable. Please try again in a few moments.'
|
||||
};
|
||||
}
|
||||
|
||||
protected override mapProviderError(error: any): AIProviderError | null {
|
||||
if (!error) return null;
|
||||
const message: string = error.message || '';
|
||||
const status = error.status || error.statusCode;
|
||||
|
||||
if (status === 400) {
|
||||
if (message.includes('max_tokens')) {
|
||||
return new AIProviderError(
|
||||
'Max tokens value exceeds model limit. Please reduce max_tokens or use a model with higher limits.',
|
||||
AIErrorType.INVALID_REQUEST, status, error
|
||||
);
|
||||
}
|
||||
if (message.includes('model')) {
|
||||
return new AIProviderError(
|
||||
'Invalid model specified. Please check model name and availability.',
|
||||
AIErrorType.MODEL_NOT_FOUND, status, error
|
||||
);
|
||||
}
|
||||
if (message.includes('messages')) {
|
||||
return new AIProviderError(
|
||||
'Invalid message format. Please check message structure and content.',
|
||||
AIErrorType.INVALID_REQUEST, status, error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 403 && (message.includes('billing') || message.includes('quota'))) {
|
||||
return new AIProviderError(
|
||||
'Insufficient quota or billing issue. Please check your OpenAI account billing and usage limits.',
|
||||
AIErrorType.AUTHENTICATION, status, error
|
||||
);
|
||||
}
|
||||
|
||||
// OpenAI model names follow specific patterns
|
||||
const validPatterns = [
|
||||
/^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/, // e.g., gpt-4o, gpt-4o-mini, gpt-4o-2024-11-20
|
||||
/^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/, // e.g., gpt-4, gpt-4-turbo, gpt-4-turbo-2024-04-09
|
||||
/^gpt-3\.5-turbo(?:-\d{4})?$/, // e.g., gpt-3.5-turbo, gpt-3.5-turbo-0125
|
||||
/^gpt-4-\d{4}-preview$/, // e.g., gpt-4-0125-preview
|
||||
/^text-davinci-\d{3}$/, // Legacy models
|
||||
/^ft:.+$/ // Fine-tuned models
|
||||
];
|
||||
|
||||
const isValid = validPatterns.some(pattern => pattern.test(modelName));
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`Model name '${modelName}' doesn't match expected OpenAI naming patterns. This may cause API errors.`);
|
||||
if (status === 429 && message.includes('quota')) {
|
||||
return new AIProviderError(
|
||||
'Usage quota exceeded. Please check your OpenAI usage limits and billing.',
|
||||
AIErrorType.RATE_LIMIT, status, error
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,8 +416,8 @@ export class OpenAIProvider extends BaseAIProvider {
|
||||
return {
|
||||
model: params.model || this.defaultModel,
|
||||
messages: this.convertMessages(params.messages),
|
||||
max_tokens: params.maxTokens || 1000,
|
||||
temperature: params.temperature ?? 0.7,
|
||||
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
|
||||
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
|
||||
top_p: params.topP,
|
||||
stop: params.stopSequences,
|
||||
stream,
|
||||
@@ -563,151 +544,4 @@ export class OpenAIProvider extends BaseAIProvider {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles and transforms OpenAI-specific errors.
|
||||
*
|
||||
* This method maps OpenAI's error responses to our standardized
|
||||
* error format, providing helpful context and actionable suggestions.
|
||||
*
|
||||
* @private
|
||||
* @param error - Original error from OpenAI API
|
||||
* @returns Normalized AIProviderError
|
||||
*/
|
||||
private handleOpenAIError(error: any): AIProviderError {
|
||||
if (error instanceof AIProviderError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const message = error.message || 'Unknown OpenAI API error';
|
||||
const status = error.status || error.statusCode;
|
||||
|
||||
// Map OpenAI-specific error codes and types
|
||||
switch (status) {
|
||||
case 400:
|
||||
if (message.includes('max_tokens')) {
|
||||
return new AIProviderError(
|
||||
'Max tokens value exceeds model limit. Please reduce max_tokens or use a model with higher limits.',
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
if (message.includes('model')) {
|
||||
return new AIProviderError(
|
||||
'Invalid model specified. Please check model name and availability.',
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
if (message.includes('messages')) {
|
||||
return new AIProviderError(
|
||||
'Invalid message format. Please check message structure and content.',
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
return new AIProviderError(
|
||||
`Invalid request: ${message}`,
|
||||
AIErrorType.INVALID_REQUEST,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 401:
|
||||
return new AIProviderError(
|
||||
'Authentication failed. Please verify your OpenAI API key from https://platform.openai.com/api-keys',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 403:
|
||||
if (message.includes('billing') || message.includes('quota')) {
|
||||
return new AIProviderError(
|
||||
'Insufficient quota or billing issue. Please check your OpenAI account billing and usage limits.',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
return new AIProviderError(
|
||||
'Access forbidden. Your API key may not have permission for this model or feature.',
|
||||
AIErrorType.AUTHENTICATION,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 404:
|
||||
return new AIProviderError(
|
||||
'Model not found. The specified OpenAI model may not exist or be available to your account.',
|
||||
AIErrorType.MODEL_NOT_FOUND,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 429:
|
||||
if (message.includes('rate')) {
|
||||
return new AIProviderError(
|
||||
'Rate limit exceeded. Please reduce request frequency and implement exponential backoff.',
|
||||
AIErrorType.RATE_LIMIT,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
if (message.includes('quota')) {
|
||||
return new AIProviderError(
|
||||
'Usage quota exceeded. Please check your OpenAI usage limits and billing.',
|
||||
AIErrorType.RATE_LIMIT,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
return new AIProviderError(
|
||||
'Too many requests. Please slow down and try again.',
|
||||
AIErrorType.RATE_LIMIT,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return new AIProviderError(
|
||||
'OpenAI service temporarily unavailable. Please try again in a few moments.',
|
||||
AIErrorType.NETWORK,
|
||||
status,
|
||||
error
|
||||
);
|
||||
|
||||
default:
|
||||
// Handle timeout and network errors
|
||||
if (message.includes('timeout') || error.code === 'ETIMEDOUT') {
|
||||
return new AIProviderError(
|
||||
'Request timed out. OpenAI may be experiencing high load.',
|
||||
AIErrorType.TIMEOUT,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('network') || error.code === 'ECONNREFUSED') {
|
||||
return new AIProviderError(
|
||||
'Network error connecting to OpenAI servers.',
|
||||
AIErrorType.NETWORK,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return new AIProviderError(
|
||||
`OpenAI API error: ${message}`,
|
||||
AIErrorType.UNKNOWN,
|
||||
status,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
src/providers/openwebui-http.ts
Normal file
59
src/providers/openwebui-http.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||
import { DEFAULT_TIMEOUT_MS } from '../constants.js';
|
||||
|
||||
export interface OpenWebUIHttpOptions {
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
timeout?: number;
|
||||
dangerouslyAllowInsecureConnections?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client shared by OpenWebUI strategies.
|
||||
* Handles auth header, request body serialization, and timeout-to-AIProviderError translation.
|
||||
*/
|
||||
export class OpenWebUIHttpClient {
|
||||
readonly baseUrl: string;
|
||||
private readonly apiKey: string | undefined;
|
||||
private readonly timeout: number;
|
||||
private readonly dangerouslyAllowInsecureConnections: boolean;
|
||||
|
||||
constructor(options: OpenWebUIHttpOptions) {
|
||||
this.baseUrl = options.baseUrl;
|
||||
this.apiKey = options.apiKey;
|
||||
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
this.dangerouslyAllowInsecureConnections = options.dangerouslyAllowInsecureConnections ?? true;
|
||||
}
|
||||
|
||||
async request(path: string, method: string, body?: unknown): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'simple-ai-provider/2.0.0'
|
||||
};
|
||||
|
||||
if (this.apiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(this.timeout)
|
||||
};
|
||||
|
||||
try {
|
||||
return await fetch(`${this.baseUrl}${path}`, requestOptions);
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new AIProviderError(
|
||||
'Request timed out',
|
||||
AIErrorType.TIMEOUT,
|
||||
undefined,
|
||||
error
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
306
src/providers/openwebui-strategies.ts
Normal file
306
src/providers/openwebui-strategies.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type {
|
||||
AIMessage,
|
||||
CompletionChunk,
|
||||
CompletionParams,
|
||||
CompletionResponse
|
||||
} from '../types/index.js';
|
||||
import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||
import { DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '../constants.js';
|
||||
import type { OpenWebUIHttpClient } from './openwebui-http.js';
|
||||
import type {
|
||||
OllamaGenerateResponse,
|
||||
OpenWebUIChatResponse,
|
||||
OpenWebUIModelsResponse,
|
||||
OpenWebUIStreamChunk
|
||||
} from './openwebui-types.js';
|
||||
|
||||
/**
|
||||
* Strategy interface for OpenWebUI's two backend modes.
|
||||
* Selected at construction based on `useOllamaProxy`.
|
||||
*/
|
||||
export interface OpenWebUIStrategy {
|
||||
validateConnection(): Promise<void>;
|
||||
complete(params: CompletionParams, defaultModel: string): Promise<CompletionResponse<string>>;
|
||||
stream(params: CompletionParams, defaultModel: string): AsyncIterable<CompletionChunk>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Chat strategy — OpenAI-compatible /api/chat/completions endpoint
|
||||
// ============================================================================
|
||||
|
||||
export class OpenWebUIChatStrategy implements OpenWebUIStrategy {
|
||||
constructor(private readonly http: OpenWebUIHttpClient) {}
|
||||
|
||||
async validateConnection(): Promise<void> {
|
||||
const response = await this.http.request('/api/models', 'GET');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json() as OpenWebUIModelsResponse;
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
throw new Error('Invalid models response format');
|
||||
}
|
||||
}
|
||||
|
||||
async complete(params: CompletionParams, defaultModel: string): Promise<CompletionResponse<string>> {
|
||||
const response = await this.http.request('/api/chat/completions', 'POST', {
|
||||
model: params.model || defaultModel,
|
||||
messages: convertMessages(params.messages),
|
||||
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
|
||||
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
|
||||
top_p: params.topP,
|
||||
stop: params.stopSequences,
|
||||
stream: false
|
||||
});
|
||||
|
||||
const data = await response.json() as OpenWebUIChatResponse;
|
||||
return formatChatResponse(data);
|
||||
}
|
||||
|
||||
async *stream(params: CompletionParams, defaultModel: string): AsyncIterable<CompletionChunk> {
|
||||
const response = await this.http.request('/api/chat/completions', 'POST', {
|
||||
model: params.model || defaultModel,
|
||||
messages: convertMessages(params.messages),
|
||||
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
|
||||
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
|
||||
top_p: params.topP,
|
||||
stop: params.stopSequences,
|
||||
stream: true
|
||||
});
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body for streaming');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let messageId = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data: ')) continue;
|
||||
|
||||
const data = trimmed.slice(6);
|
||||
if (data === '[DONE]') return;
|
||||
|
||||
let chunk: OpenWebUIStreamChunk;
|
||||
try {
|
||||
chunk = JSON.parse(data) as OpenWebUIStreamChunk;
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse streaming chunk:', parseError);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk.id && !messageId) {
|
||||
messageId = chunk.id;
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
content: delta.content,
|
||||
isComplete: false,
|
||||
id: messageId || chunk.id
|
||||
};
|
||||
}
|
||||
|
||||
if (chunk.choices[0]?.finish_reason) {
|
||||
yield {
|
||||
content: '',
|
||||
isComplete: true,
|
||||
id: messageId || chunk.id,
|
||||
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ollama strategy — direct /ollama/api/generate endpoint
|
||||
// ============================================================================
|
||||
|
||||
export class OpenWebUIOllamaStrategy implements OpenWebUIStrategy {
|
||||
constructor(private readonly http: OpenWebUIHttpClient) {}
|
||||
|
||||
async validateConnection(): Promise<void> {
|
||||
const response = await this.http.request('/ollama/api/tags', 'GET');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async complete(params: CompletionParams, defaultModel: string): Promise<CompletionResponse<string>> {
|
||||
const response = await this.http.request('/ollama/api/generate', 'POST', {
|
||||
model: params.model || defaultModel,
|
||||
prompt: convertMessagesToPrompt(params.messages),
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
|
||||
top_p: params.topP,
|
||||
num_predict: params.maxTokens || DEFAULT_MAX_TOKENS,
|
||||
stop: params.stopSequences
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json() as OllamaGenerateResponse;
|
||||
return formatOllamaResponse(data);
|
||||
}
|
||||
|
||||
async *stream(params: CompletionParams, defaultModel: string): AsyncIterable<CompletionChunk> {
|
||||
const response = await this.http.request('/ollama/api/generate', 'POST', {
|
||||
model: params.model || defaultModel,
|
||||
prompt: convertMessagesToPrompt(params.messages),
|
||||
stream: true,
|
||||
options: {
|
||||
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
|
||||
top_p: params.topP,
|
||||
num_predict: params.maxTokens || DEFAULT_MAX_TOKENS,
|
||||
stop: params.stopSequences
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body for streaming');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const messageId = `ollama-${Date.now()}`;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
let chunk: OllamaGenerateResponse;
|
||||
try {
|
||||
chunk = JSON.parse(trimmed) as OllamaGenerateResponse;
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse Ollama streaming chunk:', parseError);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (chunk.response) {
|
||||
yield {
|
||||
content: chunk.response,
|
||||
isComplete: false,
|
||||
id: messageId
|
||||
};
|
||||
}
|
||||
|
||||
if (chunk.done) {
|
||||
yield {
|
||||
content: '',
|
||||
isComplete: true,
|
||||
id: messageId,
|
||||
usage: {
|
||||
promptTokens: chunk.prompt_eval_count || 0,
|
||||
completionTokens: chunk.eval_count || 0,
|
||||
totalTokens: (chunk.prompt_eval_count || 0) + (chunk.eval_count || 0)
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shared message helpers
|
||||
// ============================================================================
|
||||
|
||||
function convertMessages(messages: AIMessage[]): Array<{ role: string; content: string }> {
|
||||
return messages.map(message => ({
|
||||
role: message.role,
|
||||
content: message.content
|
||||
}));
|
||||
}
|
||||
|
||||
function convertMessagesToPrompt(messages: AIMessage[]): string {
|
||||
let prompt = '';
|
||||
for (const message of messages) {
|
||||
switch (message.role) {
|
||||
case 'system':
|
||||
prompt += `System: ${message.content}\n\n`;
|
||||
break;
|
||||
case 'user':
|
||||
prompt += `Human: ${message.content}\n\n`;
|
||||
break;
|
||||
case 'assistant':
|
||||
prompt += `Assistant: ${message.content}\n\n`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return prompt + 'Assistant: ';
|
||||
}
|
||||
|
||||
function formatChatResponse(response: OpenWebUIChatResponse): CompletionResponse<string> {
|
||||
const choice = response.choices[0];
|
||||
if (!choice || !choice.message.content) {
|
||||
throw new AIProviderError('No content found in OpenWebUI response', AIErrorType.UNKNOWN);
|
||||
}
|
||||
|
||||
return {
|
||||
content: choice.message.content,
|
||||
model: response.model,
|
||||
usage: {
|
||||
promptTokens: response.usage?.prompt_tokens || 0,
|
||||
completionTokens: response.usage?.completion_tokens || 0,
|
||||
totalTokens: response.usage?.total_tokens || 0
|
||||
},
|
||||
id: response.id,
|
||||
metadata: {
|
||||
finishReason: choice.finish_reason,
|
||||
created: response.created
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function formatOllamaResponse(response: OllamaGenerateResponse): CompletionResponse<string> {
|
||||
return {
|
||||
content: response.response,
|
||||
model: response.model,
|
||||
usage: {
|
||||
promptTokens: response.prompt_eval_count || 0,
|
||||
completionTokens: response.eval_count || 0,
|
||||
totalTokens: (response.prompt_eval_count || 0) + (response.eval_count || 0)
|
||||
},
|
||||
id: `ollama-${Date.now()}`,
|
||||
metadata: {
|
||||
created: new Date(response.created_at).getTime(),
|
||||
totalDuration: response.total_duration,
|
||||
loadDuration: response.load_duration,
|
||||
promptEvalDuration: response.prompt_eval_duration,
|
||||
evalDuration: response.eval_duration
|
||||
}
|
||||
};
|
||||
}
|
||||
55
src/providers/openwebui-types.ts
Normal file
55
src/providers/openwebui-types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Wire-format response types for OpenWebUI's two backends.
|
||||
*/
|
||||
|
||||
export interface OpenWebUIChatResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: { role: string; content: string };
|
||||
finish_reason: string | null;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenWebUIStreamChunk {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
delta: { role?: string; content?: string };
|
||||
finish_reason: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OllamaGenerateResponse {
|
||||
model: string;
|
||||
created_at: string;
|
||||
response: string;
|
||||
done: boolean;
|
||||
context?: number[];
|
||||
total_duration?: number;
|
||||
load_duration?: number;
|
||||
prompt_eval_count?: number;
|
||||
prompt_eval_duration?: number;
|
||||
eval_count?: number;
|
||||
eval_duration?: number;
|
||||
}
|
||||
|
||||
export interface OpenWebUIModelsResponse {
|
||||
data: Array<{
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,168 +1,70 @@
|
||||
/**
|
||||
* Factory utilities for creating AI providers
|
||||
* Provides convenient methods for instantiating and configuring providers
|
||||
* Factory utilities for creating AI providers.
|
||||
*/
|
||||
|
||||
import type { AIProviderConfig } from '../types/index.js';
|
||||
import { ClaudeProvider, type ClaudeConfig } from '../providers/claude.js';
|
||||
import { OpenAIProvider, type OpenAIConfig } from '../providers/openai.js';
|
||||
import { GeminiProvider, type GeminiConfig } from '../providers/gemini.js';
|
||||
import { OpenWebUIProvider, type OpenWebUIConfig } from '../providers/openwebui.js';
|
||||
import { ClaudeCodeProvider, type ClaudeCodeConfig } from '../providers/claude-code.js';
|
||||
import { BaseAIProvider } from '../providers/base.js';
|
||||
|
||||
/**
|
||||
* Supported AI provider types
|
||||
*/
|
||||
export type ProviderType = 'claude' | 'openai' | 'gemini' | 'openwebui';
|
||||
export const PROVIDER_REGISTRY = {
|
||||
claude: ClaudeProvider,
|
||||
openai: OpenAIProvider,
|
||||
gemini: GeminiProvider,
|
||||
openwebui: OpenWebUIProvider,
|
||||
'claude-code': ClaudeCodeProvider
|
||||
} as const;
|
||||
|
||||
export type ProviderType = keyof typeof PROVIDER_REGISTRY;
|
||||
|
||||
/**
|
||||
* Configuration map for different provider types
|
||||
*/
|
||||
export interface ProviderConfigMap {
|
||||
claude: ClaudeConfig;
|
||||
openai: OpenAIConfig;
|
||||
gemini: GeminiConfig;
|
||||
openwebui: OpenWebUIConfig;
|
||||
'claude-code': ClaudeCodeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create AI providers
|
||||
* @param type - The type of provider to create
|
||||
* @param config - Configuration for the provider
|
||||
* @returns Configured AI provider instance
|
||||
*/
|
||||
export function createProvider<T extends ProviderType>(
|
||||
type: T,
|
||||
config: ProviderConfigMap[T]
|
||||
): BaseAIProvider {
|
||||
switch (type) {
|
||||
case 'claude':
|
||||
return new ClaudeProvider(config as ClaudeConfig);
|
||||
case 'openai':
|
||||
return new OpenAIProvider(config as OpenAIConfig);
|
||||
case 'gemini':
|
||||
return new GeminiProvider(config as GeminiConfig);
|
||||
case 'openwebui':
|
||||
return new OpenWebUIProvider(config as OpenWebUIConfig);
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${type}`);
|
||||
const ProviderClass = PROVIDER_REGISTRY[type];
|
||||
if (!ProviderClass) {
|
||||
throw new Error(`Unsupported provider type: ${type}`);
|
||||
}
|
||||
return new ProviderClass(config as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Claude provider with simplified configuration
|
||||
* @param apiKey - Anthropic API key
|
||||
* @param options - Optional additional configuration
|
||||
* @returns Configured Claude provider instance
|
||||
*/
|
||||
export function createClaudeProvider(
|
||||
apiKey: string,
|
||||
options: Partial<Omit<ClaudeConfig, 'apiKey'>> = {}
|
||||
): ClaudeProvider {
|
||||
return new ClaudeProvider({
|
||||
apiKey,
|
||||
...options
|
||||
});
|
||||
return new ClaudeProvider({ apiKey, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an OpenAI provider with simplified configuration
|
||||
* @param apiKey - OpenAI API key
|
||||
* @param options - Optional additional configuration
|
||||
* @returns Configured OpenAI provider instance
|
||||
*/
|
||||
export function createOpenAIProvider(
|
||||
apiKey: string,
|
||||
options: Partial<Omit<OpenAIConfig, 'apiKey'>> = {}
|
||||
): OpenAIProvider {
|
||||
return new OpenAIProvider({
|
||||
apiKey,
|
||||
...options
|
||||
});
|
||||
return new OpenAIProvider({ apiKey, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Gemini provider with simplified configuration
|
||||
* @param apiKey - Google AI API key
|
||||
* @param options - Optional additional configuration
|
||||
* @returns Configured Gemini provider instance
|
||||
*/
|
||||
export function createGeminiProvider(
|
||||
apiKey: string,
|
||||
options: Partial<Omit<GeminiConfig, 'apiKey'>> = {}
|
||||
): GeminiProvider {
|
||||
return new GeminiProvider({
|
||||
apiKey,
|
||||
...options
|
||||
});
|
||||
return new GeminiProvider({ apiKey, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an OpenWebUI provider instance
|
||||
*/
|
||||
export function createOpenWebUIProvider(config: OpenWebUIConfig): OpenWebUIProvider {
|
||||
return new OpenWebUIProvider(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider registry for dynamic provider creation
|
||||
*/
|
||||
export class ProviderRegistry {
|
||||
private static providers = new Map<string, new (config: AIProviderConfig) => BaseAIProvider>();
|
||||
|
||||
/**
|
||||
* Register a new provider type
|
||||
* @param name - Name of the provider
|
||||
* @param providerClass - Provider class constructor
|
||||
*/
|
||||
static register(name: string, providerClass: new (config: AIProviderConfig) => BaseAIProvider): void {
|
||||
this.providers.set(name.toLowerCase(), providerClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a provider by name
|
||||
* @param name - Name of the provider
|
||||
* @param config - Configuration for the provider
|
||||
* @returns Provider instance
|
||||
*/
|
||||
static create(name: string, config: AIProviderConfig): BaseAIProvider {
|
||||
const ProviderClass = this.providers.get(name.toLowerCase());
|
||||
if (!ProviderClass) {
|
||||
throw new Error(`Provider '${name}' is not registered`);
|
||||
}
|
||||
return new ProviderClass(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of registered provider names
|
||||
* @returns Array of registered provider names
|
||||
*/
|
||||
static getRegisteredProviders(): string[] {
|
||||
return Array.from(this.providers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider is registered
|
||||
* @param name - Name of the provider
|
||||
* @returns True if provider is registered
|
||||
*/
|
||||
static isRegistered(name: string): boolean {
|
||||
return this.providers.has(name.toLowerCase());
|
||||
}
|
||||
export function createClaudeCodeProvider(
|
||||
options: Partial<ClaudeCodeConfig> = {}
|
||||
): ClaudeCodeProvider {
|
||||
return new ClaudeCodeProvider(options);
|
||||
}
|
||||
|
||||
// Pre-register built-in providers
|
||||
ProviderRegistry.register('claude', ClaudeProvider);
|
||||
ProviderRegistry.register('openai', OpenAIProvider);
|
||||
ProviderRegistry.register('gemini', GeminiProvider);
|
||||
ProviderRegistry.register('openwebui', OpenWebUIProvider);
|
||||
|
||||
/**
|
||||
* Registry of all available providers
|
||||
*/
|
||||
export const PROVIDER_REGISTRY = {
|
||||
claude: ClaudeProvider,
|
||||
openai: OpenAIProvider,
|
||||
gemini: GeminiProvider,
|
||||
openwebui: OpenWebUIProvider
|
||||
} as const;
|
||||
178
tests/claude-code.test.ts
Normal file
178
tests/claude-code.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Tests for ClaudeCodeProvider
|
||||
*
|
||||
* The provider shells out to the local Claude Code CLI via
|
||||
* @anthropic-ai/claude-agent-sdk. Tests cover the public surface and the
|
||||
* provider-specific hooks; full integration is not exercised because it
|
||||
* would spawn a real CLI process.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import { ClaudeCodeProvider } from '../src/providers/claude-code.js';
|
||||
import { AIProviderError, AIErrorType } from '../src/types/index.js';
|
||||
|
||||
describe('ClaudeCodeProvider', () => {
|
||||
let provider: ClaudeCodeProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new ClaudeCodeProvider({ apiKey: 'test-key' });
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should accept empty config (subscription mode)', () => {
|
||||
const p = new ClaudeCodeProvider({});
|
||||
expect(p).toBeInstanceOf(ClaudeCodeProvider);
|
||||
});
|
||||
|
||||
it('should accept apiKey override', () => {
|
||||
const p = new ClaudeCodeProvider({ apiKey: 'sk-...' });
|
||||
expect(p).toBeInstanceOf(ClaudeCodeProvider);
|
||||
});
|
||||
|
||||
it('should accept custom defaults', () => {
|
||||
const p = new ClaudeCodeProvider({
|
||||
defaultModel: 'opus',
|
||||
maxTurns: 5,
|
||||
allowedTools: ['Read', 'Bash'],
|
||||
cwd: '/tmp/agent'
|
||||
});
|
||||
expect(p).toBeInstanceOf(ClaudeCodeProvider);
|
||||
});
|
||||
|
||||
it('should reject non-string apiKey', () => {
|
||||
expect(() => new ClaudeCodeProvider({ apiKey: 123 as any }))
|
||||
.toThrow(AIProviderError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInfo', () => {
|
||||
it('should advertise subscriptionAuth capability', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
expect(info.name).toBe('Claude Code');
|
||||
expect(info.supportsStreaming).toBe(true);
|
||||
expect(info.capabilities).toMatchObject({
|
||||
subscriptionAuth: true,
|
||||
requiresLocalCli: true,
|
||||
systemMessages: true
|
||||
});
|
||||
expect(info.models).toContain('sonnet');
|
||||
expect(info.models).toContain('opus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error mapping', () => {
|
||||
const mapError = (err: any) => (provider as any).mapProviderError(err);
|
||||
|
||||
it('should detect missing CLI', () => {
|
||||
const err = new Error('spawn claude ENOENT');
|
||||
|
||||
const mapped = mapError(err);
|
||||
|
||||
expect(mapped).toBeInstanceOf(AIProviderError);
|
||||
expect(mapped.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
expect(mapped.message).toContain('CLI');
|
||||
});
|
||||
|
||||
it('should detect missing authentication', () => {
|
||||
const err = new Error('not logged in');
|
||||
|
||||
const mapped = mapError(err);
|
||||
|
||||
expect(mapped).toBeInstanceOf(AIProviderError);
|
||||
expect(mapped.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
});
|
||||
|
||||
it('should return null for unrelated errors', () => {
|
||||
expect(mapError(new Error('something else'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SDK assistant error mapping', () => {
|
||||
const mapSdkError = (code: string) => (provider as any).errorForSdkAssistantError(code);
|
||||
|
||||
it('should map authentication_failed', () => {
|
||||
const err = mapSdkError('authentication_failed');
|
||||
expect(err.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
});
|
||||
|
||||
it('should map rate_limit', () => {
|
||||
const err = mapSdkError('rate_limit');
|
||||
expect(err.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
});
|
||||
|
||||
it('should map model_not_found', () => {
|
||||
const err = mapSdkError('model_not_found');
|
||||
expect(err.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
});
|
||||
|
||||
it('should map server_error to NETWORK', () => {
|
||||
const err = mapSdkError('server_error');
|
||||
expect(err.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should fall back to UNKNOWN for unrecognized codes', () => {
|
||||
const err = mapSdkError('weird_new_code');
|
||||
expect(err.type).toBe(AIErrorType.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prompt building', () => {
|
||||
const build = (messages: any[]) => (provider as any).buildPrompt(messages);
|
||||
|
||||
it('should pass single user turn through unchanged', () => {
|
||||
const result = build([{ role: 'user', content: 'Hello' }]);
|
||||
|
||||
expect(result.prompt).toBe('Hello');
|
||||
expect(result.systemPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should combine multiple system messages', () => {
|
||||
const result = build([
|
||||
{ role: 'system', content: 'Be brief' },
|
||||
{ role: 'system', content: 'Use British spelling' },
|
||||
{ role: 'user', content: 'Hi' }
|
||||
]);
|
||||
|
||||
expect(result.systemPrompt).toBe('Be brief\n\nUse British spelling');
|
||||
expect(result.prompt).toBe('Hi');
|
||||
});
|
||||
|
||||
it('should flatten multi-turn conversation with role labels', () => {
|
||||
const result = build([
|
||||
{ role: 'user', content: 'What is 2+2?' },
|
||||
{ role: 'assistant', content: '4' },
|
||||
{ role: 'user', content: 'Why?' }
|
||||
]);
|
||||
|
||||
expect(result.prompt).toContain('Human: What is 2+2?');
|
||||
expect(result.prompt).toContain('Assistant: 4');
|
||||
expect(result.prompt).toContain('Human: Why?');
|
||||
expect(result.prompt.endsWith('Assistant:')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty turn list', () => {
|
||||
expect(() => build([{ role: 'system', content: 'only system' }]))
|
||||
.toThrow(AIProviderError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
it('should require initialization before complete()', async () => {
|
||||
await expect(
|
||||
provider.complete({ messages: [{ role: 'user', content: 'test' }] })
|
||||
).rejects.toThrow('Provider must be initialized before use');
|
||||
});
|
||||
|
||||
it('should validate temperature range when initialized', async () => {
|
||||
(provider as any).initialized = true;
|
||||
|
||||
await expect(
|
||||
provider.complete({
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
temperature: 1.5
|
||||
})
|
||||
).rejects.toThrow(/temperature must be at most 1/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe('ClaudeProvider', () => {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
temperature: 1.5
|
||||
})
|
||||
).rejects.toThrow('Temperature must be a number between 0.0 and 1.0');
|
||||
).rejects.toThrow(/temperature must be at most 1/);
|
||||
});
|
||||
|
||||
it('should validate message format', async () => {
|
||||
@@ -78,19 +78,20 @@ describe('ClaudeProvider', () => {
|
||||
it('should handle authentication errors', () => {
|
||||
const error = new Error('Unauthorized');
|
||||
(error as any).status = 401;
|
||||
|
||||
const providerError = (provider as any).handleAnthropicError(error);
|
||||
|
||||
|
||||
const providerError = (provider as any).normalizeError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
expect(providerError.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle rate limit errors', () => {
|
||||
const error = new Error('Rate limited');
|
||||
(error as any).status = 429;
|
||||
|
||||
const providerError = (provider as any).handleAnthropicError(error);
|
||||
|
||||
|
||||
const providerError = (provider as any).normalizeError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
});
|
||||
@@ -98,12 +99,22 @@ describe('ClaudeProvider', () => {
|
||||
it('should handle model not found errors', () => {
|
||||
const error = new Error('Model not found');
|
||||
(error as any).status = 404;
|
||||
|
||||
const providerError = (provider as any).handleAnthropicError(error);
|
||||
|
||||
|
||||
const providerError = (provider as any).normalizeError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
});
|
||||
|
||||
it('should route 400 with max_tokens to mapProviderError', () => {
|
||||
const error = new Error('max_tokens too large');
|
||||
(error as any).status = 400;
|
||||
|
||||
const providerError = (provider as any).normalizeError(error);
|
||||
|
||||
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
expect(providerError.message).toMatch(/Max tokens/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message conversion', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('GeminiProvider', () => {
|
||||
beforeEach(() => {
|
||||
provider = new GeminiProvider({
|
||||
apiKey: 'test-api-key',
|
||||
defaultModel: 'gemini-1.5-flash'
|
||||
defaultModel: 'gemini-2.5-flash'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,13 +52,13 @@ describe('GeminiProvider', () => {
|
||||
describe('getInfo', () => {
|
||||
it('should return provider information', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
|
||||
expect(info.name).toBe('Gemini');
|
||||
expect(info.version).toBe('1.0.0');
|
||||
expect(info.version).toBe('2.0.0');
|
||||
expect(info.supportsStreaming).toBe(true);
|
||||
expect(info.models).toContain('gemini-1.5-flash');
|
||||
expect(info.models).toContain('gemini-2.5-flash');
|
||||
expect(info.models).toContain('gemini-2.5-pro');
|
||||
expect(info.models).toContain('gemini-1.5-pro');
|
||||
expect(info.models).toContain('gemini-1.0-pro');
|
||||
expect(info.maxContextLength).toBe(1048576);
|
||||
expect(info.capabilities).toHaveProperty('vision', true);
|
||||
expect(info.capabilities).toHaveProperty('functionCalling', true);
|
||||
@@ -79,7 +79,7 @@ describe('GeminiProvider', () => {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
temperature: 1.5
|
||||
})
|
||||
).rejects.toThrow('Temperature must be a number between 0.0 and 1.0');
|
||||
).rejects.toThrow(/temperature must be at most 1/);
|
||||
});
|
||||
|
||||
it('should validate top_p range', async () => {
|
||||
@@ -92,7 +92,7 @@ describe('GeminiProvider', () => {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
topP: 1.5
|
||||
})
|
||||
).rejects.toThrow('Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)');
|
||||
).rejects.toThrow(/topP must be at most 1/);
|
||||
});
|
||||
|
||||
it('should validate message format', async () => {
|
||||
@@ -129,11 +129,13 @@ describe('GeminiProvider', () => {
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
const mapError = (err: any) => (provider as any).normalizeError(err);
|
||||
|
||||
it('should handle authentication errors', () => {
|
||||
const error = new Error('API key invalid');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
expect(providerError.message).toContain('Invalid Google API key');
|
||||
@@ -141,9 +143,9 @@ describe('GeminiProvider', () => {
|
||||
|
||||
it('should handle rate limit errors', () => {
|
||||
const error = new Error('quota exceeded');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
expect(providerError.message).toContain('API quota exceeded');
|
||||
@@ -151,9 +153,9 @@ describe('GeminiProvider', () => {
|
||||
|
||||
it('should handle model not found errors', () => {
|
||||
const error = new Error('model not found');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
expect(providerError.message).toContain('Model not found');
|
||||
@@ -161,36 +163,36 @@ describe('GeminiProvider', () => {
|
||||
|
||||
it('should handle invalid request errors', () => {
|
||||
const error = new Error('invalid length request parameters');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
});
|
||||
|
||||
it('should handle network errors', () => {
|
||||
const error = new Error('network connection failed');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should handle timeout errors', () => {
|
||||
const error = new Error('request timeout');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.TIMEOUT);
|
||||
});
|
||||
|
||||
it('should handle unknown errors', () => {
|
||||
const error = new Error('Unknown error');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.UNKNOWN);
|
||||
});
|
||||
@@ -266,7 +268,7 @@ describe('GeminiProvider', () => {
|
||||
stopSequences: ['STOP', 'END']
|
||||
};
|
||||
|
||||
const result = (provider as any).buildGenerationConfig(params);
|
||||
const result = (provider as any).buildConfig(params);
|
||||
|
||||
expect(result.temperature).toBe(0.8);
|
||||
expect(result.topP).toBe(0.9);
|
||||
@@ -279,20 +281,29 @@ describe('GeminiProvider', () => {
|
||||
messages: [{ role: 'user' as const, content: 'test' }]
|
||||
};
|
||||
|
||||
const result = (provider as any).buildGenerationConfig(params);
|
||||
const result = (provider as any).buildConfig(params);
|
||||
|
||||
expect(result.temperature).toBe(0.7);
|
||||
expect(result.maxOutputTokens).toBe(1000);
|
||||
});
|
||||
|
||||
it('should attach systemInstruction when passed', () => {
|
||||
const params = { messages: [{ role: 'user' as const, content: 'test' }] };
|
||||
|
||||
const result = (provider as any).buildConfig(params, 'be helpful');
|
||||
|
||||
expect(result.systemInstruction).toBe('be helpful');
|
||||
});
|
||||
});
|
||||
|
||||
describe('response formatting', () => {
|
||||
// The @google/genai SDK exposes `text` as a getter on GenerateContentResponse,
|
||||
// so the mock provides it directly rather than reconstructing the candidate->parts tree.
|
||||
|
||||
it('should format completion response correctly', () => {
|
||||
const mockResponse = {
|
||||
text: 'Hello there!',
|
||||
candidates: [{
|
||||
content: {
|
||||
parts: [{ text: 'Hello there!' }]
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
safetyRatings: [],
|
||||
citationMetadata: null
|
||||
@@ -304,42 +315,19 @@ describe('GeminiProvider', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = (provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash');
|
||||
const result = (provider as any).formatCompletionResponse(mockResponse, 'gemini-2.5-flash');
|
||||
|
||||
expect(result.content).toBe('Hello there!');
|
||||
expect(result.model).toBe('gemini-1.5-flash');
|
||||
expect(result.model).toBe('gemini-2.5-flash');
|
||||
expect(result.usage.promptTokens).toBe(10);
|
||||
expect(result.usage.completionTokens).toBe(20);
|
||||
expect(result.usage.totalTokens).toBe(30);
|
||||
expect(result.metadata.finishReason).toBe('STOP');
|
||||
});
|
||||
|
||||
it('should handle multiple text parts', () => {
|
||||
const mockResponse = {
|
||||
candidates: [{
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'Hello ' },
|
||||
{ text: 'there!' },
|
||||
{ functionCall: { name: 'test' } } // Non-text part should be filtered
|
||||
]
|
||||
},
|
||||
finishReason: 'STOP'
|
||||
}],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 10,
|
||||
totalTokenCount: 15
|
||||
}
|
||||
};
|
||||
|
||||
const result = (provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash');
|
||||
|
||||
expect(result.content).toBe('Hello there!');
|
||||
});
|
||||
|
||||
it('should throw error for empty response', () => {
|
||||
const mockResponse = {
|
||||
text: '',
|
||||
candidates: [],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 5,
|
||||
@@ -349,8 +337,8 @@ describe('GeminiProvider', () => {
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
(provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash');
|
||||
}).toThrow('No candidates found in Gemini response');
|
||||
(provider as any).formatCompletionResponse(mockResponse, 'gemini-2.5-flash');
|
||||
}).toThrow('No text content found in Gemini response');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('OpenAIProvider', () => {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
temperature: 1.5
|
||||
})
|
||||
).rejects.toThrow('Temperature must be a number between 0.0 and 1.0');
|
||||
).rejects.toThrow(/temperature must be at most 1/);
|
||||
});
|
||||
|
||||
it('should validate top_p range', async () => {
|
||||
@@ -85,7 +85,7 @@ describe('OpenAIProvider', () => {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
topP: 1.5
|
||||
})
|
||||
).rejects.toThrow('Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)');
|
||||
).rejects.toThrow(/topP must be at most 1/);
|
||||
});
|
||||
|
||||
it('should validate message format', async () => {
|
||||
@@ -120,23 +120,25 @@ describe('OpenAIProvider', () => {
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
const mapError = (err: any) => (provider as any).normalizeError(err);
|
||||
|
||||
it('should handle authentication errors', () => {
|
||||
const error = new Error('Unauthorized');
|
||||
(error as any).status = 401;
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
expect(providerError.message).toContain('Authentication failed');
|
||||
expect(providerError.message).toContain('OpenAI API key');
|
||||
});
|
||||
|
||||
it('should handle rate limit errors', () => {
|
||||
const error = new Error('Rate limited');
|
||||
(error as any).status = 429;
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
expect(providerError.message).toContain('Too many requests');
|
||||
@@ -145,9 +147,9 @@ describe('OpenAIProvider', () => {
|
||||
it('should handle model not found errors', () => {
|
||||
const error = new Error('Model not found');
|
||||
(error as any).status = 404;
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
expect(providerError.message).toContain('Model not found');
|
||||
@@ -156,9 +158,9 @@ describe('OpenAIProvider', () => {
|
||||
it('should handle invalid request errors', () => {
|
||||
const error = new Error('Bad request');
|
||||
(error as any).status = 400;
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
});
|
||||
@@ -166,21 +168,31 @@ describe('OpenAIProvider', () => {
|
||||
it('should handle server errors', () => {
|
||||
const error = new Error('Internal server error');
|
||||
(error as any).status = 500;
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should handle unknown errors', () => {
|
||||
const error = new Error('Unknown error');
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should route 429 with quota to quota-specific message', () => {
|
||||
const error = new Error('quota exceeded');
|
||||
(error as any).status = 429;
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
expect(providerError.message).toContain('Usage quota exceeded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message conversion', () => {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/**
|
||||
* Tests for OpenWebUI provider implementation
|
||||
*
|
||||
* The R3 refactor split message conversion, response formatting, and the
|
||||
* HTTP client into openwebui-strategies.ts and openwebui-http.ts. Tests for
|
||||
* those helpers should live alongside those files. This file covers the
|
||||
* provider's public surface and the error-mapping hooks it adds on top of
|
||||
* BaseAIProvider.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, jest } from 'bun:test';
|
||||
@@ -14,7 +20,7 @@ describe('OpenWebUIProvider', () => {
|
||||
mockConfig = {
|
||||
apiKey: 'test-bearer-token',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
defaultModel: 'llama3.1'
|
||||
defaultModel: 'llama3.1:latest'
|
||||
};
|
||||
});
|
||||
|
||||
@@ -26,31 +32,32 @@ describe('OpenWebUIProvider', () => {
|
||||
});
|
||||
|
||||
it('should use default values when not specified', () => {
|
||||
const minimalConfig: OpenWebUIConfig = { apiKey: 'test' };
|
||||
provider = new OpenWebUIProvider(minimalConfig);
|
||||
provider = new OpenWebUIProvider({ apiKey: 'test' });
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should handle custom configuration', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
it('should accept Ollama proxy mode', () => {
|
||||
provider = new OpenWebUIProvider({
|
||||
apiKey: 'test',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
defaultModel: 'mistral:7b',
|
||||
useOllamaProxy: true
|
||||
};
|
||||
provider = new OpenWebUIProvider(config);
|
||||
});
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should handle trailing slash in baseUrl', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
it('should normalize trailing slash in baseUrl', () => {
|
||||
provider = new OpenWebUIProvider({
|
||||
apiKey: 'test',
|
||||
baseUrl: 'http://localhost:3000/',
|
||||
defaultModel: 'llama3.1'
|
||||
};
|
||||
provider = new OpenWebUIProvider(config);
|
||||
baseUrl: 'http://localhost:3000/'
|
||||
});
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should reject malformed baseUrl', () => {
|
||||
expect(() => new OpenWebUIProvider({ apiKey: 'test', baseUrl: 'not a url' }))
|
||||
.toThrow(AIProviderError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInfo', () => {
|
||||
@@ -60,9 +67,9 @@ describe('OpenWebUIProvider', () => {
|
||||
|
||||
it('should return correct provider information', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
|
||||
expect(info.name).toBe('OpenWebUI');
|
||||
expect(info.version).toBe('1.0.0');
|
||||
expect(info.version).toBe('2.0.0');
|
||||
expect(info.supportsStreaming).toBe(true);
|
||||
expect(info.maxContextLength).toBe(32768);
|
||||
expect(Array.isArray(info.models)).toBe(true);
|
||||
@@ -70,345 +77,120 @@ describe('OpenWebUIProvider', () => {
|
||||
|
||||
it('should include expected models', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
|
||||
expect(info.models).toContain('llama3.1:latest');
|
||||
expect(info.models).toContain('mistral:latest');
|
||||
expect(info.models).toContain('codellama:latest');
|
||||
expect(info.models).toContain('gemma:latest');
|
||||
});
|
||||
|
||||
it('should have correct capabilities', () => {
|
||||
it('should expose capabilities', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
expect(info.capabilities).toEqual({
|
||||
|
||||
expect(info.capabilities).toMatchObject({
|
||||
vision: false,
|
||||
functionCalling: false,
|
||||
jsonMode: false,
|
||||
systemMessages: true,
|
||||
reasoning: true,
|
||||
codeGeneration: true,
|
||||
localDeployment: true,
|
||||
multiModel: true
|
||||
localDeployment: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
describe('Error mapping', () => {
|
||||
const mapError = (err: any) => (provider as any).normalizeError(err);
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new OpenWebUIProvider(mockConfig);
|
||||
});
|
||||
|
||||
it('should handle connection refused errors', () => {
|
||||
const mockError = new Error('connect ECONNREFUSED') as any;
|
||||
mockError.code = 'ECONNREFUSED';
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
it('should map ECONNREFUSED to NETWORK', () => {
|
||||
const error = new Error('connect ECONNREFUSED') as any;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
||||
expect(aiError.message).toContain('Network error connecting to OpenWebUI');
|
||||
});
|
||||
|
||||
it('should handle hostname resolution errors', () => {
|
||||
const mockError = new Error('getaddrinfo ENOTFOUND') as any;
|
||||
mockError.code = 'ENOTFOUND';
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
expect(aiError.type).toBe(AIErrorType.UNKNOWN);
|
||||
expect(aiError.message).toContain('OpenWebUI error');
|
||||
it('should map ENOTFOUND to NETWORK', () => {
|
||||
const error = new Error('getaddrinfo ENOTFOUND') as any;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should handle 404 model not found errors', () => {
|
||||
const mockError = new Error('model not available') as any;
|
||||
mockError.status = 404;
|
||||
mockError.message = 'model not available';
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map 404 with "model" in message to MODEL_NOT_FOUND', () => {
|
||||
const error = new Error('model not available') as any;
|
||||
error.status = 404;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
expect(aiError.message).toContain('OpenWebUI');
|
||||
});
|
||||
|
||||
it('should handle 404 endpoint not found errors', () => {
|
||||
const mockError = new Error('Not found') as any;
|
||||
mockError.status = 404;
|
||||
mockError.message = 'endpoint not found';
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
expect(aiError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
expect(aiError.message).toContain('OpenWebUI endpoint not found');
|
||||
it('should map 404 without "model" in message to MODEL_NOT_FOUND with endpoint message', () => {
|
||||
const error = new Error('Not found') as any;
|
||||
error.status = 404;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
expect(aiError.message).toContain('endpoint');
|
||||
});
|
||||
|
||||
it('should handle timeout errors', () => {
|
||||
const mockError = new Error('Request timeout') as any;
|
||||
mockError.code = 'ETIMEDOUT';
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map timeout errors to TIMEOUT', () => {
|
||||
const error = new Error('Request timeout') as any;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.TIMEOUT);
|
||||
});
|
||||
|
||||
it('should handle rate limit errors', () => {
|
||||
const mockError = new Error('Rate limit exceeded') as any;
|
||||
mockError.status = 429;
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map 429 to RATE_LIMIT', () => {
|
||||
const error = new Error('Rate limit exceeded') as any;
|
||||
error.status = 429;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
});
|
||||
|
||||
it('should handle server errors', () => {
|
||||
const mockError = new Error('Internal server error') as any;
|
||||
mockError.status = 500;
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map 500 to NETWORK', () => {
|
||||
const error = new Error('Internal server error') as any;
|
||||
error.status = 500;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should handle authentication errors', () => {
|
||||
const mockError = new Error('Unauthorized') as any;
|
||||
mockError.status = 401;
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map 401 to AUTHENTICATION with OpenWebUI hint', () => {
|
||||
const error = new Error('Unauthorized') as any;
|
||||
error.status = 401;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
expect(aiError.message).toContain('Authentication failed with OpenWebUI');
|
||||
expect(aiError.message).toContain('OpenWebUI');
|
||||
});
|
||||
|
||||
it('should handle unknown errors', () => {
|
||||
const mockError = new Error('Unknown error');
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map unrecognized errors to UNKNOWN', () => {
|
||||
const error = new Error('Unknown error');
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Conversion', () => {
|
||||
beforeEach(() => {
|
||||
provider = new OpenWebUIProvider(mockConfig);
|
||||
});
|
||||
|
||||
it('should convert messages correctly for chat API', () => {
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: 'You are helpful' },
|
||||
{ role: 'user' as const, content: 'Hello' },
|
||||
{ role: 'assistant' as const, content: 'Hi there!' }
|
||||
];
|
||||
|
||||
const converted = (provider as any).convertMessages(messages);
|
||||
|
||||
expect(converted).toHaveLength(3);
|
||||
expect(converted[0]).toEqual({ role: 'system', content: 'You are helpful' });
|
||||
expect(converted[1]).toEqual({ role: 'user', content: 'Hello' });
|
||||
expect(converted[2]).toEqual({ role: 'assistant', content: 'Hi there!' });
|
||||
});
|
||||
|
||||
it('should convert messages to prompt for Ollama API', () => {
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: 'You are helpful' },
|
||||
{ role: 'user' as const, content: 'Hello' },
|
||||
{ role: 'assistant' as const, content: 'Hi there!' }
|
||||
];
|
||||
|
||||
const prompt = (provider as any).convertMessagesToPrompt(messages);
|
||||
|
||||
expect(prompt).toBe('System: You are helpful\n\nHuman: Hello\n\nAssistant: Hi there!\n\nAssistant: ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Formatting', () => {
|
||||
beforeEach(() => {
|
||||
provider = new OpenWebUIProvider(mockConfig);
|
||||
});
|
||||
|
||||
it('should format chat completion response correctly', () => {
|
||||
const mockResponse = {
|
||||
id: 'test-id',
|
||||
object: 'chat.completion',
|
||||
created: 1234567890,
|
||||
model: 'llama3.1',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Hello there!' },
|
||||
finish_reason: 'stop'
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15
|
||||
}
|
||||
};
|
||||
|
||||
const response = (provider as any).formatChatResponse(mockResponse);
|
||||
|
||||
expect(response.content).toBe('Hello there!');
|
||||
expect(response.model).toBe('llama3.1');
|
||||
expect(response.id).toBe('test-id');
|
||||
expect(response.usage.promptTokens).toBe(10);
|
||||
expect(response.usage.completionTokens).toBe(5);
|
||||
expect(response.usage.totalTokens).toBe(15);
|
||||
expect(response.metadata?.finishReason).toBe('stop');
|
||||
expect(response.metadata?.created).toBe(1234567890);
|
||||
});
|
||||
|
||||
it('should format Ollama response correctly', () => {
|
||||
const mockResponse = {
|
||||
model: 'llama3.1',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
response: 'Hello there!',
|
||||
done: true,
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
total_duration: 1000000,
|
||||
eval_duration: 500000
|
||||
};
|
||||
|
||||
const response = (provider as any).formatOllamaResponse(mockResponse);
|
||||
|
||||
expect(response.content).toBe('Hello there!');
|
||||
expect(response.model).toBe('llama3.1');
|
||||
expect(response.usage.promptTokens).toBe(10);
|
||||
expect(response.usage.completionTokens).toBe(5);
|
||||
expect(response.usage.totalTokens).toBe(15);
|
||||
expect(response.metadata?.created).toBe(1704067200000);
|
||||
expect(response.metadata?.totalDuration).toBe(1000000);
|
||||
});
|
||||
|
||||
it('should handle response without content', () => {
|
||||
const mockResponse = {
|
||||
id: 'test-id',
|
||||
object: 'chat.completion',
|
||||
created: 1234567890,
|
||||
model: 'llama3.1',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant' }, // Message without content
|
||||
finish_reason: 'stop'
|
||||
}],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 0, total_tokens: 5 }
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
(provider as any).formatChatResponse(mockResponse);
|
||||
}).toThrow(AIProviderError);
|
||||
});
|
||||
|
||||
it('should handle missing usage information', () => {
|
||||
const mockResponse = {
|
||||
id: 'test-id',
|
||||
object: 'chat.completion',
|
||||
created: 1234567890,
|
||||
model: 'llama3.1',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Hello there!' },
|
||||
finish_reason: 'stop'
|
||||
}]
|
||||
// No usage information
|
||||
};
|
||||
|
||||
const response = (provider as any).formatChatResponse(mockResponse);
|
||||
|
||||
expect(response.content).toBe('Hello there!');
|
||||
expect(response.usage.promptTokens).toBe(0);
|
||||
expect(response.usage.completionTokens).toBe(0);
|
||||
expect(response.usage.totalTokens).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Options', () => {
|
||||
it('should handle custom baseUrl', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
baseUrl: 'https://my-openwebui.com'
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should handle custom default model', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
defaultModel: 'mistral:7b'
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should handle Ollama proxy mode', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
useOllamaProxy: true
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should handle SSL verification settings', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
dangerouslyAllowInsecureConnections: false
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Generation', () => {
|
||||
beforeEach(() => {
|
||||
provider = new OpenWebUIProvider(mockConfig);
|
||||
});
|
||||
|
||||
it('should generate correct headers', () => {
|
||||
const headers = (provider as any).makeRequest('http://test.com', 'GET');
|
||||
// Note: This would need to be mocked in a real test environment
|
||||
expect(typeof headers).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Mode Selection', () => {
|
||||
it('should use chat API by default', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
useOllamaProxy: false
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should use Ollama proxy when configured', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
useOllamaProxy: true
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
beforeEach(() => {
|
||||
provider = new OpenWebUIProvider(mockConfig);
|
||||
});
|
||||
|
||||
it('should validate provider is initialized before use', async () => {
|
||||
it('should require initialization before use', async () => {
|
||||
const params: CompletionParams = {
|
||||
messages: [{ role: 'user', content: 'Hello' }]
|
||||
};
|
||||
@@ -417,31 +199,31 @@ describe('OpenWebUIProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('structured responses', () => {
|
||||
it('should handle structured responses correctly', async () => {
|
||||
const provider = new OpenWebUIProvider({ apiKey: 'test-key' });
|
||||
describe('Structured responses', () => {
|
||||
it('should parse JSON content into typed responses', async () => {
|
||||
const p = new OpenWebUIProvider({ apiKey: 'test-key' });
|
||||
const mockResponse = {
|
||||
content: JSON.stringify({ name: 'John Doe', age: 30 }),
|
||||
model: 'llama3.1',
|
||||
model: 'llama3.1:latest',
|
||||
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
|
||||
id: 'test-id'
|
||||
};
|
||||
(provider as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
|
||||
(provider as any).initialized = true;
|
||||
(p as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
|
||||
(p as any).initialized = true;
|
||||
|
||||
const responseType = createResponseType<{ name: string; age: number }>
|
||||
(`{ name: string; age: number }`,
|
||||
const responseType = createResponseType<{ name: string; age: number }>(
|
||||
`{ name: string; age: number }`,
|
||||
'A user profile'
|
||||
);
|
||||
|
||||
const response = await provider.complete<{ name: string; age: number }>({
|
||||
const response = await p.complete<{ name: string; age: number }>({
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
responseType
|
||||
});
|
||||
|
||||
expect(response.content).toEqual({ name: 'John Doe', age: 30 });
|
||||
expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 }));
|
||||
expect((provider as any).doComplete).toHaveBeenCalled();
|
||||
expect((p as any).doComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user