Compare commits
16 Commits
v2.0.0
...
docs/claud
| Author | SHA1 | Date | |
|---|---|---|---|
|
956ca21f54
|
|||
|
b99707c629
|
|||
|
b935209142
|
|||
|
d4871d22fe
|
|||
|
9cb7ee8f9e
|
|||
|
a0d181787c
|
|||
|
93ef2611c0
|
|||
|
c8c579da8b
|
|||
|
e10ce7f53a
|
|||
|
6298a0027d
|
|||
|
8e430b2659
|
|||
|
3d985a95a8
|
|||
|
eacd76d259
|
|||
|
b36d57711b
|
|||
|
8e73296ac2
|
|||
|
bb37e61eaf
|
745
README.md
745
README.md
@@ -1,35 +1,10 @@
|
|||||||
# Simple AI Provider
|
# 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
|
## Install
|
||||||
- 🎯 **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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install simple-ai-provider
|
npm install simple-ai-provider
|
||||||
@@ -37,37 +12,14 @@ npm install simple-ai-provider
|
|||||||
bun add 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
|
```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! });
|
||||||
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
|
|
||||||
await claude.initialize();
|
await claude.initialize();
|
||||||
|
|
||||||
const response = await claude.complete({
|
const response = await claude.complete({
|
||||||
@@ -75,183 +27,107 @@ const response = await claude.complete({
|
|||||||
{ role: 'system', content: 'You are a helpful assistant.' },
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
||||||
{ role: 'user', content: 'Explain TypeScript in one sentence.' }
|
{ role: 'user', content: 'Explain TypeScript in one sentence.' }
|
||||||
],
|
],
|
||||||
maxTokens: 100,
|
maxTokens: 200
|
||||||
temperature: 0.7
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(response.content);
|
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
|
```typescript
|
||||||
import { createProvider, createClaudeProvider, createOpenAIProvider, createGeminiProvider, createOpenWebUIProvider } from 'simple-ai-provider';
|
import { ClaudeProvider } from 'simple-ai-provider';
|
||||||
|
|
||||||
// Method 1: Specific factory functions
|
const claude = new ClaudeProvider({
|
||||||
const claude = createClaudeProvider('your-key', { defaultModel: 'claude-3-5-sonnet-20241022' });
|
apiKey: process.env.ANTHROPIC_API_KEY!,
|
||||||
const openai = createOpenAIProvider('your-key', { defaultModel: 'gpt-4o' });
|
defaultModel: 'claude-3-5-sonnet-20241022', // optional
|
||||||
const gemini = createGeminiProvider('your-key', { defaultModel: 'gemini-1.5-flash' });
|
version: '2023-06-01', // optional
|
||||||
const openwebui = createOpenWebUIProvider({ apiKey: 'your-key', baseUrl: 'http://localhost:3000' });
|
timeout: 30_000, // optional, ms
|
||||||
|
maxRetries: 3 // optional
|
||||||
// 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
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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 (via local CLI)
|
||||||
|
|
||||||
- **Automatic Parsing**: The AI's JSON response is automatically parsed into your specified type.
|
`ClaudeCodeProvider` wraps `@anthropic-ai/claude-agent-sdk`, which spawns the local `claude` CLI under the hood.
|
||||||
- **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.
|
|
||||||
|
|
||||||
### Streaming with Response Types
|
> **Honest status:** the **API-key mode** (Mode B below) works reliably. The **subscription mode** (Mode A) is currently broken on macOS due to an upstream bug in `@anthropic-ai/claude-agent-sdk` — the SDK isolates `CLAUDE_CONFIG_DIR` per invocation and tries to copy a `~/.claude/.credentials.json` file that doesn't exist on macOS (Claude Code stores credentials in the Keychain). The provider misidentifies the OAuth token and bills it as an API key, so subscribers without API credits hit "Credit balance is too low".
|
||||||
|
>
|
||||||
|
> If you have a Pro/Max subscription and need programmatic access today: use the direct [`ClaudeProvider`](#claude-anthropic-api) with an API key, or wait for the upstream SDK fix. The `oauthToken` field is wired up and ready for when Anthropic fixes the Keychain handling.
|
||||||
|
|
||||||
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.
|
**Mode A — Claude Pro/Max subscription** *(see status note above)*:
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required for respective providers
|
# One-time: install the CLI, then mint a long-lived OAuth token.
|
||||||
export ANTHROPIC_API_KEY="your-claude-api-key"
|
# `claude login` alone is NOT enough — Anthropic gates SDK use behind setup-token.
|
||||||
export OPENAI_API_KEY="your-openai-api-key"
|
claude setup-token
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Provider-Specific Configuration
|
|
||||||
|
|
||||||
### Claude Configuration
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const claude = new ClaudeProvider({
|
import { ClaudeCodeProvider } from 'simple-ai-provider';
|
||||||
apiKey: 'your-api-key',
|
|
||||||
defaultModel: 'claude-3-5-sonnet-20241022',
|
const claude = new ClaudeCodeProvider({
|
||||||
version: '2023-06-01',
|
oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN // from `claude setup-token`
|
||||||
maxRetries: 3,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### OpenAI Configuration
|
**Mode B — Console API key** (billed per-token, works reliably):
|
||||||
|
|
||||||
```typescript
|
```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({
|
||||||
|
apiKey: '...',
|
||||||
|
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 even when it works:**
|
||||||
|
- 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.
|
||||||
|
- If you have an API key, [`ClaudeProvider`](#claude-anthropic-api) is the simpler, faster choice.
|
||||||
|
|
||||||
|
### OpenAI
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { OpenAIProvider } from 'simple-ai-provider';
|
||||||
|
|
||||||
const openai = new OpenAIProvider({
|
const openai = new OpenAIProvider({
|
||||||
apiKey: 'your-api-key',
|
apiKey: process.env.OPENAI_API_KEY!,
|
||||||
defaultModel: 'gpt-4o',
|
defaultModel: 'gpt-4o',
|
||||||
organization: 'your-org-id',
|
organization: 'org-...', // optional
|
||||||
project: 'your-project-id',
|
project: 'proj-...' // optional
|
||||||
maxRetries: 3,
|
|
||||||
timeout: 30000
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gemini Configuration
|
`baseUrl` is supported for OpenAI-compatible endpoints.
|
||||||
|
|
||||||
|
### Gemini (Google)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
import { GeminiProvider } from 'simple-ai-provider';
|
||||||
|
|
||||||
const gemini = new GeminiProvider({
|
const gemini = new GeminiProvider({
|
||||||
apiKey: 'your-api-key',
|
apiKey: process.env.GOOGLE_AI_API_KEY!,
|
||||||
defaultModel: 'gemini-1.5-flash',
|
defaultModel: 'gemini-2.5-flash',
|
||||||
safetySettings: [
|
safetySettings: [/* SafetySetting[] from @google/genai */],
|
||||||
{
|
|
||||||
category: 'HARM_CATEGORY_HARASSMENT',
|
|
||||||
threshold: 'BLOCK_MEDIUM_AND_ABOVE'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
topP: 0.8,
|
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
|
```typescript
|
||||||
|
import { OpenWebUIProvider } from 'simple-ai-provider';
|
||||||
|
|
||||||
const openwebui = new OpenWebUIProvider({
|
const openwebui = new OpenWebUIProvider({
|
||||||
apiKey: 'your-bearer-token', // Get from OpenWebUI Settings > Account
|
apiKey: 'your-bearer-token', // from Settings > Account in OpenWebUI
|
||||||
baseUrl: 'http://localhost:3000', // Your OpenWebUI instance
|
baseUrl: 'http://localhost:3000',
|
||||||
defaultModel: 'llama3.1',
|
defaultModel: 'llama3.1:latest',
|
||||||
useOllamaProxy: false, // Use OpenWebUI's chat API (recommended)
|
useOllamaProxy: false, // false: OpenWebUI chat API (default)
|
||||||
// useOllamaProxy: true, // Use Ollama API proxy for direct model access
|
// true: direct Ollama proxy
|
||||||
dangerouslyAllowInsecureConnections: true, // For local HTTPS
|
dangerouslyAllowInsecureConnections: true
|
||||||
timeout: 60000, // Longer timeout for local inference
|
|
||||||
maxRetries: 2
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🌊 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
|
```typescript
|
||||||
const stream = provider.stream({
|
for await (const chunk of provider.stream({ messages, maxTokens: 200 })) {
|
||||||
messages: [{ role: 'user', content: 'Count from 1 to 10' }],
|
|
||||||
maxTokens: 100
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
if (!chunk.isComplete) {
|
if (!chunk.isComplete) {
|
||||||
process.stdout.write(chunk.content);
|
process.stdout.write(chunk.content);
|
||||||
} else {
|
} 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
|
```typescript
|
||||||
const providers = {
|
import { createResponseType } from 'simple-ai-provider';
|
||||||
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'
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize all providers
|
interface UserProfile {
|
||||||
await Promise.all(Object.values(providers).map(p => p.initialize()));
|
name: string;
|
||||||
|
age: number;
|
||||||
// Use the same interface for all
|
hobbies: string[];
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 |
|
## Factory functions
|
||||||
|----------|---------------|-----------|--------|------------------|-----------------|----------|
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
### Detailed Capabilities
|
If you prefer a single entry point:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Get provider information including available models
|
import { createProvider } from 'simple-ai-provider';
|
||||||
const info = provider.getInfo();
|
|
||||||
console.log('Available models:', info.models);
|
|
||||||
|
|
||||||
// Example output:
|
const claude = createProvider('claude', { apiKey: '…' });
|
||||||
// Claude: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', ...]
|
const openai = createProvider('openai', { apiKey: '…' });
|
||||||
// OpenAI: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', ...]
|
const gemini = createProvider('gemini', { apiKey: '…' });
|
||||||
// Gemini: ['gemini-1.5-flash', 'gemini-1.5-pro', ...]
|
const openwebui = createProvider('openwebui', { apiKey: '…', baseUrl: '…' });
|
||||||
// OpenWebUI: ['llama3.1:latest', 'mistral:latest', ...]
|
const claudeCode = createProvider('claude-code', {});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Model Selection Guidelines
|
Per-provider shortcut factories also exist: `createClaudeProvider`, `createOpenAIProvider`, `createGeminiProvider`, `createOpenWebUIProvider`, `createClaudeCodeProvider`.
|
||||||
|
|
||||||
**For Claude (Anthropic):**
|
## Error handling
|
||||||
- Check [Anthropic's model documentation](https://docs.anthropic.com/claude/docs/models-overview) for latest models
|
|
||||||
|
|
||||||
**For OpenAI:**
|
Every error thrown by the library is an `AIProviderError` with a typed `.type` and optional `.statusCode`:
|
||||||
- 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:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { AIProviderError, AIErrorType } from 'simple-ai-provider';
|
import { AIProviderError, AIErrorType } from 'simple-ai-provider';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await provider.complete({
|
await provider.complete({ messages: [...] });
|
||||||
messages: [{ role: 'user', content: 'Hello' }]
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AIProviderError) {
|
if (error instanceof AIProviderError) {
|
||||||
switch (error.type) {
|
switch (error.type) {
|
||||||
case AIErrorType.AUTHENTICATION:
|
case AIErrorType.AUTHENTICATION: /* bad API key, expired token */ break;
|
||||||
console.log('Invalid API key or authentication failed');
|
case AIErrorType.RATE_LIMIT: /* slow down */ break;
|
||||||
break;
|
case AIErrorType.MODEL_NOT_FOUND: /* wrong model name */ break;
|
||||||
case AIErrorType.RATE_LIMIT:
|
case AIErrorType.INVALID_REQUEST: /* bad input */ break;
|
||||||
console.log('Rate limited, try again later');
|
case AIErrorType.NETWORK: /* transient connectivity */ break;
|
||||||
break;
|
case AIErrorType.TIMEOUT: /* slow request */ break;
|
||||||
case AIErrorType.MODEL_NOT_FOUND:
|
case AIErrorType.UNKNOWN: /* fall back */ break;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
console.error(error.statusCode, error.originalError);
|
||||||
// Access additional error details
|
|
||||||
console.log('Status Code:', error.statusCode);
|
|
||||||
console.log('Original Error:', error.originalError);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Types
|
## Configuration reference
|
||||||
|
|
||||||
- **AUTHENTICATION**: Invalid API keys or authentication failures
|
All providers share these base options:
|
||||||
- **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
|
|
||||||
|
|
||||||
## 🔧 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
|
```typescript
|
||||||
// OpenAI-compatible endpoint
|
import type {
|
||||||
const customOpenAI = new OpenAIProvider({
|
AIMessage,
|
||||||
apiKey: 'your-key',
|
CompletionParams,
|
||||||
baseUrl: 'https://api.custom-provider.com/v1'
|
CompletionResponse,
|
||||||
});
|
CompletionChunk,
|
||||||
|
TokenUsage,
|
||||||
|
ProviderInfo,
|
||||||
|
ResponseType,
|
||||||
|
|
||||||
// Custom OpenWebUI instance
|
// Per-provider config interfaces
|
||||||
const remoteOpenWebUI = new OpenWebUIProvider({
|
ClaudeConfig,
|
||||||
apiKey: 'your-key',
|
ClaudeCodeConfig,
|
||||||
baseUrl: 'https://my-openwebui.example.com',
|
OpenAIConfig,
|
||||||
apiPath: '/api/v1'
|
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
|
```typescript
|
||||||
const info = provider.getInfo();
|
const info = provider.getInfo();
|
||||||
console.log(`Provider: ${info.name} v${info.version}`);
|
// { name, version, models, maxContextLength, supportsStreaming, capabilities }
|
||||||
console.log(`Models: ${info.models.join(', ')}`);
|
|
||||||
console.log(`Max Context: ${info.maxContextLength} tokens`);
|
|
||||||
console.log(`Supports Streaming: ${info.supportsStreaming}`);
|
|
||||||
console.log('Capabilities:', info.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
|
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:
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test connection and list available models
|
- `doInitialize()` / `doComplete()` / `doStream()` — the actual SDK calls
|
||||||
try {
|
- `getModelNamePatterns()` — regexes for naming-convention warnings
|
||||||
await openwebui.initialize();
|
- `sendValidationProbe()` — minimal request used during `initialize()`
|
||||||
console.log('Connected to local OpenWebUI instance');
|
- `mapProviderError()` / `providerErrorMessages()` — provider-specific error translation
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**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
|
## Development
|
||||||
- **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:
|
|
||||||
|
|
||||||
```bash
|
```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
|
git clone https://gitea.jleibl.net/jleibl/simple-ai-provider.git
|
||||||
cd simple-ai-provider
|
cd simple-ai-provider
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
# Build the project
|
|
||||||
bun run build
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
# Run tests
|
Examples live in `examples/`:
|
||||||
bun test
|
|
||||||
|
|
||||||
# Run examples
|
```bash
|
||||||
bun run examples/basic-usage.ts
|
bun run examples/basic-usage.ts
|
||||||
bun run examples/structured-response-types.ts
|
|
||||||
bun run examples/multi-provider.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
|
## License
|
||||||
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
|
|
||||||
|
|
||||||
examples/
|
MIT — see [LICENSE](LICENSE).
|
||||||
├── basic-usage.ts # Basic usage examples
|
|
||||||
├── structured-response-types.ts # Structured output examples
|
|
||||||
└── multi-provider.ts # Multi-provider examples
|
|
||||||
|
|
||||||
tests/
|
## Links
|
||||||
├── 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
|
|
||||||
|
|
||||||
- [Anthropic Claude API](https://docs.anthropic.com/claude/reference/)
|
- [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/)
|
- [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/)
|
- [OpenWebUI](https://openwebui.com/)
|
||||||
- [Gitea Repository](https://gitea.jleibl.net/jleibl/simple-ai-provider)
|
- [Repository](https://gitea.jleibl.net/jleibl/simple-ai-provider)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
⭐ **Star this repo if you find it helpful!**
|
|
||||||
|
|||||||
199
bun.lock
199
bun.lock
@@ -4,6 +4,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "simple-ai-provider",
|
"name": "simple-ai-provider",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.3.146",
|
||||||
"@anthropic-ai/sdk": "^0.97.0",
|
"@anthropic-ai/sdk": "^0.97.0",
|
||||||
"@google/genai": "^2.5.0",
|
"@google/genai": "^2.5.0",
|
||||||
"openai": "^6.0.0",
|
"openai": "^6.0.0",
|
||||||
@@ -19,12 +20,34 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||||
|
|
||||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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-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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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",
|
"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",
|
"description": "A simple and extensible AI provider package for easy integration of multiple AI services",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.mjs",
|
"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": "bun run build:clean && bun run build:types && bun run build:esm && bun run build:cjs",
|
||||||
"build:clean": "rm -rf dist",
|
"build:clean": "rm -rf dist",
|
||||||
"build:types": "tsc --project tsconfig.build.json",
|
"build:types": "tsc --project tsconfig.build.json",
|
||||||
"build:esm": "bun build src/index.ts --outfile dist/index.mjs --format esm",
|
"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",
|
"build:cjs": "bun build src/index.ts --outfile dist/index.js --format cjs --target node",
|
||||||
"dev": "bun run src/index.ts",
|
"dev": "bun run src/index.ts",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"prepublishOnly": "bun run build"
|
"prepublishOnly": "bun run build"
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
"url": "https://gitea.jleibl.net/jleibl/simple-ai-provider.git"
|
"url": "https://gitea.jleibl.net/jleibl/simple-ai-provider.git"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.3.146",
|
||||||
"@anthropic-ai/sdk": "^0.97.0",
|
"@anthropic-ai/sdk": "^0.97.0",
|
||||||
"@google/genai": "^2.5.0",
|
"@google/genai": "^2.5.0",
|
||||||
"openai": "^6.0.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 { OpenAIProvider, type OpenAIConfig } from './providers/openai.js';
|
||||||
export { GeminiProvider, type GeminiConfig } from './providers/gemini.js';
|
export { GeminiProvider, type GeminiConfig } from './providers/gemini.js';
|
||||||
export { OpenWebUIProvider, type OpenWebUIConfig } from './providers/openwebui.js';
|
export { OpenWebUIProvider, type OpenWebUIConfig } from './providers/openwebui.js';
|
||||||
|
export { ClaudeCodeProvider, type ClaudeCodeConfig } from './providers/claude-code.js';
|
||||||
|
|
||||||
// Factory utilities
|
// Factory utilities
|
||||||
export {
|
export {
|
||||||
@@ -46,6 +47,7 @@ export {
|
|||||||
createOpenAIProvider,
|
createOpenAIProvider,
|
||||||
createGeminiProvider,
|
createGeminiProvider,
|
||||||
createOpenWebUIProvider,
|
createOpenWebUIProvider,
|
||||||
|
createClaudeCodeProvider,
|
||||||
type ProviderType,
|
type ProviderType,
|
||||||
PROVIDER_REGISTRY
|
PROVIDER_REGISTRY
|
||||||
} from './utils/factory.js';
|
} from './utils/factory.js';
|
||||||
@@ -53,9 +55,9 @@ export {
|
|||||||
/**
|
/**
|
||||||
* List of all supported providers
|
* 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
|
* Package version
|
||||||
*/
|
*/
|
||||||
export const VERSION = '1.3.1';
|
export const VERSION = '2.1.0';
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import type {
|
|||||||
ResponseType
|
ResponseType
|
||||||
} from '../types/index.js';
|
} from '../types/index.js';
|
||||||
import { AIProviderError, AIErrorType, generateResponseTypePrompt, parseAndValidateResponseType } 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
|
// ABSTRACT BASE PROVIDER CLASS
|
||||||
@@ -283,10 +288,104 @@ export abstract class BaseAIProvider {
|
|||||||
*/
|
*/
|
||||||
protected abstract doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk>;
|
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
|
// 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.
|
* 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 {
|
return {
|
||||||
...config,
|
...config,
|
||||||
timeout: this.validateTimeout(config.timeout),
|
timeout: config.timeout ?? DEFAULT_TIMEOUT_MS,
|
||||||
maxRetries: this.validateMaxRetries(config.maxRetries)
|
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates timeout configuration value.
|
* Validates an optional numeric parameter against a range.
|
||||||
*
|
* Skips validation when value is undefined so callers can apply defaults afterward.
|
||||||
* @private
|
|
||||||
* @param timeout - Timeout value to validate
|
|
||||||
* @returns Validated timeout value with default if needed
|
|
||||||
*/
|
*/
|
||||||
private validateTimeout(timeout?: number): number {
|
private validateNumberInRange(
|
||||||
const defaultTimeout = 30000; // 30 seconds
|
name: string,
|
||||||
|
value: number | undefined,
|
||||||
if (timeout === undefined) {
|
bounds: {
|
||||||
return defaultTimeout;
|
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(
|
throw new AIProviderError(
|
||||||
'Timeout must be a number between 1000ms (1s) and 300000ms (5min)',
|
`${name} must be a number between ${range}`,
|
||||||
AIErrorType.INVALID_REQUEST
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private describeRange(min?: number, max?: number, exclusiveMin?: boolean): string {
|
||||||
* Validates max retries configuration value.
|
if (min !== undefined && max !== undefined) {
|
||||||
*
|
return `${exclusiveMin ? '>' : '>='}${min} and <=${max}`;
|
||||||
* @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;
|
|
||||||
}
|
}
|
||||||
|
if (min !== undefined) return `${exclusiveMin ? '>' : '>='}${min}`;
|
||||||
if (typeof maxRetries !== 'number' || maxRetries < 0 || maxRetries > 10) {
|
if (max !== undefined) return `<=${max}`;
|
||||||
throw new AIProviderError(
|
return 'a valid number';
|
||||||
'Max retries must be a number between 0 and 10',
|
|
||||||
AIErrorType.INVALID_REQUEST
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxRetries;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -403,13 +520,11 @@ export abstract class BaseAIProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate messages array
|
|
||||||
this.validateMessages(params.messages);
|
this.validateMessages(params.messages);
|
||||||
|
|
||||||
// Validate optional parameters
|
this.validateNumberInRange('temperature', params.temperature, CONFIG_BOUNDS.temperature);
|
||||||
this.validateTemperature(params.temperature);
|
this.validateNumberInRange('topP', params.topP, CONFIG_BOUNDS.topP);
|
||||||
this.validateTopP(params.topP);
|
this.validateNumberInRange('maxTokens', params.maxTokens, { ...CONFIG_BOUNDS.maxTokens, integer: true });
|
||||||
this.validateMaxTokens(params.maxTokens);
|
|
||||||
this.validateStopSequences(params.stopSequences);
|
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 {
|
private validateStopSequences(stopSequences?: string[]): void {
|
||||||
if (stopSequences !== undefined) {
|
if (stopSequences !== undefined) {
|
||||||
if (!Array.isArray(stopSequences)) {
|
if (!Array.isArray(stopSequences)) {
|
||||||
@@ -587,65 +641,52 @@ export abstract class BaseAIProvider {
|
|||||||
* @returns Normalized AIProviderError with appropriate type and context
|
* @returns Normalized AIProviderError with appropriate type and context
|
||||||
*/
|
*/
|
||||||
protected normalizeError(error: Error): AIProviderError {
|
protected normalizeError(error: Error): AIProviderError {
|
||||||
// If already normalized, return as-is
|
|
||||||
if (error instanceof AIProviderError) {
|
if (error instanceof AIProviderError) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract status code if available
|
const providerError = this.mapProviderError(error);
|
||||||
const status = (error as any).status || (error as any).statusCode;
|
if (providerError) {
|
||||||
const message = error.message || 'Unknown error occurred';
|
return providerError;
|
||||||
|
}
|
||||||
// Map HTTP status codes to error types
|
|
||||||
if (status) {
|
const status = (error as any).status || (error as any).statusCode;
|
||||||
switch (status) {
|
const message = error.message || 'Unknown error occurred';
|
||||||
case 400:
|
const overrides = this.providerErrorMessages();
|
||||||
return new AIProviderError(
|
|
||||||
`Bad request: ${message}`,
|
const statusTypeMap: Record<number, AIErrorType> = {
|
||||||
AIErrorType.INVALID_REQUEST,
|
400: AIErrorType.INVALID_REQUEST,
|
||||||
status,
|
401: AIErrorType.AUTHENTICATION,
|
||||||
error
|
403: AIErrorType.AUTHENTICATION,
|
||||||
);
|
404: AIErrorType.MODEL_NOT_FOUND,
|
||||||
|
429: AIErrorType.RATE_LIMIT,
|
||||||
case 401:
|
500: AIErrorType.NETWORK,
|
||||||
case 403:
|
502: AIErrorType.NETWORK,
|
||||||
return new AIProviderError(
|
503: AIErrorType.NETWORK,
|
||||||
'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
|
504: AIErrorType.NETWORK
|
||||||
AIErrorType.AUTHENTICATION,
|
};
|
||||||
status,
|
|
||||||
error
|
const defaultMessages: Record<number, string> = {
|
||||||
);
|
400: `Bad request: ${message}`,
|
||||||
|
401: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
|
||||||
case 404:
|
403: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
|
||||||
return new AIProviderError(
|
404: 'The specified model or endpoint was not found. Please check the model name and availability.',
|
||||||
'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.',
|
||||||
AIErrorType.MODEL_NOT_FOUND,
|
500: 'Service temporarily unavailable. Please try again in a few moments.',
|
||||||
status,
|
502: 'Service temporarily unavailable. Please try again in a few moments.',
|
||||||
error
|
503: 'Service temporarily unavailable. Please try again in a few moments.',
|
||||||
);
|
504: 'Service temporarily unavailable. Please try again in a few moments.'
|
||||||
|
};
|
||||||
case 429:
|
|
||||||
return new AIProviderError(
|
if (status && statusTypeMap[status]) {
|
||||||
'Rate limit exceeded. Please reduce your request frequency and try again later.',
|
return new AIProviderError(
|
||||||
AIErrorType.RATE_LIMIT,
|
overrides[status] ?? defaultMessages[status]!,
|
||||||
status,
|
statusTypeMap[status]!,
|
||||||
error
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map common error patterns
|
|
||||||
if (message.includes('timeout') || message.includes('ETIMEDOUT')) {
|
if (message.includes('timeout') || message.includes('ETIMEDOUT')) {
|
||||||
return new AIProviderError(
|
return new AIProviderError(
|
||||||
'Request timed out. The operation took longer than expected.',
|
'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(
|
return new AIProviderError(
|
||||||
`Provider error: ${message}`,
|
`${this.providerName} error: ${message}`,
|
||||||
AIErrorType.UNKNOWN,
|
AIErrorType.UNKNOWN,
|
||||||
status,
|
status,
|
||||||
error
|
error
|
||||||
|
|||||||
413
src/providers/claude-code.ts
Normal file
413
src/providers/claude-code.ts
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* Claude Code Provider
|
||||||
|
*
|
||||||
|
* Wraps `@anthropic-ai/claude-agent-sdk` so consumers can use a local
|
||||||
|
* Claude Code installation through the same BaseAIProvider interface
|
||||||
|
* as the other providers.
|
||||||
|
*
|
||||||
|
* Status:
|
||||||
|
* - apiKey mode: works reliably.
|
||||||
|
* - oauthToken (subscription) mode: currently broken on macOS due to an
|
||||||
|
* upstream SDK bug. The SDK isolates CLAUDE_CONFIG_DIR per invocation
|
||||||
|
* and tries to copy ~/.claude/.credentials.json, which doesn't exist on
|
||||||
|
* macOS (creds live in the Keychain). The OAuth token gets misclassified
|
||||||
|
* as an API key by the SDK, so requests are billed against API credits
|
||||||
|
* instead of the subscription. Subscribers without API credits will see
|
||||||
|
* "Credit balance is too low" errors. The oauthToken field is wired up
|
||||||
|
* and will work as expected once the SDK fix lands upstream.
|
||||||
|
*
|
||||||
|
* Other tradeoffs:
|
||||||
|
* - Requires `claude` CLI installed on the host.
|
||||||
|
* - Higher latency (spawns a CLI process per request).
|
||||||
|
* - Designed for agent workflows, but used here in single-turn mode
|
||||||
|
* (maxTurns: 1, no tools) for plain text completion.
|
||||||
|
* - If you have an API key, prefer the direct ClaudeProvider for simpler,
|
||||||
|
* lower-latency access.
|
||||||
|
*
|
||||||
|
* @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 from Claude Code SDK. If you are a Pro/Max subscriber on macOS, this is likely the upstream @anthropic-ai/claude-agent-sdk Keychain bug — the SDK misclassifies the OAuth token as an API key. Workaround: use ClaudeProvider directly with an ANTHROPIC_API_KEY instead.'
|
||||||
|
},
|
||||||
|
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';
|
} from '../types/index.js';
|
||||||
import { BaseAIProvider } from './base.js';
|
import { BaseAIProvider } from './base.js';
|
||||||
import { AIProviderError, AIErrorType } from '../types/index.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
|
// TYPES AND INTERFACES
|
||||||
@@ -153,9 +160,8 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
constructor(config: ClaudeConfig) {
|
constructor(config: ClaudeConfig) {
|
||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
// Set Claude-specific defaults
|
this.defaultModel = config.defaultModel || DEFAULT_MODELS.claude;
|
||||||
this.defaultModel = config.defaultModel || 'claude-3-5-sonnet-20241022';
|
this.version = config.version || DEFAULT_ANTHROPIC_VERSION;
|
||||||
this.version = config.version || '2023-06-01';
|
|
||||||
|
|
||||||
// Validate model name format
|
// Validate model name format
|
||||||
this.validateModelName(this.defaultModel);
|
this.validateModelName(this.defaultModel);
|
||||||
@@ -178,7 +184,6 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
*/
|
*/
|
||||||
protected async doInitialize(): Promise<void> {
|
protected async doInitialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Create Anthropic client with optimized configuration
|
|
||||||
this.client = new Anthropic({
|
this.client = new Anthropic({
|
||||||
apiKey: this.config.apiKey,
|
apiKey: this.config.apiKey,
|
||||||
baseURL: this.config.baseUrl,
|
baseURL: this.config.baseUrl,
|
||||||
@@ -186,17 +191,14 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
maxRetries: this.config.maxRetries,
|
maxRetries: this.config.maxRetries,
|
||||||
defaultHeaders: {
|
defaultHeaders: {
|
||||||
'anthropic-version': this.version,
|
'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();
|
await this.validateConnection();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up on failure
|
|
||||||
this.client = null;
|
this.client = null;
|
||||||
|
|
||||||
throw new AIProviderError(
|
throw new AIProviderError(
|
||||||
`Failed to initialize Claude provider: ${(error as Error).message}`,
|
`Failed to initialize Claude provider: ${(error as Error).message}`,
|
||||||
AIErrorType.AUTHENTICATION,
|
AIErrorType.AUTHENTICATION,
|
||||||
@@ -225,22 +227,10 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { system, messages } = this.processMessages(params.messages);
|
||||||
// Process messages for Claude's format requirements
|
const requestParams = this.buildRequestParams(params, system, messages, false);
|
||||||
const { system, messages } = this.processMessages(params.messages);
|
const response = await this.client.messages.create(requestParams);
|
||||||
|
return this.formatCompletionResponse(response);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -262,22 +252,10 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { system, messages } = this.processMessages(params.messages);
|
||||||
// Process messages for Claude's format requirements
|
const requestParams = this.buildRequestParams(params, system, messages, true);
|
||||||
const { system, messages } = this.processMessages(params.messages);
|
const stream = await this.client.messages.create(requestParams);
|
||||||
|
yield* this.processStreamChunks(stream);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -317,76 +295,62 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
// PRIVATE UTILITY METHODS
|
// PRIVATE UTILITY METHODS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/**
|
protected override getModelNamePatterns(): RegExp[] {
|
||||||
* Validates the connection by making a minimal test request.
|
return [
|
||||||
*
|
/^claude-3(?:-5)?-(?:opus|sonnet|haiku)-\d{8}$/,
|
||||||
* @private
|
/^claude-instant-[0-9.]+$/,
|
||||||
* @throws {AIProviderError} If connection validation fails
|
/^claude-[0-9.]+$/
|
||||||
*/
|
];
|
||||||
private async validateConnection(): Promise<void> {
|
}
|
||||||
|
|
||||||
|
protected override async sendValidationProbe(): Promise<void> {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new Error('Client not initialized');
|
throw new Error('Client not initialized');
|
||||||
}
|
}
|
||||||
|
await this.client.messages.create({
|
||||||
try {
|
model: this.defaultModel,
|
||||||
// Make minimal request to test connection and permissions
|
max_tokens: 1,
|
||||||
await this.client.messages.create({
|
messages: [{ role: 'user', content: VALIDATION_PROMPT }]
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected override providerErrorMessages(): Partial<Record<number, string>> {
|
||||||
* Validates model name format for Claude models.
|
return {
|
||||||
*
|
401: 'Authentication failed. Please check your Anthropic API key from https://console.anthropic.com/',
|
||||||
* @private
|
403: 'Access forbidden. Your API key may not have permission for this model or feature.',
|
||||||
* @param modelName - Model name to validate
|
404: 'Model not found. The specified Claude model may not be available to your account.',
|
||||||
* @throws {AIProviderError} If model name format is invalid
|
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.',
|
||||||
private validateModelName(modelName: string): void {
|
502: 'Anthropic service temporarily unavailable. Please try again in a few moments.',
|
||||||
if (!modelName || typeof modelName !== 'string') {
|
503: 'Anthropic service temporarily unavailable. Please try again in a few moments.'
|
||||||
throw new AIProviderError(
|
};
|
||||||
'Model name must be a non-empty string',
|
}
|
||||||
AIErrorType.INVALID_REQUEST
|
|
||||||
);
|
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
|
return null;
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -449,8 +413,8 @@ export class ClaudeProvider extends BaseAIProvider {
|
|||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
model: params.model || this.defaultModel,
|
model: params.model || this.defaultModel,
|
||||||
max_tokens: params.maxTokens || 1000,
|
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
|
||||||
temperature: params.temperature ?? 0.7,
|
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
|
||||||
top_p: params.topP,
|
top_p: params.topP,
|
||||||
stop_sequences: params.stopSequences,
|
stop_sequences: params.stopSequences,
|
||||||
system: system || undefined,
|
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';
|
} from '../types/index.js';
|
||||||
import { BaseAIProvider } from './base.js';
|
import { BaseAIProvider } from './base.js';
|
||||||
import { AIProviderError, AIErrorType } from '../types/index.js';
|
import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_MAX_TOKENS,
|
||||||
|
DEFAULT_MODELS,
|
||||||
|
DEFAULT_TEMPERATURE,
|
||||||
|
VALIDATION_PROMPT
|
||||||
|
} from '../constants.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES AND INTERFACES
|
// TYPES AND INTERFACES
|
||||||
@@ -95,7 +101,7 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
constructor(config: GeminiConfig) {
|
constructor(config: GeminiConfig) {
|
||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
this.defaultModel = config.defaultModel || 'gemini-2.5-flash';
|
this.defaultModel = config.defaultModel || DEFAULT_MODELS.gemini;
|
||||||
this.safetySettings = config.safetySettings;
|
this.safetySettings = config.safetySettings;
|
||||||
this.defaultGenerationConfig = config.generationConfig;
|
this.defaultGenerationConfig = config.generationConfig;
|
||||||
|
|
||||||
@@ -128,20 +134,16 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
||||||
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
const model = params.model || this.defaultModel;
|
||||||
const model = params.model || this.defaultModel;
|
|
||||||
|
|
||||||
const response = await this.client.models.generateContent({
|
const response = await this.client.models.generateContent({
|
||||||
model,
|
model,
|
||||||
contents,
|
contents,
|
||||||
config: this.buildConfig(params, systemInstruction)
|
config: this.buildConfig(params, systemInstruction)
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.formatCompletionResponse(response, model);
|
return this.formatCompletionResponse(response, model);
|
||||||
} catch (error) {
|
|
||||||
throw this.handleGeminiError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async *doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> {
|
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);
|
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
||||||
const { systemInstruction, contents } = this.convertMessages(params.messages);
|
const model = params.model || this.defaultModel;
|
||||||
const model = params.model || this.defaultModel;
|
|
||||||
|
|
||||||
const stream = await this.client.models.generateContentStream({
|
const stream = await this.client.models.generateContentStream({
|
||||||
model,
|
model,
|
||||||
contents,
|
contents,
|
||||||
config: this.buildConfig(params, systemInstruction)
|
config: this.buildConfig(params, systemInstruction)
|
||||||
});
|
});
|
||||||
|
|
||||||
yield* this.processStreamChunks(stream);
|
yield* this.processStreamChunks(stream);
|
||||||
} catch (error) {
|
|
||||||
throw this.handleGeminiError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -199,55 +197,8 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
// PRIVATE UTILITY METHODS
|
// PRIVATE UTILITY METHODS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
private async validateConnection(): Promise<void> {
|
protected override getModelNamePatterns(): RegExp[] {
|
||||||
if (!this.client) {
|
return [
|
||||||
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 = [
|
|
||||||
/^gemini-2\.5-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
/^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-2\.0-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||||
/^gemini-1\.5-pro(?:-latest|-vision)?$/,
|
/^gemini-1\.5-pro(?:-latest|-vision)?$/,
|
||||||
@@ -256,12 +207,72 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
/^gemini-pro(?:-vision)?$/,
|
/^gemini-pro(?:-vision)?$/,
|
||||||
/^models\/gemini-.+$/
|
/^models\/gemini-.+$/
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const isValid = validPatterns.some(pattern => pattern.test(modelName));
|
protected override async sendValidationProbe(): Promise<void> {
|
||||||
|
if (!this.client) {
|
||||||
if (!isValid) {
|
throw new Error('Client not initialized');
|
||||||
console.warn(`Model name '${modelName}' doesn't match expected Gemini naming patterns. This may cause API errors.`);
|
|
||||||
}
|
}
|
||||||
|
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 {
|
private convertMessages(messages: AIMessage[]): ProcessedMessages {
|
||||||
@@ -291,8 +302,8 @@ export class GeminiProvider extends BaseAIProvider {
|
|||||||
const defaults = this.defaultGenerationConfig;
|
const defaults = this.defaultGenerationConfig;
|
||||||
|
|
||||||
const config: GenerateContentConfig = {
|
const config: GenerateContentConfig = {
|
||||||
temperature: params.temperature ?? defaults?.temperature ?? 0.7,
|
temperature: params.temperature ?? defaults?.temperature ?? DEFAULT_TEMPERATURE,
|
||||||
maxOutputTokens: params.maxTokens ?? defaults?.maxOutputTokens ?? 1000
|
maxOutputTokens: params.maxTokens ?? defaults?.maxOutputTokens ?? DEFAULT_MAX_TOKENS
|
||||||
};
|
};
|
||||||
|
|
||||||
const topP = params.topP ?? defaults?.topP;
|
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 { ClaudeProvider, type ClaudeConfig } from './claude.js';
|
||||||
export { OpenAIProvider, type OpenAIConfig } from './openai.js';
|
export { OpenAIProvider, type OpenAIConfig } from './openai.js';
|
||||||
export { GeminiProvider, type GeminiConfig } from './gemini.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';
|
} from '../types/index.js';
|
||||||
import { BaseAIProvider } from './base.js';
|
import { BaseAIProvider } from './base.js';
|
||||||
import { AIProviderError, AIErrorType } from '../types/index.js';
|
import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_MAX_TOKENS,
|
||||||
|
DEFAULT_MODELS,
|
||||||
|
DEFAULT_TEMPERATURE,
|
||||||
|
VALIDATION_PROMPT
|
||||||
|
} from '../constants.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES AND INTERFACES
|
// TYPES AND INTERFACES
|
||||||
@@ -173,7 +179,7 @@ export class OpenAIProvider extends BaseAIProvider {
|
|||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
// Set OpenAI-specific defaults
|
// Set OpenAI-specific defaults
|
||||||
this.defaultModel = config.defaultModel || 'gpt-4o';
|
this.defaultModel = config.defaultModel || DEFAULT_MODELS.openai;
|
||||||
this.organization = config.organization;
|
this.organization = config.organization;
|
||||||
this.project = config.project;
|
this.project = config.project;
|
||||||
|
|
||||||
@@ -249,19 +255,9 @@ export class OpenAIProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const requestParams = this.buildRequestParams(params, false);
|
||||||
// Build optimized request parameters
|
const response = await this.client.chat.completions.create(requestParams) as OpenAI.Chat.Completions.ChatCompletion;
|
||||||
const requestParams = this.buildRequestParams(params, false);
|
return this.formatCompletionResponse(response);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -283,19 +279,9 @@ export class OpenAIProvider extends BaseAIProvider {
|
|||||||
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
|
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const requestParams = this.buildRequestParams(params, true);
|
||||||
// Build streaming request parameters
|
const stream = this.client.chat.completions.create(requestParams);
|
||||||
const requestParams = this.buildRequestParams(params, true);
|
yield* this.processStreamChunks(stream);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -340,87 +326,82 @@ export class OpenAIProvider extends BaseAIProvider {
|
|||||||
// PRIVATE UTILITY METHODS
|
// PRIVATE UTILITY METHODS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/**
|
protected override getModelNamePatterns(): RegExp[] {
|
||||||
* Validates the connection by making a minimal test request.
|
return [
|
||||||
*
|
/^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||||
* @private
|
/^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/,
|
||||||
* @throws {AIProviderError} If connection validation fails
|
/^gpt-3\.5-turbo(?:-\d{4})?$/,
|
||||||
*/
|
/^gpt-4-\d{4}-preview$/,
|
||||||
private async validateConnection(): Promise<void> {
|
/^text-davinci-\d{3}$/,
|
||||||
|
/^ft:.+$/
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async sendValidationProbe(): Promise<void> {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new Error('Client not initialized');
|
throw new Error('Client not initialized');
|
||||||
}
|
}
|
||||||
|
await this.client.chat.completions.create({
|
||||||
try {
|
model: this.defaultModel,
|
||||||
// Make minimal request to test connection and permissions
|
messages: [{ role: 'user', content: VALIDATION_PROMPT }],
|
||||||
await this.client.chat.completions.create({
|
max_tokens: 1
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected override providerErrorMessages(): Partial<Record<number, string>> {
|
||||||
* Validates model name format for OpenAI models.
|
return {
|
||||||
*
|
401: 'Authentication failed. Please verify your OpenAI API key from https://platform.openai.com/api-keys',
|
||||||
* @private
|
403: 'Access forbidden. Your API key may not have permission for this model or feature.',
|
||||||
* @param modelName - Model name to validate
|
404: 'Model not found. The specified OpenAI model may not exist or be available to your account.',
|
||||||
* @throws {AIProviderError} If model name format is invalid
|
429: 'Too many requests. Please slow down and try again.',
|
||||||
*/
|
500: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
|
||||||
private validateModelName(modelName: string): void {
|
502: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
|
||||||
if (!modelName || typeof modelName !== 'string') {
|
503: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
|
||||||
throw new AIProviderError(
|
504: 'OpenAI service temporarily unavailable. Please try again in a few moments.'
|
||||||
'Model name must be a non-empty string',
|
};
|
||||||
AIErrorType.INVALID_REQUEST
|
}
|
||||||
|
|
||||||
|
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
|
if (status === 429 && message.includes('quota')) {
|
||||||
const validPatterns = [
|
return new AIProviderError(
|
||||||
/^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/, // e.g., gpt-4o, gpt-4o-mini, gpt-4o-2024-11-20
|
'Usage quota exceeded. Please check your OpenAI usage limits and billing.',
|
||||||
/^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/, // e.g., gpt-4, gpt-4-turbo, gpt-4-turbo-2024-04-09
|
AIErrorType.RATE_LIMIT, status, error
|
||||||
/^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.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -435,8 +416,8 @@ export class OpenAIProvider extends BaseAIProvider {
|
|||||||
return {
|
return {
|
||||||
model: params.model || this.defaultModel,
|
model: params.model || this.defaultModel,
|
||||||
messages: this.convertMessages(params.messages),
|
messages: this.convertMessages(params.messages),
|
||||||
max_tokens: params.maxTokens || 1000,
|
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
|
||||||
temperature: params.temperature ?? 0.7,
|
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
|
||||||
top_p: params.topP,
|
top_p: params.topP,
|
||||||
stop: params.stopSequences,
|
stop: params.stopSequences,
|
||||||
stream,
|
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
|
* Factory utilities for creating AI providers.
|
||||||
* Provides convenient methods for instantiating and configuring providers
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AIProviderConfig } from '../types/index.js';
|
|
||||||
import { ClaudeProvider, type ClaudeConfig } from '../providers/claude.js';
|
import { ClaudeProvider, type ClaudeConfig } from '../providers/claude.js';
|
||||||
import { OpenAIProvider, type OpenAIConfig } from '../providers/openai.js';
|
import { OpenAIProvider, type OpenAIConfig } from '../providers/openai.js';
|
||||||
import { GeminiProvider, type GeminiConfig } from '../providers/gemini.js';
|
import { GeminiProvider, type GeminiConfig } from '../providers/gemini.js';
|
||||||
import { OpenWebUIProvider, type OpenWebUIConfig } from '../providers/openwebui.js';
|
import { OpenWebUIProvider, type OpenWebUIConfig } from '../providers/openwebui.js';
|
||||||
|
import { ClaudeCodeProvider, type ClaudeCodeConfig } from '../providers/claude-code.js';
|
||||||
import { BaseAIProvider } from '../providers/base.js';
|
import { BaseAIProvider } from '../providers/base.js';
|
||||||
|
|
||||||
/**
|
export const PROVIDER_REGISTRY = {
|
||||||
* Supported AI provider types
|
claude: ClaudeProvider,
|
||||||
*/
|
openai: OpenAIProvider,
|
||||||
export type ProviderType = 'claude' | 'openai' | 'gemini' | 'openwebui';
|
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 {
|
export interface ProviderConfigMap {
|
||||||
claude: ClaudeConfig;
|
claude: ClaudeConfig;
|
||||||
openai: OpenAIConfig;
|
openai: OpenAIConfig;
|
||||||
gemini: GeminiConfig;
|
gemini: GeminiConfig;
|
||||||
openwebui: OpenWebUIConfig;
|
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>(
|
export function createProvider<T extends ProviderType>(
|
||||||
type: T,
|
type: T,
|
||||||
config: ProviderConfigMap[T]
|
config: ProviderConfigMap[T]
|
||||||
): BaseAIProvider {
|
): BaseAIProvider {
|
||||||
switch (type) {
|
const ProviderClass = PROVIDER_REGISTRY[type];
|
||||||
case 'claude':
|
if (!ProviderClass) {
|
||||||
return new ClaudeProvider(config as ClaudeConfig);
|
throw new Error(`Unsupported provider type: ${type}`);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
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(
|
export function createClaudeProvider(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options: Partial<Omit<ClaudeConfig, 'apiKey'>> = {}
|
options: Partial<Omit<ClaudeConfig, 'apiKey'>> = {}
|
||||||
): ClaudeProvider {
|
): ClaudeProvider {
|
||||||
return new ClaudeProvider({
|
return new ClaudeProvider({ apiKey, ...options });
|
||||||
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(
|
export function createOpenAIProvider(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options: Partial<Omit<OpenAIConfig, 'apiKey'>> = {}
|
options: Partial<Omit<OpenAIConfig, 'apiKey'>> = {}
|
||||||
): OpenAIProvider {
|
): OpenAIProvider {
|
||||||
return new OpenAIProvider({
|
return new OpenAIProvider({ apiKey, ...options });
|
||||||
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(
|
export function createGeminiProvider(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options: Partial<Omit<GeminiConfig, 'apiKey'>> = {}
|
options: Partial<Omit<GeminiConfig, 'apiKey'>> = {}
|
||||||
): GeminiProvider {
|
): GeminiProvider {
|
||||||
return new GeminiProvider({
|
return new GeminiProvider({ apiKey, ...options });
|
||||||
apiKey,
|
|
||||||
...options
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an OpenWebUI provider instance
|
|
||||||
*/
|
|
||||||
export function createOpenWebUIProvider(config: OpenWebUIConfig): OpenWebUIProvider {
|
export function createOpenWebUIProvider(config: OpenWebUIConfig): OpenWebUIProvider {
|
||||||
return new OpenWebUIProvider(config);
|
return new OpenWebUIProvider(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function createClaudeCodeProvider(
|
||||||
* Provider registry for dynamic provider creation
|
options: Partial<ClaudeCodeConfig> = {}
|
||||||
*/
|
): ClaudeCodeProvider {
|
||||||
export class ProviderRegistry {
|
return new ClaudeCodeProvider(options);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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' }],
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
temperature: 1.5
|
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 () => {
|
it('should validate message format', async () => {
|
||||||
@@ -78,19 +78,20 @@ describe('ClaudeProvider', () => {
|
|||||||
it('should handle authentication errors', () => {
|
it('should handle authentication errors', () => {
|
||||||
const error = new Error('Unauthorized');
|
const error = new Error('Unauthorized');
|
||||||
(error as any).status = 401;
|
(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).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||||
|
expect(providerError.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle rate limit errors', () => {
|
it('should handle rate limit errors', () => {
|
||||||
const error = new Error('Rate limited');
|
const error = new Error('Rate limited');
|
||||||
(error as any).status = 429;
|
(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).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||||
});
|
});
|
||||||
@@ -98,12 +99,22 @@ describe('ClaudeProvider', () => {
|
|||||||
it('should handle model not found errors', () => {
|
it('should handle model not found errors', () => {
|
||||||
const error = new Error('Model not found');
|
const error = new Error('Model not found');
|
||||||
(error as any).status = 404;
|
(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).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
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', () => {
|
describe('message conversion', () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('GeminiProvider', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
provider = new GeminiProvider({
|
provider = new GeminiProvider({
|
||||||
apiKey: 'test-api-key',
|
apiKey: 'test-api-key',
|
||||||
defaultModel: 'gemini-1.5-flash'
|
defaultModel: 'gemini-2.5-flash'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,13 +52,13 @@ describe('GeminiProvider', () => {
|
|||||||
describe('getInfo', () => {
|
describe('getInfo', () => {
|
||||||
it('should return provider information', () => {
|
it('should return provider information', () => {
|
||||||
const info = provider.getInfo();
|
const info = provider.getInfo();
|
||||||
|
|
||||||
expect(info.name).toBe('Gemini');
|
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.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.5-pro');
|
||||||
expect(info.models).toContain('gemini-1.0-pro');
|
|
||||||
expect(info.maxContextLength).toBe(1048576);
|
expect(info.maxContextLength).toBe(1048576);
|
||||||
expect(info.capabilities).toHaveProperty('vision', true);
|
expect(info.capabilities).toHaveProperty('vision', true);
|
||||||
expect(info.capabilities).toHaveProperty('functionCalling', true);
|
expect(info.capabilities).toHaveProperty('functionCalling', true);
|
||||||
@@ -79,7 +79,7 @@ describe('GeminiProvider', () => {
|
|||||||
messages: [{ role: 'user', content: 'test' }],
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
temperature: 1.5
|
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 () => {
|
it('should validate top_p range', async () => {
|
||||||
@@ -92,7 +92,7 @@ describe('GeminiProvider', () => {
|
|||||||
messages: [{ role: 'user', content: 'test' }],
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
topP: 1.5
|
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 () => {
|
it('should validate message format', async () => {
|
||||||
@@ -129,11 +129,13 @@ describe('GeminiProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
|
const mapError = (err: any) => (provider as any).normalizeError(err);
|
||||||
|
|
||||||
it('should handle authentication errors', () => {
|
it('should handle authentication errors', () => {
|
||||||
const error = new Error('API key invalid');
|
const error = new Error('API key invalid');
|
||||||
|
|
||||||
const providerError = (provider as any).handleGeminiError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||||
expect(providerError.message).toContain('Invalid Google API key');
|
expect(providerError.message).toContain('Invalid Google API key');
|
||||||
@@ -141,9 +143,9 @@ describe('GeminiProvider', () => {
|
|||||||
|
|
||||||
it('should handle rate limit errors', () => {
|
it('should handle rate limit errors', () => {
|
||||||
const error = new Error('quota exceeded');
|
const error = new Error('quota exceeded');
|
||||||
|
|
||||||
const providerError = (provider as any).handleGeminiError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||||
expect(providerError.message).toContain('API quota exceeded');
|
expect(providerError.message).toContain('API quota exceeded');
|
||||||
@@ -151,9 +153,9 @@ describe('GeminiProvider', () => {
|
|||||||
|
|
||||||
it('should handle model not found errors', () => {
|
it('should handle model not found errors', () => {
|
||||||
const error = new Error('model not found');
|
const error = new Error('model not found');
|
||||||
|
|
||||||
const providerError = (provider as any).handleGeminiError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||||
expect(providerError.message).toContain('Model not found');
|
expect(providerError.message).toContain('Model not found');
|
||||||
@@ -161,36 +163,36 @@ describe('GeminiProvider', () => {
|
|||||||
|
|
||||||
it('should handle invalid request errors', () => {
|
it('should handle invalid request errors', () => {
|
||||||
const error = new Error('invalid length request parameters');
|
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).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle network errors', () => {
|
it('should handle network errors', () => {
|
||||||
const error = new Error('network connection failed');
|
const error = new Error('network connection failed');
|
||||||
|
|
||||||
const providerError = (provider as any).handleGeminiError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.NETWORK);
|
expect(providerError.type).toBe(AIErrorType.NETWORK);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle timeout errors', () => {
|
it('should handle timeout errors', () => {
|
||||||
const error = new Error('request timeout');
|
const error = new Error('request timeout');
|
||||||
|
|
||||||
const providerError = (provider as any).handleGeminiError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.TIMEOUT);
|
expect(providerError.type).toBe(AIErrorType.TIMEOUT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle unknown errors', () => {
|
it('should handle unknown errors', () => {
|
||||||
const error = new Error('Unknown error');
|
const error = new Error('Unknown error');
|
||||||
|
|
||||||
const providerError = (provider as any).handleGeminiError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.UNKNOWN);
|
expect(providerError.type).toBe(AIErrorType.UNKNOWN);
|
||||||
});
|
});
|
||||||
@@ -266,7 +268,7 @@ describe('GeminiProvider', () => {
|
|||||||
stopSequences: ['STOP', 'END']
|
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.temperature).toBe(0.8);
|
||||||
expect(result.topP).toBe(0.9);
|
expect(result.topP).toBe(0.9);
|
||||||
@@ -279,20 +281,29 @@ describe('GeminiProvider', () => {
|
|||||||
messages: [{ role: 'user' as const, content: 'test' }]
|
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.temperature).toBe(0.7);
|
||||||
expect(result.maxOutputTokens).toBe(1000);
|
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', () => {
|
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', () => {
|
it('should format completion response correctly', () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
|
text: 'Hello there!',
|
||||||
candidates: [{
|
candidates: [{
|
||||||
content: {
|
|
||||||
parts: [{ text: 'Hello there!' }]
|
|
||||||
},
|
|
||||||
finishReason: 'STOP',
|
finishReason: 'STOP',
|
||||||
safetyRatings: [],
|
safetyRatings: [],
|
||||||
citationMetadata: null
|
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.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.promptTokens).toBe(10);
|
||||||
expect(result.usage.completionTokens).toBe(20);
|
expect(result.usage.completionTokens).toBe(20);
|
||||||
expect(result.usage.totalTokens).toBe(30);
|
expect(result.usage.totalTokens).toBe(30);
|
||||||
expect(result.metadata.finishReason).toBe('STOP');
|
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', () => {
|
it('should throw error for empty response', () => {
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
|
text: '',
|
||||||
candidates: [],
|
candidates: [],
|
||||||
usageMetadata: {
|
usageMetadata: {
|
||||||
promptTokenCount: 5,
|
promptTokenCount: 5,
|
||||||
@@ -349,8 +337,8 @@ describe('GeminiProvider', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
(provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash');
|
(provider as any).formatCompletionResponse(mockResponse, 'gemini-2.5-flash');
|
||||||
}).toThrow('No candidates found in Gemini response');
|
}).toThrow('No text content found in Gemini response');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ describe('OpenAIProvider', () => {
|
|||||||
messages: [{ role: 'user', content: 'test' }],
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
temperature: 1.5
|
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 () => {
|
it('should validate top_p range', async () => {
|
||||||
@@ -85,7 +85,7 @@ describe('OpenAIProvider', () => {
|
|||||||
messages: [{ role: 'user', content: 'test' }],
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
topP: 1.5
|
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 () => {
|
it('should validate message format', async () => {
|
||||||
@@ -120,23 +120,25 @@ describe('OpenAIProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
|
const mapError = (err: any) => (provider as any).normalizeError(err);
|
||||||
|
|
||||||
it('should handle authentication errors', () => {
|
it('should handle authentication errors', () => {
|
||||||
const error = new Error('Unauthorized');
|
const error = new Error('Unauthorized');
|
||||||
(error as any).status = 401;
|
(error as any).status = 401;
|
||||||
|
|
||||||
const providerError = (provider as any).handleOpenAIError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
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', () => {
|
it('should handle rate limit errors', () => {
|
||||||
const error = new Error('Rate limited');
|
const error = new Error('Rate limited');
|
||||||
(error as any).status = 429;
|
(error as any).status = 429;
|
||||||
|
|
||||||
const providerError = (provider as any).handleOpenAIError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||||
expect(providerError.message).toContain('Too many requests');
|
expect(providerError.message).toContain('Too many requests');
|
||||||
@@ -145,9 +147,9 @@ describe('OpenAIProvider', () => {
|
|||||||
it('should handle model not found errors', () => {
|
it('should handle model not found errors', () => {
|
||||||
const error = new Error('Model not found');
|
const error = new Error('Model not found');
|
||||||
(error as any).status = 404;
|
(error as any).status = 404;
|
||||||
|
|
||||||
const providerError = (provider as any).handleOpenAIError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||||
expect(providerError.message).toContain('Model not found');
|
expect(providerError.message).toContain('Model not found');
|
||||||
@@ -156,9 +158,9 @@ describe('OpenAIProvider', () => {
|
|||||||
it('should handle invalid request errors', () => {
|
it('should handle invalid request errors', () => {
|
||||||
const error = new Error('Bad request');
|
const error = new Error('Bad request');
|
||||||
(error as any).status = 400;
|
(error as any).status = 400;
|
||||||
|
|
||||||
const providerError = (provider as any).handleOpenAIError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||||
});
|
});
|
||||||
@@ -166,21 +168,31 @@ describe('OpenAIProvider', () => {
|
|||||||
it('should handle server errors', () => {
|
it('should handle server errors', () => {
|
||||||
const error = new Error('Internal server error');
|
const error = new Error('Internal server error');
|
||||||
(error as any).status = 500;
|
(error as any).status = 500;
|
||||||
|
|
||||||
const providerError = (provider as any).handleOpenAIError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.NETWORK);
|
expect(providerError.type).toBe(AIErrorType.NETWORK);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle unknown errors', () => {
|
it('should handle unknown errors', () => {
|
||||||
const error = new Error('Unknown error');
|
const error = new Error('Unknown error');
|
||||||
|
|
||||||
const providerError = (provider as any).handleOpenAIError(error);
|
const providerError = mapError(error);
|
||||||
|
|
||||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||||
expect(providerError.type).toBe(AIErrorType.UNKNOWN);
|
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', () => {
|
describe('message conversion', () => {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for OpenWebUI provider implementation
|
* 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';
|
import { describe, it, expect, beforeEach, jest } from 'bun:test';
|
||||||
@@ -14,7 +20,7 @@ describe('OpenWebUIProvider', () => {
|
|||||||
mockConfig = {
|
mockConfig = {
|
||||||
apiKey: 'test-bearer-token',
|
apiKey: 'test-bearer-token',
|
||||||
baseUrl: 'http://localhost:3000',
|
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', () => {
|
it('should use default values when not specified', () => {
|
||||||
const minimalConfig: OpenWebUIConfig = { apiKey: 'test' };
|
provider = new OpenWebUIProvider({ apiKey: 'test' });
|
||||||
provider = new OpenWebUIProvider(minimalConfig);
|
|
||||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle custom configuration', () => {
|
it('should accept Ollama proxy mode', () => {
|
||||||
const config: OpenWebUIConfig = {
|
provider = new OpenWebUIProvider({
|
||||||
apiKey: 'test',
|
apiKey: 'test',
|
||||||
baseUrl: 'http://localhost:8080',
|
baseUrl: 'http://localhost:8080',
|
||||||
defaultModel: 'mistral:7b',
|
defaultModel: 'mistral:7b',
|
||||||
useOllamaProxy: true
|
useOllamaProxy: true
|
||||||
};
|
});
|
||||||
provider = new OpenWebUIProvider(config);
|
|
||||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle trailing slash in baseUrl', () => {
|
it('should normalize trailing slash in baseUrl', () => {
|
||||||
const config: OpenWebUIConfig = {
|
provider = new OpenWebUIProvider({
|
||||||
apiKey: 'test',
|
apiKey: 'test',
|
||||||
baseUrl: 'http://localhost:3000/',
|
baseUrl: 'http://localhost:3000/'
|
||||||
defaultModel: 'llama3.1'
|
});
|
||||||
};
|
|
||||||
provider = new OpenWebUIProvider(config);
|
|
||||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should reject malformed baseUrl', () => {
|
||||||
|
expect(() => new OpenWebUIProvider({ apiKey: 'test', baseUrl: 'not a url' }))
|
||||||
|
.toThrow(AIProviderError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getInfo', () => {
|
describe('getInfo', () => {
|
||||||
@@ -60,9 +67,9 @@ describe('OpenWebUIProvider', () => {
|
|||||||
|
|
||||||
it('should return correct provider information', () => {
|
it('should return correct provider information', () => {
|
||||||
const info = provider.getInfo();
|
const info = provider.getInfo();
|
||||||
|
|
||||||
expect(info.name).toBe('OpenWebUI');
|
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.supportsStreaming).toBe(true);
|
||||||
expect(info.maxContextLength).toBe(32768);
|
expect(info.maxContextLength).toBe(32768);
|
||||||
expect(Array.isArray(info.models)).toBe(true);
|
expect(Array.isArray(info.models)).toBe(true);
|
||||||
@@ -70,345 +77,120 @@ describe('OpenWebUIProvider', () => {
|
|||||||
|
|
||||||
it('should include expected models', () => {
|
it('should include expected models', () => {
|
||||||
const info = provider.getInfo();
|
const info = provider.getInfo();
|
||||||
|
|
||||||
expect(info.models).toContain('llama3.1:latest');
|
expect(info.models).toContain('llama3.1:latest');
|
||||||
expect(info.models).toContain('mistral:latest');
|
expect(info.models).toContain('mistral:latest');
|
||||||
expect(info.models).toContain('codellama:latest');
|
expect(info.models).toContain('codellama:latest');
|
||||||
expect(info.models).toContain('gemma:latest');
|
expect(info.models).toContain('gemma:latest');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct capabilities', () => {
|
it('should expose capabilities', () => {
|
||||||
const info = provider.getInfo();
|
const info = provider.getInfo();
|
||||||
|
|
||||||
expect(info.capabilities).toEqual({
|
expect(info.capabilities).toMatchObject({
|
||||||
vision: false,
|
vision: false,
|
||||||
functionCalling: false,
|
functionCalling: false,
|
||||||
jsonMode: false,
|
|
||||||
systemMessages: true,
|
systemMessages: true,
|
||||||
reasoning: true,
|
localDeployment: true
|
||||||
codeGeneration: true,
|
|
||||||
localDeployment: true,
|
|
||||||
multiModel: true
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Error Handling', () => {
|
describe('Error mapping', () => {
|
||||||
|
const mapError = (err: any) => (provider as any).normalizeError(err);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
provider = new OpenWebUIProvider(mockConfig);
|
provider = new OpenWebUIProvider(mockConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle connection refused errors', () => {
|
it('should map ECONNREFUSED to NETWORK', () => {
|
||||||
const mockError = new Error('connect ECONNREFUSED') as any;
|
const error = new Error('connect ECONNREFUSED') as any;
|
||||||
mockError.code = 'ECONNREFUSED';
|
|
||||||
|
const aiError = mapError(error);
|
||||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
|
||||||
|
|
||||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||||
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
||||||
expect(aiError.message).toContain('Network error connecting to OpenWebUI');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle hostname resolution errors', () => {
|
it('should map ENOTFOUND to NETWORK', () => {
|
||||||
const mockError = new Error('getaddrinfo ENOTFOUND') as any;
|
const error = new Error('getaddrinfo ENOTFOUND') as any;
|
||||||
mockError.code = 'ENOTFOUND';
|
|
||||||
|
const aiError = mapError(error);
|
||||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
|
||||||
|
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
||||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
|
||||||
expect(aiError.type).toBe(AIErrorType.UNKNOWN);
|
|
||||||
expect(aiError.message).toContain('OpenWebUI error');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle 404 model not found errors', () => {
|
it('should map 404 with "model" in message to MODEL_NOT_FOUND', () => {
|
||||||
const mockError = new Error('model not available') as any;
|
const error = new Error('model not available') as any;
|
||||||
mockError.status = 404;
|
error.status = 404;
|
||||||
mockError.message = 'model not available';
|
|
||||||
|
const aiError = mapError(error);
|
||||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
|
||||||
|
|
||||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
|
||||||
expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||||
|
expect(aiError.message).toContain('OpenWebUI');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle 404 endpoint not found errors', () => {
|
it('should map 404 without "model" in message to MODEL_NOT_FOUND with endpoint message', () => {
|
||||||
const mockError = new Error('Not found') as any;
|
const error = new Error('Not found') as any;
|
||||||
mockError.status = 404;
|
error.status = 404;
|
||||||
mockError.message = 'endpoint not found';
|
|
||||||
|
const aiError = mapError(error);
|
||||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
|
||||||
|
expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
expect(aiError.message).toContain('endpoint');
|
||||||
expect(aiError.type).toBe(AIErrorType.INVALID_REQUEST);
|
|
||||||
expect(aiError.message).toContain('OpenWebUI endpoint not found');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle timeout errors', () => {
|
it('should map timeout errors to TIMEOUT', () => {
|
||||||
const mockError = new Error('Request timeout') as any;
|
const error = new Error('Request timeout') as any;
|
||||||
mockError.code = 'ETIMEDOUT';
|
|
||||||
|
const aiError = mapError(error);
|
||||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
|
||||||
|
|
||||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
|
||||||
expect(aiError.type).toBe(AIErrorType.TIMEOUT);
|
expect(aiError.type).toBe(AIErrorType.TIMEOUT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle rate limit errors', () => {
|
it('should map 429 to RATE_LIMIT', () => {
|
||||||
const mockError = new Error('Rate limit exceeded') as any;
|
const error = new Error('Rate limit exceeded') as any;
|
||||||
mockError.status = 429;
|
error.status = 429;
|
||||||
|
|
||||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
const aiError = mapError(error);
|
||||||
|
|
||||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
|
||||||
expect(aiError.type).toBe(AIErrorType.RATE_LIMIT);
|
expect(aiError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle server errors', () => {
|
it('should map 500 to NETWORK', () => {
|
||||||
const mockError = new Error('Internal server error') as any;
|
const error = new Error('Internal server error') as any;
|
||||||
mockError.status = 500;
|
error.status = 500;
|
||||||
|
|
||||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
const aiError = mapError(error);
|
||||||
|
|
||||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
|
||||||
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle authentication errors', () => {
|
it('should map 401 to AUTHENTICATION with OpenWebUI hint', () => {
|
||||||
const mockError = new Error('Unauthorized') as any;
|
const error = new Error('Unauthorized') as any;
|
||||||
mockError.status = 401;
|
error.status = 401;
|
||||||
|
|
||||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
const aiError = mapError(error);
|
||||||
|
|
||||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
|
||||||
expect(aiError.type).toBe(AIErrorType.AUTHENTICATION);
|
expect(aiError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||||
expect(aiError.message).toContain('Authentication failed with OpenWebUI');
|
expect(aiError.message).toContain('OpenWebUI');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle unknown errors', () => {
|
it('should map unrecognized errors to UNKNOWN', () => {
|
||||||
const mockError = new Error('Unknown error');
|
const error = new Error('Unknown error');
|
||||||
|
|
||||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
const aiError = mapError(error);
|
||||||
|
|
||||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
|
||||||
expect(aiError.type).toBe(AIErrorType.UNKNOWN);
|
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', () => {
|
describe('Validation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
provider = new OpenWebUIProvider(mockConfig);
|
provider = new OpenWebUIProvider(mockConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should validate provider is initialized before use', async () => {
|
it('should require initialization before use', async () => {
|
||||||
const params: CompletionParams = {
|
const params: CompletionParams = {
|
||||||
messages: [{ role: 'user', content: 'Hello' }]
|
messages: [{ role: 'user', content: 'Hello' }]
|
||||||
};
|
};
|
||||||
@@ -417,31 +199,31 @@ describe('OpenWebUIProvider', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('structured responses', () => {
|
describe('Structured responses', () => {
|
||||||
it('should handle structured responses correctly', async () => {
|
it('should parse JSON content into typed responses', async () => {
|
||||||
const provider = new OpenWebUIProvider({ apiKey: 'test-key' });
|
const p = new OpenWebUIProvider({ apiKey: 'test-key' });
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
content: JSON.stringify({ name: 'John Doe', age: 30 }),
|
content: JSON.stringify({ name: 'John Doe', age: 30 }),
|
||||||
model: 'llama3.1',
|
model: 'llama3.1:latest',
|
||||||
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
|
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
|
||||||
id: 'test-id'
|
id: 'test-id'
|
||||||
};
|
};
|
||||||
(provider as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
|
(p as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
|
||||||
(provider as any).initialized = true;
|
(p as any).initialized = true;
|
||||||
|
|
||||||
const responseType = createResponseType<{ name: string; age: number }>
|
const responseType = createResponseType<{ name: string; age: number }>(
|
||||||
(`{ name: string; age: number }`,
|
`{ name: string; age: number }`,
|
||||||
'A user profile'
|
'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' }],
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
responseType
|
responseType
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.content).toEqual({ name: 'John Doe', age: 30 });
|
expect(response.content).toEqual({ name: 'John Doe', age: 30 });
|
||||||
expect(response.rawContent).toBe(JSON.stringify({ 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