Compare commits
6 Commits
93ef2611c0
...
docs/claud
| Author | SHA1 | Date | |
|---|---|---|---|
|
956ca21f54
|
|||
|
b99707c629
|
|||
|
b935209142
|
|||
|
d4871d22fe
|
|||
|
9cb7ee8f9e
|
|||
|
a0d181787c
|
39
README.md
39
README.md
@@ -52,27 +52,45 @@ const claude = new ClaudeProvider({
|
||||
});
|
||||
```
|
||||
|
||||
### Claude Code (subscription via local CLI)
|
||||
### Claude Code (via local CLI)
|
||||
|
||||
`ClaudeCodeProvider` wraps `@anthropic-ai/claude-agent-sdk`, which authenticates through the local `claude` CLI. This is the supported path for **Claude Pro / Max subscribers** who don't have a console API key.
|
||||
`ClaudeCodeProvider` wraps `@anthropic-ai/claude-agent-sdk`, which spawns the local `claude` CLI under the hood.
|
||||
|
||||
**Setup:** install the CLI and run `claude login` once. No API key required.
|
||||
> **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.
|
||||
|
||||
**Mode A — Claude Pro/Max subscription** *(see status note above)*:
|
||||
|
||||
```bash
|
||||
# One-time: install the CLI, then mint a long-lived OAuth token.
|
||||
# `claude login` alone is NOT enough — Anthropic gates SDK use behind setup-token.
|
||||
claude setup-token
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { ClaudeCodeProvider } from 'simple-ai-provider';
|
||||
|
||||
const claude = new ClaudeCodeProvider({}); // uses local credentials
|
||||
await claude.initialize();
|
||||
|
||||
const response = await claude.complete({
|
||||
messages: [{ role: 'user', content: 'Hello!' }]
|
||||
const claude = new ClaudeCodeProvider({
|
||||
oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN // from `claude setup-token`
|
||||
});
|
||||
```
|
||||
|
||||
You can still pass `apiKey` to override (it's set as `ANTHROPIC_API_KEY` for the SDK). Optional config:
|
||||
**Mode B — Console API key** (billed per-token, works reliably):
|
||||
|
||||
```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)
|
||||
@@ -80,10 +98,11 @@ new ClaudeCodeProvider({
|
||||
});
|
||||
```
|
||||
|
||||
**Trade-offs to know:**
|
||||
**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
|
||||
|
||||
|
||||
61
examples/claude-code.ts
Normal file
61
examples/claude-code.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Quick smoke test for ClaudeCodeProvider.
|
||||
*
|
||||
* Prereqs (pick one):
|
||||
* - Claude Pro/Max subscription: run `claude setup-token`, then set
|
||||
* CLAUDE_CODE_OAUTH_TOKEN=<the token it prints> in your environment
|
||||
* (or pass it as `oauthToken` below).
|
||||
* - Console API key: set ANTHROPIC_API_KEY (or pass `apiKey`).
|
||||
*
|
||||
* `claude login` alone is NOT sufficient for SDK use — Anthropic gates
|
||||
* non-interactive (`claude -p`) invocations behind a separate token.
|
||||
*
|
||||
* Run: bun run examples/claude-code.ts
|
||||
*/
|
||||
|
||||
import { ClaudeCodeProvider } from '../src/index.js';
|
||||
|
||||
async function main() {
|
||||
const claude = new ClaudeCodeProvider({
|
||||
defaultModel: 'sonnet',
|
||||
oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||
});
|
||||
|
||||
console.log('Initializing…');
|
||||
await claude.initialize();
|
||||
console.log('Ready.\n');
|
||||
|
||||
// ── Non-streaming completion ───────────────────────────────────────────
|
||||
console.log('--- complete() ---');
|
||||
const response = await claude.complete({
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are concise. Reply in one sentence.' },
|
||||
{ role: 'user', content: 'What is the capital of Japan?' }
|
||||
],
|
||||
maxTokens: 100
|
||||
});
|
||||
|
||||
console.log(response.content);
|
||||
console.log(`tokens: ${response.usage.totalTokens} | cost: $${response.metadata?.costUsd ?? '?'}\n`);
|
||||
|
||||
// ── Streaming ──────────────────────────────────────────────────────────
|
||||
console.log('--- stream() ---');
|
||||
let totalTokens = 0;
|
||||
for await (const chunk of claude.stream({
|
||||
messages: [{ role: 'user', content: 'Count from 1 to 5, one per line.' }],
|
||||
maxTokens: 100
|
||||
})) {
|
||||
if (!chunk.isComplete) {
|
||||
process.stdout.write(chunk.content);
|
||||
} else {
|
||||
totalTokens = chunk.usage?.totalTokens ?? 0;
|
||||
}
|
||||
}
|
||||
console.log(`\ntokens: ${totalTokens}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Failed:', err.message);
|
||||
if (err.type) console.error('Type:', err.type);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "simple-ai-provider",
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.0",
|
||||
"description": "A simple and extensible AI provider package for easy integration of multiple AI services",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
|
||||
@@ -60,4 +60,4 @@ export const SUPPORTED_PROVIDERS = ['claude', 'openai', 'gemini', 'openwebui', '
|
||||
/**
|
||||
* Package version
|
||||
*/
|
||||
export const VERSION = '2.0.1';
|
||||
export const VERSION = '2.1.0';
|
||||
|
||||
@@ -2,17 +2,27 @@
|
||||
* Claude Code Provider
|
||||
*
|
||||
* Wraps `@anthropic-ai/claude-agent-sdk` so consumers can use a local
|
||||
* Claude Code installation — including Claude Pro/Max subscription
|
||||
* accounts authenticated via `claude login` — through the same
|
||||
* BaseAIProvider interface as the other providers.
|
||||
* Claude Code installation through the same BaseAIProvider interface
|
||||
* as the other providers.
|
||||
*
|
||||
* Tradeoffs vs the direct Anthropic API provider:
|
||||
* - Authenticates via the local CLI, so subscription users (no API key)
|
||||
* can use it.
|
||||
* - Requires `claude` to be installed and logged in on the host.
|
||||
* - Higher latency (shells out to a CLI process per request).
|
||||
* 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
|
||||
*/
|
||||
@@ -31,12 +41,31 @@ import { AIProviderError, AIErrorType } from '../types/index.js';
|
||||
import { DEFAULT_MODELS } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Configuration for the Claude Code provider. `apiKey` is optional —
|
||||
* when omitted the SDK falls back to ANTHROPIC_API_KEY in the
|
||||
* environment or the local subscription credentials managed by the
|
||||
* `claude` CLI.
|
||||
* 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;
|
||||
|
||||
/**
|
||||
@@ -71,6 +100,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
|
||||
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);
|
||||
@@ -79,6 +109,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
|
||||
this.cwd = config.cwd;
|
||||
this.allowedTools = config.allowedTools ?? [];
|
||||
this.maxTurns = config.maxTurns ?? 1;
|
||||
this.oauthToken = config.oauthToken;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,13 +126,21 @@ export class ClaudeCodeProvider extends BaseAIProvider {
|
||||
);
|
||||
}
|
||||
|
||||
if (config.apiKey !== undefined && (typeof config.apiKey !== 'string')) {
|
||||
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 ?? '',
|
||||
@@ -114,6 +153,9 @@ export class ClaudeCodeProvider extends BaseAIProvider {
|
||||
// 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;
|
||||
}
|
||||
@@ -322,7 +364,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
|
||||
const map: Record<string, { type: AIErrorType; message: string }> = {
|
||||
authentication_failed: {
|
||||
type: AIErrorType.AUTHENTICATION,
|
||||
message: 'Claude Code authentication failed. Run `claude login` or set ANTHROPIC_API_KEY.'
|
||||
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,
|
||||
@@ -330,7 +372,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
|
||||
},
|
||||
billing_error: {
|
||||
type: AIErrorType.AUTHENTICATION,
|
||||
message: 'Billing error. Check your Anthropic account billing or subscription status.'
|
||||
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,
|
||||
|
||||
178
tests/claude-code.test.ts
Normal file
178
tests/claude-code.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Tests for ClaudeCodeProvider
|
||||
*
|
||||
* The provider shells out to the local Claude Code CLI via
|
||||
* @anthropic-ai/claude-agent-sdk. Tests cover the public surface and the
|
||||
* provider-specific hooks; full integration is not exercised because it
|
||||
* would spawn a real CLI process.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
import { ClaudeCodeProvider } from '../src/providers/claude-code.js';
|
||||
import { AIProviderError, AIErrorType } from '../src/types/index.js';
|
||||
|
||||
describe('ClaudeCodeProvider', () => {
|
||||
let provider: ClaudeCodeProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new ClaudeCodeProvider({ apiKey: 'test-key' });
|
||||
});
|
||||
|
||||
describe('Constructor', () => {
|
||||
it('should accept empty config (subscription mode)', () => {
|
||||
const p = new ClaudeCodeProvider({});
|
||||
expect(p).toBeInstanceOf(ClaudeCodeProvider);
|
||||
});
|
||||
|
||||
it('should accept apiKey override', () => {
|
||||
const p = new ClaudeCodeProvider({ apiKey: 'sk-...' });
|
||||
expect(p).toBeInstanceOf(ClaudeCodeProvider);
|
||||
});
|
||||
|
||||
it('should accept custom defaults', () => {
|
||||
const p = new ClaudeCodeProvider({
|
||||
defaultModel: 'opus',
|
||||
maxTurns: 5,
|
||||
allowedTools: ['Read', 'Bash'],
|
||||
cwd: '/tmp/agent'
|
||||
});
|
||||
expect(p).toBeInstanceOf(ClaudeCodeProvider);
|
||||
});
|
||||
|
||||
it('should reject non-string apiKey', () => {
|
||||
expect(() => new ClaudeCodeProvider({ apiKey: 123 as any }))
|
||||
.toThrow(AIProviderError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInfo', () => {
|
||||
it('should advertise subscriptionAuth capability', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
expect(info.name).toBe('Claude Code');
|
||||
expect(info.supportsStreaming).toBe(true);
|
||||
expect(info.capabilities).toMatchObject({
|
||||
subscriptionAuth: true,
|
||||
requiresLocalCli: true,
|
||||
systemMessages: true
|
||||
});
|
||||
expect(info.models).toContain('sonnet');
|
||||
expect(info.models).toContain('opus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error mapping', () => {
|
||||
const mapError = (err: any) => (provider as any).mapProviderError(err);
|
||||
|
||||
it('should detect missing CLI', () => {
|
||||
const err = new Error('spawn claude ENOENT');
|
||||
|
||||
const mapped = mapError(err);
|
||||
|
||||
expect(mapped).toBeInstanceOf(AIProviderError);
|
||||
expect(mapped.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
expect(mapped.message).toContain('CLI');
|
||||
});
|
||||
|
||||
it('should detect missing authentication', () => {
|
||||
const err = new Error('not logged in');
|
||||
|
||||
const mapped = mapError(err);
|
||||
|
||||
expect(mapped).toBeInstanceOf(AIProviderError);
|
||||
expect(mapped.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
});
|
||||
|
||||
it('should return null for unrelated errors', () => {
|
||||
expect(mapError(new Error('something else'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SDK assistant error mapping', () => {
|
||||
const mapSdkError = (code: string) => (provider as any).errorForSdkAssistantError(code);
|
||||
|
||||
it('should map authentication_failed', () => {
|
||||
const err = mapSdkError('authentication_failed');
|
||||
expect(err.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
});
|
||||
|
||||
it('should map rate_limit', () => {
|
||||
const err = mapSdkError('rate_limit');
|
||||
expect(err.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
});
|
||||
|
||||
it('should map model_not_found', () => {
|
||||
const err = mapSdkError('model_not_found');
|
||||
expect(err.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
});
|
||||
|
||||
it('should map server_error to NETWORK', () => {
|
||||
const err = mapSdkError('server_error');
|
||||
expect(err.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should fall back to UNKNOWN for unrecognized codes', () => {
|
||||
const err = mapSdkError('weird_new_code');
|
||||
expect(err.type).toBe(AIErrorType.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prompt building', () => {
|
||||
const build = (messages: any[]) => (provider as any).buildPrompt(messages);
|
||||
|
||||
it('should pass single user turn through unchanged', () => {
|
||||
const result = build([{ role: 'user', content: 'Hello' }]);
|
||||
|
||||
expect(result.prompt).toBe('Hello');
|
||||
expect(result.systemPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should combine multiple system messages', () => {
|
||||
const result = build([
|
||||
{ role: 'system', content: 'Be brief' },
|
||||
{ role: 'system', content: 'Use British spelling' },
|
||||
{ role: 'user', content: 'Hi' }
|
||||
]);
|
||||
|
||||
expect(result.systemPrompt).toBe('Be brief\n\nUse British spelling');
|
||||
expect(result.prompt).toBe('Hi');
|
||||
});
|
||||
|
||||
it('should flatten multi-turn conversation with role labels', () => {
|
||||
const result = build([
|
||||
{ role: 'user', content: 'What is 2+2?' },
|
||||
{ role: 'assistant', content: '4' },
|
||||
{ role: 'user', content: 'Why?' }
|
||||
]);
|
||||
|
||||
expect(result.prompt).toContain('Human: What is 2+2?');
|
||||
expect(result.prompt).toContain('Assistant: 4');
|
||||
expect(result.prompt).toContain('Human: Why?');
|
||||
expect(result.prompt.endsWith('Assistant:')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty turn list', () => {
|
||||
expect(() => build([{ role: 'system', content: 'only system' }]))
|
||||
.toThrow(AIProviderError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
it('should require initialization before complete()', async () => {
|
||||
await expect(
|
||||
provider.complete({ messages: [{ role: 'user', content: 'test' }] })
|
||||
).rejects.toThrow('Provider must be initialized before use');
|
||||
});
|
||||
|
||||
it('should validate temperature range when initialized', async () => {
|
||||
(provider as any).initialized = true;
|
||||
|
||||
await expect(
|
||||
provider.complete({
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
temperature: 1.5
|
||||
})
|
||||
).rejects.toThrow(/temperature must be at most 1/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe('ClaudeProvider', () => {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
temperature: 1.5
|
||||
})
|
||||
).rejects.toThrow('Temperature must be a number between 0.0 and 1.0');
|
||||
).rejects.toThrow(/temperature must be at most 1/);
|
||||
});
|
||||
|
||||
it('should validate message format', async () => {
|
||||
@@ -78,19 +78,20 @@ describe('ClaudeProvider', () => {
|
||||
it('should handle authentication errors', () => {
|
||||
const error = new Error('Unauthorized');
|
||||
(error as any).status = 401;
|
||||
|
||||
const providerError = (provider as any).handleAnthropicError(error);
|
||||
|
||||
|
||||
const providerError = (provider as any).normalizeError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
expect(providerError.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should handle rate limit errors', () => {
|
||||
const error = new Error('Rate limited');
|
||||
(error as any).status = 429;
|
||||
|
||||
const providerError = (provider as any).handleAnthropicError(error);
|
||||
|
||||
|
||||
const providerError = (provider as any).normalizeError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
});
|
||||
@@ -98,12 +99,22 @@ describe('ClaudeProvider', () => {
|
||||
it('should handle model not found errors', () => {
|
||||
const error = new Error('Model not found');
|
||||
(error as any).status = 404;
|
||||
|
||||
const providerError = (provider as any).handleAnthropicError(error);
|
||||
|
||||
|
||||
const providerError = (provider as any).normalizeError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
});
|
||||
|
||||
it('should route 400 with max_tokens to mapProviderError', () => {
|
||||
const error = new Error('max_tokens too large');
|
||||
(error as any).status = 400;
|
||||
|
||||
const providerError = (provider as any).normalizeError(error);
|
||||
|
||||
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
expect(providerError.message).toMatch(/Max tokens/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message conversion', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('GeminiProvider', () => {
|
||||
beforeEach(() => {
|
||||
provider = new GeminiProvider({
|
||||
apiKey: 'test-api-key',
|
||||
defaultModel: 'gemini-1.5-flash'
|
||||
defaultModel: 'gemini-2.5-flash'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,13 +52,13 @@ describe('GeminiProvider', () => {
|
||||
describe('getInfo', () => {
|
||||
it('should return provider information', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
|
||||
expect(info.name).toBe('Gemini');
|
||||
expect(info.version).toBe('1.0.0');
|
||||
expect(info.version).toBe('2.0.0');
|
||||
expect(info.supportsStreaming).toBe(true);
|
||||
expect(info.models).toContain('gemini-1.5-flash');
|
||||
expect(info.models).toContain('gemini-2.5-flash');
|
||||
expect(info.models).toContain('gemini-2.5-pro');
|
||||
expect(info.models).toContain('gemini-1.5-pro');
|
||||
expect(info.models).toContain('gemini-1.0-pro');
|
||||
expect(info.maxContextLength).toBe(1048576);
|
||||
expect(info.capabilities).toHaveProperty('vision', true);
|
||||
expect(info.capabilities).toHaveProperty('functionCalling', true);
|
||||
@@ -79,7 +79,7 @@ describe('GeminiProvider', () => {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
temperature: 1.5
|
||||
})
|
||||
).rejects.toThrow('Temperature must be a number between 0.0 and 1.0');
|
||||
).rejects.toThrow(/temperature must be at most 1/);
|
||||
});
|
||||
|
||||
it('should validate top_p range', async () => {
|
||||
@@ -92,7 +92,7 @@ describe('GeminiProvider', () => {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
topP: 1.5
|
||||
})
|
||||
).rejects.toThrow('Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)');
|
||||
).rejects.toThrow(/topP must be at most 1/);
|
||||
});
|
||||
|
||||
it('should validate message format', async () => {
|
||||
@@ -129,11 +129,13 @@ describe('GeminiProvider', () => {
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
const mapError = (err: any) => (provider as any).normalizeError(err);
|
||||
|
||||
it('should handle authentication errors', () => {
|
||||
const error = new Error('API key invalid');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
expect(providerError.message).toContain('Invalid Google API key');
|
||||
@@ -141,9 +143,9 @@ describe('GeminiProvider', () => {
|
||||
|
||||
it('should handle rate limit errors', () => {
|
||||
const error = new Error('quota exceeded');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
expect(providerError.message).toContain('API quota exceeded');
|
||||
@@ -151,9 +153,9 @@ describe('GeminiProvider', () => {
|
||||
|
||||
it('should handle model not found errors', () => {
|
||||
const error = new Error('model not found');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
expect(providerError.message).toContain('Model not found');
|
||||
@@ -161,36 +163,36 @@ describe('GeminiProvider', () => {
|
||||
|
||||
it('should handle invalid request errors', () => {
|
||||
const error = new Error('invalid length request parameters');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
});
|
||||
|
||||
it('should handle network errors', () => {
|
||||
const error = new Error('network connection failed');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should handle timeout errors', () => {
|
||||
const error = new Error('request timeout');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.TIMEOUT);
|
||||
});
|
||||
|
||||
it('should handle unknown errors', () => {
|
||||
const error = new Error('Unknown error');
|
||||
|
||||
const providerError = (provider as any).handleGeminiError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.UNKNOWN);
|
||||
});
|
||||
@@ -266,7 +268,7 @@ describe('GeminiProvider', () => {
|
||||
stopSequences: ['STOP', 'END']
|
||||
};
|
||||
|
||||
const result = (provider as any).buildGenerationConfig(params);
|
||||
const result = (provider as any).buildConfig(params);
|
||||
|
||||
expect(result.temperature).toBe(0.8);
|
||||
expect(result.topP).toBe(0.9);
|
||||
@@ -279,20 +281,29 @@ describe('GeminiProvider', () => {
|
||||
messages: [{ role: 'user' as const, content: 'test' }]
|
||||
};
|
||||
|
||||
const result = (provider as any).buildGenerationConfig(params);
|
||||
const result = (provider as any).buildConfig(params);
|
||||
|
||||
expect(result.temperature).toBe(0.7);
|
||||
expect(result.maxOutputTokens).toBe(1000);
|
||||
});
|
||||
|
||||
it('should attach systemInstruction when passed', () => {
|
||||
const params = { messages: [{ role: 'user' as const, content: 'test' }] };
|
||||
|
||||
const result = (provider as any).buildConfig(params, 'be helpful');
|
||||
|
||||
expect(result.systemInstruction).toBe('be helpful');
|
||||
});
|
||||
});
|
||||
|
||||
describe('response formatting', () => {
|
||||
// The @google/genai SDK exposes `text` as a getter on GenerateContentResponse,
|
||||
// so the mock provides it directly rather than reconstructing the candidate->parts tree.
|
||||
|
||||
it('should format completion response correctly', () => {
|
||||
const mockResponse = {
|
||||
text: 'Hello there!',
|
||||
candidates: [{
|
||||
content: {
|
||||
parts: [{ text: 'Hello there!' }]
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
safetyRatings: [],
|
||||
citationMetadata: null
|
||||
@@ -304,42 +315,19 @@ describe('GeminiProvider', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = (provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash');
|
||||
const result = (provider as any).formatCompletionResponse(mockResponse, 'gemini-2.5-flash');
|
||||
|
||||
expect(result.content).toBe('Hello there!');
|
||||
expect(result.model).toBe('gemini-1.5-flash');
|
||||
expect(result.model).toBe('gemini-2.5-flash');
|
||||
expect(result.usage.promptTokens).toBe(10);
|
||||
expect(result.usage.completionTokens).toBe(20);
|
||||
expect(result.usage.totalTokens).toBe(30);
|
||||
expect(result.metadata.finishReason).toBe('STOP');
|
||||
});
|
||||
|
||||
it('should handle multiple text parts', () => {
|
||||
const mockResponse = {
|
||||
candidates: [{
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'Hello ' },
|
||||
{ text: 'there!' },
|
||||
{ functionCall: { name: 'test' } } // Non-text part should be filtered
|
||||
]
|
||||
},
|
||||
finishReason: 'STOP'
|
||||
}],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 10,
|
||||
totalTokenCount: 15
|
||||
}
|
||||
};
|
||||
|
||||
const result = (provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash');
|
||||
|
||||
expect(result.content).toBe('Hello there!');
|
||||
});
|
||||
|
||||
it('should throw error for empty response', () => {
|
||||
const mockResponse = {
|
||||
text: '',
|
||||
candidates: [],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 5,
|
||||
@@ -349,8 +337,8 @@ describe('GeminiProvider', () => {
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
(provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash');
|
||||
}).toThrow('No candidates found in Gemini response');
|
||||
(provider as any).formatCompletionResponse(mockResponse, 'gemini-2.5-flash');
|
||||
}).toThrow('No text content found in Gemini response');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('OpenAIProvider', () => {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
temperature: 1.5
|
||||
})
|
||||
).rejects.toThrow('Temperature must be a number between 0.0 and 1.0');
|
||||
).rejects.toThrow(/temperature must be at most 1/);
|
||||
});
|
||||
|
||||
it('should validate top_p range', async () => {
|
||||
@@ -85,7 +85,7 @@ describe('OpenAIProvider', () => {
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
topP: 1.5
|
||||
})
|
||||
).rejects.toThrow('Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)');
|
||||
).rejects.toThrow(/topP must be at most 1/);
|
||||
});
|
||||
|
||||
it('should validate message format', async () => {
|
||||
@@ -120,23 +120,25 @@ describe('OpenAIProvider', () => {
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
const mapError = (err: any) => (provider as any).normalizeError(err);
|
||||
|
||||
it('should handle authentication errors', () => {
|
||||
const error = new Error('Unauthorized');
|
||||
(error as any).status = 401;
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
expect(providerError.message).toContain('Authentication failed');
|
||||
expect(providerError.message).toContain('OpenAI API key');
|
||||
});
|
||||
|
||||
it('should handle rate limit errors', () => {
|
||||
const error = new Error('Rate limited');
|
||||
(error as any).status = 429;
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
expect(providerError.message).toContain('Too many requests');
|
||||
@@ -145,9 +147,9 @@ describe('OpenAIProvider', () => {
|
||||
it('should handle model not found errors', () => {
|
||||
const error = new Error('Model not found');
|
||||
(error as any).status = 404;
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
expect(providerError.message).toContain('Model not found');
|
||||
@@ -156,9 +158,9 @@ describe('OpenAIProvider', () => {
|
||||
it('should handle invalid request errors', () => {
|
||||
const error = new Error('Bad request');
|
||||
(error as any).status = 400;
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
});
|
||||
@@ -166,21 +168,31 @@ describe('OpenAIProvider', () => {
|
||||
it('should handle server errors', () => {
|
||||
const error = new Error('Internal server error');
|
||||
(error as any).status = 500;
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should handle unknown errors', () => {
|
||||
const error = new Error('Unknown error');
|
||||
|
||||
const providerError = (provider as any).handleOpenAIError(error);
|
||||
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError).toBeInstanceOf(AIProviderError);
|
||||
expect(providerError.type).toBe(AIErrorType.UNKNOWN);
|
||||
});
|
||||
|
||||
it('should route 429 with quota to quota-specific message', () => {
|
||||
const error = new Error('quota exceeded');
|
||||
(error as any).status = 429;
|
||||
|
||||
const providerError = mapError(error);
|
||||
|
||||
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
expect(providerError.message).toContain('Usage quota exceeded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message conversion', () => {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/**
|
||||
* Tests for OpenWebUI provider implementation
|
||||
*
|
||||
* The R3 refactor split message conversion, response formatting, and the
|
||||
* HTTP client into openwebui-strategies.ts and openwebui-http.ts. Tests for
|
||||
* those helpers should live alongside those files. This file covers the
|
||||
* provider's public surface and the error-mapping hooks it adds on top of
|
||||
* BaseAIProvider.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, jest } from 'bun:test';
|
||||
@@ -14,7 +20,7 @@ describe('OpenWebUIProvider', () => {
|
||||
mockConfig = {
|
||||
apiKey: 'test-bearer-token',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
defaultModel: 'llama3.1'
|
||||
defaultModel: 'llama3.1:latest'
|
||||
};
|
||||
});
|
||||
|
||||
@@ -26,31 +32,32 @@ describe('OpenWebUIProvider', () => {
|
||||
});
|
||||
|
||||
it('should use default values when not specified', () => {
|
||||
const minimalConfig: OpenWebUIConfig = { apiKey: 'test' };
|
||||
provider = new OpenWebUIProvider(minimalConfig);
|
||||
provider = new OpenWebUIProvider({ apiKey: 'test' });
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should handle custom configuration', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
it('should accept Ollama proxy mode', () => {
|
||||
provider = new OpenWebUIProvider({
|
||||
apiKey: 'test',
|
||||
baseUrl: 'http://localhost:8080',
|
||||
defaultModel: 'mistral:7b',
|
||||
useOllamaProxy: true
|
||||
};
|
||||
provider = new OpenWebUIProvider(config);
|
||||
});
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should handle trailing slash in baseUrl', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
it('should normalize trailing slash in baseUrl', () => {
|
||||
provider = new OpenWebUIProvider({
|
||||
apiKey: 'test',
|
||||
baseUrl: 'http://localhost:3000/',
|
||||
defaultModel: 'llama3.1'
|
||||
};
|
||||
provider = new OpenWebUIProvider(config);
|
||||
baseUrl: 'http://localhost:3000/'
|
||||
});
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should reject malformed baseUrl', () => {
|
||||
expect(() => new OpenWebUIProvider({ apiKey: 'test', baseUrl: 'not a url' }))
|
||||
.toThrow(AIProviderError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInfo', () => {
|
||||
@@ -60,9 +67,9 @@ describe('OpenWebUIProvider', () => {
|
||||
|
||||
it('should return correct provider information', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
|
||||
expect(info.name).toBe('OpenWebUI');
|
||||
expect(info.version).toBe('1.0.0');
|
||||
expect(info.version).toBe('2.0.0');
|
||||
expect(info.supportsStreaming).toBe(true);
|
||||
expect(info.maxContextLength).toBe(32768);
|
||||
expect(Array.isArray(info.models)).toBe(true);
|
||||
@@ -70,345 +77,120 @@ describe('OpenWebUIProvider', () => {
|
||||
|
||||
it('should include expected models', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
|
||||
expect(info.models).toContain('llama3.1:latest');
|
||||
expect(info.models).toContain('mistral:latest');
|
||||
expect(info.models).toContain('codellama:latest');
|
||||
expect(info.models).toContain('gemma:latest');
|
||||
});
|
||||
|
||||
it('should have correct capabilities', () => {
|
||||
it('should expose capabilities', () => {
|
||||
const info = provider.getInfo();
|
||||
|
||||
expect(info.capabilities).toEqual({
|
||||
|
||||
expect(info.capabilities).toMatchObject({
|
||||
vision: false,
|
||||
functionCalling: false,
|
||||
jsonMode: false,
|
||||
systemMessages: true,
|
||||
reasoning: true,
|
||||
codeGeneration: true,
|
||||
localDeployment: true,
|
||||
multiModel: true
|
||||
localDeployment: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
describe('Error mapping', () => {
|
||||
const mapError = (err: any) => (provider as any).normalizeError(err);
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new OpenWebUIProvider(mockConfig);
|
||||
});
|
||||
|
||||
it('should handle connection refused errors', () => {
|
||||
const mockError = new Error('connect ECONNREFUSED') as any;
|
||||
mockError.code = 'ECONNREFUSED';
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
it('should map ECONNREFUSED to NETWORK', () => {
|
||||
const error = new Error('connect ECONNREFUSED') as any;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
||||
expect(aiError.message).toContain('Network error connecting to OpenWebUI');
|
||||
});
|
||||
|
||||
it('should handle hostname resolution errors', () => {
|
||||
const mockError = new Error('getaddrinfo ENOTFOUND') as any;
|
||||
mockError.code = 'ENOTFOUND';
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
expect(aiError.type).toBe(AIErrorType.UNKNOWN);
|
||||
expect(aiError.message).toContain('OpenWebUI error');
|
||||
it('should map ENOTFOUND to NETWORK', () => {
|
||||
const error = new Error('getaddrinfo ENOTFOUND') as any;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should handle 404 model not found errors', () => {
|
||||
const mockError = new Error('model not available') as any;
|
||||
mockError.status = 404;
|
||||
mockError.message = 'model not available';
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map 404 with "model" in message to MODEL_NOT_FOUND', () => {
|
||||
const error = new Error('model not available') as any;
|
||||
error.status = 404;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
expect(aiError.message).toContain('OpenWebUI');
|
||||
});
|
||||
|
||||
it('should handle 404 endpoint not found errors', () => {
|
||||
const mockError = new Error('Not found') as any;
|
||||
mockError.status = 404;
|
||||
mockError.message = 'endpoint not found';
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
expect(aiError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
expect(aiError.message).toContain('OpenWebUI endpoint not found');
|
||||
it('should map 404 without "model" in message to MODEL_NOT_FOUND with endpoint message', () => {
|
||||
const error = new Error('Not found') as any;
|
||||
error.status = 404;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
|
||||
expect(aiError.message).toContain('endpoint');
|
||||
});
|
||||
|
||||
it('should handle timeout errors', () => {
|
||||
const mockError = new Error('Request timeout') as any;
|
||||
mockError.code = 'ETIMEDOUT';
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map timeout errors to TIMEOUT', () => {
|
||||
const error = new Error('Request timeout') as any;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.TIMEOUT);
|
||||
});
|
||||
|
||||
it('should handle rate limit errors', () => {
|
||||
const mockError = new Error('Rate limit exceeded') as any;
|
||||
mockError.status = 429;
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map 429 to RATE_LIMIT', () => {
|
||||
const error = new Error('Rate limit exceeded') as any;
|
||||
error.status = 429;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.RATE_LIMIT);
|
||||
});
|
||||
|
||||
it('should handle server errors', () => {
|
||||
const mockError = new Error('Internal server error') as any;
|
||||
mockError.status = 500;
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map 500 to NETWORK', () => {
|
||||
const error = new Error('Internal server error') as any;
|
||||
error.status = 500;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should handle authentication errors', () => {
|
||||
const mockError = new Error('Unauthorized') as any;
|
||||
mockError.status = 401;
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map 401 to AUTHENTICATION with OpenWebUI hint', () => {
|
||||
const error = new Error('Unauthorized') as any;
|
||||
error.status = 401;
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.AUTHENTICATION);
|
||||
expect(aiError.message).toContain('Authentication failed with OpenWebUI');
|
||||
expect(aiError.message).toContain('OpenWebUI');
|
||||
});
|
||||
|
||||
it('should handle unknown errors', () => {
|
||||
const mockError = new Error('Unknown error');
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
it('should map unrecognized errors to UNKNOWN', () => {
|
||||
const error = new Error('Unknown error');
|
||||
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError.type).toBe(AIErrorType.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Conversion', () => {
|
||||
beforeEach(() => {
|
||||
provider = new OpenWebUIProvider(mockConfig);
|
||||
});
|
||||
|
||||
it('should convert messages correctly for chat API', () => {
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: 'You are helpful' },
|
||||
{ role: 'user' as const, content: 'Hello' },
|
||||
{ role: 'assistant' as const, content: 'Hi there!' }
|
||||
];
|
||||
|
||||
const converted = (provider as any).convertMessages(messages);
|
||||
|
||||
expect(converted).toHaveLength(3);
|
||||
expect(converted[0]).toEqual({ role: 'system', content: 'You are helpful' });
|
||||
expect(converted[1]).toEqual({ role: 'user', content: 'Hello' });
|
||||
expect(converted[2]).toEqual({ role: 'assistant', content: 'Hi there!' });
|
||||
});
|
||||
|
||||
it('should convert messages to prompt for Ollama API', () => {
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: 'You are helpful' },
|
||||
{ role: 'user' as const, content: 'Hello' },
|
||||
{ role: 'assistant' as const, content: 'Hi there!' }
|
||||
];
|
||||
|
||||
const prompt = (provider as any).convertMessagesToPrompt(messages);
|
||||
|
||||
expect(prompt).toBe('System: You are helpful\n\nHuman: Hello\n\nAssistant: Hi there!\n\nAssistant: ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Formatting', () => {
|
||||
beforeEach(() => {
|
||||
provider = new OpenWebUIProvider(mockConfig);
|
||||
});
|
||||
|
||||
it('should format chat completion response correctly', () => {
|
||||
const mockResponse = {
|
||||
id: 'test-id',
|
||||
object: 'chat.completion',
|
||||
created: 1234567890,
|
||||
model: 'llama3.1',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Hello there!' },
|
||||
finish_reason: 'stop'
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15
|
||||
}
|
||||
};
|
||||
|
||||
const response = (provider as any).formatChatResponse(mockResponse);
|
||||
|
||||
expect(response.content).toBe('Hello there!');
|
||||
expect(response.model).toBe('llama3.1');
|
||||
expect(response.id).toBe('test-id');
|
||||
expect(response.usage.promptTokens).toBe(10);
|
||||
expect(response.usage.completionTokens).toBe(5);
|
||||
expect(response.usage.totalTokens).toBe(15);
|
||||
expect(response.metadata?.finishReason).toBe('stop');
|
||||
expect(response.metadata?.created).toBe(1234567890);
|
||||
});
|
||||
|
||||
it('should format Ollama response correctly', () => {
|
||||
const mockResponse = {
|
||||
model: 'llama3.1',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
response: 'Hello there!',
|
||||
done: true,
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
total_duration: 1000000,
|
||||
eval_duration: 500000
|
||||
};
|
||||
|
||||
const response = (provider as any).formatOllamaResponse(mockResponse);
|
||||
|
||||
expect(response.content).toBe('Hello there!');
|
||||
expect(response.model).toBe('llama3.1');
|
||||
expect(response.usage.promptTokens).toBe(10);
|
||||
expect(response.usage.completionTokens).toBe(5);
|
||||
expect(response.usage.totalTokens).toBe(15);
|
||||
expect(response.metadata?.created).toBe(1704067200000);
|
||||
expect(response.metadata?.totalDuration).toBe(1000000);
|
||||
});
|
||||
|
||||
it('should handle response without content', () => {
|
||||
const mockResponse = {
|
||||
id: 'test-id',
|
||||
object: 'chat.completion',
|
||||
created: 1234567890,
|
||||
model: 'llama3.1',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant' }, // Message without content
|
||||
finish_reason: 'stop'
|
||||
}],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 0, total_tokens: 5 }
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
(provider as any).formatChatResponse(mockResponse);
|
||||
}).toThrow(AIProviderError);
|
||||
});
|
||||
|
||||
it('should handle missing usage information', () => {
|
||||
const mockResponse = {
|
||||
id: 'test-id',
|
||||
object: 'chat.completion',
|
||||
created: 1234567890,
|
||||
model: 'llama3.1',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Hello there!' },
|
||||
finish_reason: 'stop'
|
||||
}]
|
||||
// No usage information
|
||||
};
|
||||
|
||||
const response = (provider as any).formatChatResponse(mockResponse);
|
||||
|
||||
expect(response.content).toBe('Hello there!');
|
||||
expect(response.usage.promptTokens).toBe(0);
|
||||
expect(response.usage.completionTokens).toBe(0);
|
||||
expect(response.usage.totalTokens).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Options', () => {
|
||||
it('should handle custom baseUrl', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
baseUrl: 'https://my-openwebui.com'
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should handle custom default model', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
defaultModel: 'mistral:7b'
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should handle Ollama proxy mode', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
useOllamaProxy: true
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should handle SSL verification settings', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
dangerouslyAllowInsecureConnections: false
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Generation', () => {
|
||||
beforeEach(() => {
|
||||
provider = new OpenWebUIProvider(mockConfig);
|
||||
});
|
||||
|
||||
it('should generate correct headers', () => {
|
||||
const headers = (provider as any).makeRequest('http://test.com', 'GET');
|
||||
// Note: This would need to be mocked in a real test environment
|
||||
expect(typeof headers).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Mode Selection', () => {
|
||||
it('should use chat API by default', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
useOllamaProxy: false
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
|
||||
it('should use Ollama proxy when configured', () => {
|
||||
const config: OpenWebUIConfig = {
|
||||
apiKey: 'test',
|
||||
useOllamaProxy: true
|
||||
};
|
||||
|
||||
provider = new OpenWebUIProvider(config);
|
||||
expect(provider).toBeInstanceOf(OpenWebUIProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
beforeEach(() => {
|
||||
provider = new OpenWebUIProvider(mockConfig);
|
||||
});
|
||||
|
||||
it('should validate provider is initialized before use', async () => {
|
||||
it('should require initialization before use', async () => {
|
||||
const params: CompletionParams = {
|
||||
messages: [{ role: 'user', content: 'Hello' }]
|
||||
};
|
||||
@@ -417,31 +199,31 @@ describe('OpenWebUIProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('structured responses', () => {
|
||||
it('should handle structured responses correctly', async () => {
|
||||
const provider = new OpenWebUIProvider({ apiKey: 'test-key' });
|
||||
describe('Structured responses', () => {
|
||||
it('should parse JSON content into typed responses', async () => {
|
||||
const p = new OpenWebUIProvider({ apiKey: 'test-key' });
|
||||
const mockResponse = {
|
||||
content: JSON.stringify({ name: 'John Doe', age: 30 }),
|
||||
model: 'llama3.1',
|
||||
model: 'llama3.1:latest',
|
||||
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
|
||||
id: 'test-id'
|
||||
};
|
||||
(provider as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
|
||||
(provider as any).initialized = true;
|
||||
(p as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
|
||||
(p as any).initialized = true;
|
||||
|
||||
const responseType = createResponseType<{ name: string; age: number }>
|
||||
(`{ name: string; age: number }`,
|
||||
const responseType = createResponseType<{ name: string; age: number }>(
|
||||
`{ name: string; age: number }`,
|
||||
'A user profile'
|
||||
);
|
||||
|
||||
const response = await provider.complete<{ name: string; age: number }>({
|
||||
const response = await p.complete<{ name: string; age: number }>({
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
responseType
|
||||
});
|
||||
|
||||
expect(response.content).toEqual({ name: 'John Doe', age: 30 });
|
||||
expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 }));
|
||||
expect((provider as any).doComplete).toHaveBeenCalled();
|
||||
expect((p as any).doComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user