Compare commits
5 Commits
93ef2611c0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
b99707c629
|
|||
|
b935209142
|
|||
|
d4871d22fe
|
|||
|
9cb7ee8f9e
|
|||
|
a0d181787c
|
31
README.md
31
README.md
@@ -52,27 +52,46 @@ const claude = new ClaudeProvider({
|
||||
});
|
||||
```
|
||||
|
||||
### Claude Code (subscription via local CLI)
|
||||
### Claude Code (subscription or API key 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. It supports two billing modes:
|
||||
|
||||
**Setup:** install the CLI and run `claude login` once. No API key required.
|
||||
**Mode A — Claude Pro/Max subscription** (billed against subscription credits):
|
||||
|
||||
```bash
|
||||
# One-time: install the CLI, then mint a long-lived OAuth token.
|
||||
# `claude login` alone is NOT enough — Anthropic gates SDK use behind setup-token.
|
||||
claude setup-token
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { ClaudeCodeProvider } from 'simple-ai-provider';
|
||||
|
||||
const claude = new ClaudeCodeProvider({}); // uses local credentials
|
||||
await claude.initialize();
|
||||
const claude = new ClaudeCodeProvider({
|
||||
oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN // from `claude setup-token`
|
||||
});
|
||||
|
||||
await claude.initialize();
|
||||
const response = await claude.complete({
|
||||
messages: [{ role: 'user', content: 'Hello!' }]
|
||||
});
|
||||
```
|
||||
|
||||
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):
|
||||
|
||||
```typescript
|
||||
const claude = new ClaudeCodeProvider({
|
||||
apiKey: process.env.ANTHROPIC_API_KEY
|
||||
});
|
||||
```
|
||||
|
||||
If both are present, `oauthToken` wins. Either field can also be picked up from the environment (`CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY`) without being passed explicitly.
|
||||
|
||||
Optional config:
|
||||
|
||||
```typescript
|
||||
new ClaudeCodeProvider({
|
||||
oauthToken: '...',
|
||||
defaultModel: 'sonnet', // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID
|
||||
maxTurns: 1, // 1 for plain completion; raise for agent/tool loops
|
||||
allowedTools: [], // tool names to enable (default: none)
|
||||
|
||||
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';
|
||||
|
||||
@@ -31,12 +31,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 +90,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 +99,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 +116,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 +143,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 +354,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 +362,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. For Claude Pro/Max subscribers using the SDK: run `claude setup-token` and pass the resulting token as `oauthToken` (interactive `claude login` alone is not sufficient for non-interactive SDK calls).'
|
||||
},
|
||||
rate_limit: {
|
||||
type: AIErrorType.RATE_LIMIT,
|
||||
|
||||
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 () => {
|
||||
@@ -79,17 +79,18 @@ describe('ClaudeProvider', () => {
|
||||
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);
|
||||
@@ -99,11 +100,21 @@ describe('ClaudeProvider', () => {
|
||||
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'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,11 +54,11 @@ describe('GeminiProvider', () => {
|
||||
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,10 +129,12 @@ 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);
|
||||
@@ -142,7 +144,7 @@ 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);
|
||||
@@ -152,7 +154,7 @@ 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);
|
||||
@@ -162,7 +164,7 @@ 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);
|
||||
@@ -171,7 +173,7 @@ describe('GeminiProvider', () => {
|
||||
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);
|
||||
@@ -180,7 +182,7 @@ describe('GeminiProvider', () => {
|
||||
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);
|
||||
@@ -189,7 +191,7 @@ describe('GeminiProvider', () => {
|
||||
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,22 +120,24 @@ 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);
|
||||
@@ -146,7 +148,7 @@ describe('OpenAIProvider', () => {
|
||||
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);
|
||||
@@ -157,7 +159,7 @@ describe('OpenAIProvider', () => {
|
||||
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);
|
||||
@@ -167,7 +169,7 @@ describe('OpenAIProvider', () => {
|
||||
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);
|
||||
@@ -176,11 +178,21 @@ describe('OpenAIProvider', () => {
|
||||
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', () => {
|
||||
@@ -62,7 +69,7 @@ describe('OpenWebUIProvider', () => {
|
||||
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);
|
||||
@@ -77,338 +84,113 @@ describe('OpenWebUIProvider', () => {
|
||||
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';
|
||||
it('should map ECONNREFUSED to NETWORK', () => {
|
||||
const error = new Error('connect ECONNREFUSED') as any;
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
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';
|
||||
it('should map ENOTFOUND to NETWORK', () => {
|
||||
const error = new Error('getaddrinfo ENOTFOUND') as any;
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
expect(aiError.type).toBe(AIErrorType.UNKNOWN);
|
||||
expect(aiError.message).toContain('OpenWebUI 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';
|
||||
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 = (provider as any).handleOpenWebUIError(mockError);
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
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';
|
||||
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 = (provider as any).handleOpenWebUIError(mockError);
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
expect(aiError.type).toBe(AIErrorType.INVALID_REQUEST);
|
||||
expect(aiError.message).toContain('OpenWebUI endpoint not found');
|
||||
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';
|
||||
it('should map timeout errors to TIMEOUT', () => {
|
||||
const error = new Error('Request timeout') as any;
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
expect(aiError.type).toBe(AIErrorType.TIMEOUT);
|
||||
});
|
||||
|
||||
it('should handle rate limit errors', () => {
|
||||
const mockError = new Error('Rate limit exceeded') as any;
|
||||
mockError.status = 429;
|
||||
it('should map 429 to RATE_LIMIT', () => {
|
||||
const error = new Error('Rate limit exceeded') as any;
|
||||
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);
|
||||
});
|
||||
|
||||
it('should handle server errors', () => {
|
||||
const mockError = new Error('Internal server error') as any;
|
||||
mockError.status = 500;
|
||||
it('should map 500 to NETWORK', () => {
|
||||
const error = new Error('Internal server error') as any;
|
||||
error.status = 500;
|
||||
|
||||
const aiError = (provider as any).handleOpenWebUIError(mockError);
|
||||
const aiError = mapError(error);
|
||||
|
||||
expect(aiError).toBeInstanceOf(AIProviderError);
|
||||
expect(aiError.type).toBe(AIErrorType.NETWORK);
|
||||
});
|
||||
|
||||
it('should handle authentication errors', () => {
|
||||
const mockError = new Error('Unauthorized') as any;
|
||||
mockError.status = 401;
|
||||
it('should map 401 to AUTHENTICATION with OpenWebUI hint', () => {
|
||||
const error = new Error('Unauthorized') as any;
|
||||
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.message).toContain('Authentication failed with OpenWebUI');
|
||||
expect(aiError.message).toContain('OpenWebUI');
|
||||
});
|
||||
|
||||
it('should handle unknown errors', () => {
|
||||
const mockError = new Error('Unknown error');
|
||||
it('should map unrecognized errors to UNKNOWN', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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