5 Commits

Author SHA1 Message Date
b99707c629 Merge pull request 'fix(claude-code): add oauthToken config for subscription SDK auth' (#12) from fix/claude-code-oauth-token into main
Reviewed-on: #12
2026-05-21 12:56:28 +00:00
b935209142 fix(claude-code): add oauthToken config for subscription SDK auth
`claude login` alone does not authorize SDK / non-interactive
(`claude -p`) usage. Anthropic gates that behind a separate long-lived
token minted by `claude setup-token`. Without it, subscription users
hit `billing_error` from the SDK even though interactive Claude Code
works fine.

Changes:

- ClaudeCodeConfig.oauthToken: new optional field. When set, the
  provider exports it as CLAUDE_CODE_OAUTH_TOKEN before invoking the
  SDK, which makes the SDK bill against the user's Pro/Max
  subscription instead of API credits.
- apiKey continues to work for users with a console API key. If both
  are present, oauthToken takes precedence.
- billing_error / authentication_failed messages now point users at
  `claude setup-token` so the fix is obvious from the error alone.
- README: rewrite the Claude Code section to document both modes and
  the `claude setup-token` step explicitly.
- examples/claude-code.ts: read CLAUDE_CODE_OAUTH_TOKEN from env so
  the smoke test actually works for subscribers.
2026-05-21 14:56:14 +02:00
d4871d22fe chore(release): 2.1.0
Minor release adding the ClaudeCodeProvider for Claude Pro/Max
subscription users (PR #10) along with a rewritten README (PR #10)
and a repaired test suite (PR #11).

New public exports:
- ClaudeCodeProvider, ClaudeCodeConfig
- createClaudeCodeProvider
- 'claude-code' added to PROVIDER_REGISTRY and SUPPORTED_PROVIDERS

No breaking changes.
2026-05-21 14:39:36 +02:00
9cb7ee8f9e Merge pull request 'test: repair test suite after R1-R3 refactors and add Claude Code tests' (#11) from chore/test-suite-repair into main
Reviewed-on: #11
2026-05-21 12:38:05 +00:00
a0d181787c test: repair test suite after R1-R3 refactors and add Claude Code tests
The R1-R3 refactors removed the per-provider handle*Error methods,
consolidated validators into validateNumberInRange, renamed Gemini's
buildGenerationConfig to buildConfig, and split OpenWebUI's message
conversion and response formatting into openwebui-strategies.ts.
44 of 91 existing tests broke as a result.

Changes:

claude.test.ts (8 -> 13 passing)
- Switch error mapping tests from handleAnthropicError to the base
  normalizeError method.
- Update temperature error-message expectation to match the new
  validateNumberInRange wording.

openai.test.ts (16 -> 22 passing)
- Same handleOpenAIError -> normalizeError switch.
- Add a regression test for the 429+"quota" path that goes through
  mapProviderError instead of the base mapping.

gemini.test.ts (17 -> 27 passing)
- Update getInfo expectations to the new default model (gemini-2.5)
  and provider version 2.0.0.
- Switch to normalizeError.
- Rename buildGenerationConfig -> buildConfig (renamed during R2).
- Update formatCompletionResponse mocks: the @google/genai SDK
  exposes response.text as a getter rather than walking
  candidates[0].content.parts.

openwebui.test.ts (4 -> 19 passing)
- Drop tests for convertMessages / convertMessagesToPrompt /
  formatChatResponse / formatOllamaResponse / makeRequest: these
  moved into openwebui-strategies.ts and openwebui-http.ts during R3
  and warrant their own test files.
- Add error-mapping tests covering the new mapProviderError +
  providerErrorMessages plumbing.

claude-code.test.ts (new, 18 tests)
- Constructor accepts subscription mode (empty config).
- getInfo advertises subscriptionAuth + requiresLocalCli.
- mapProviderError catches ENOENT and "not logged in".
- errorForSdkAssistantError covers each documented SDK error code.
- buildPrompt handles single-turn pass-through, multi-system
  combination, multi-turn flattening with role labels, and rejects
  empty turn lists.

Full suite: 100 pass / 0 fail across 5 files.
2026-05-21 14:37:33 +02:00
10 changed files with 505 additions and 422 deletions

View File

@@ -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 ```typescript
import { ClaudeCodeProvider } from 'simple-ai-provider'; import { ClaudeCodeProvider } from 'simple-ai-provider';
const claude = new ClaudeCodeProvider({}); // uses local credentials const claude = new ClaudeCodeProvider({
await claude.initialize(); oauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN // from `claude setup-token`
});
await claude.initialize();
const response = await claude.complete({ const response = await claude.complete({
messages: [{ role: 'user', content: 'Hello!' }] 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 ```typescript
new ClaudeCodeProvider({ new ClaudeCodeProvider({
oauthToken: '...',
defaultModel: 'sonnet', // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID defaultModel: 'sonnet', // 'sonnet' | 'opus' | 'haiku' | 'inherit' | full model ID
maxTurns: 1, // 1 for plain completion; raise for agent/tool loops maxTurns: 1, // 1 for plain completion; raise for agent/tool loops
allowedTools: [], // tool names to enable (default: none) allowedTools: [], // tool names to enable (default: none)

61
examples/claude-code.ts Normal file
View 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);
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "simple-ai-provider", "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", "description": "A simple and extensible AI provider package for easy integration of multiple AI services",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.mjs", "module": "dist/index.mjs",

View File

@@ -60,4 +60,4 @@ export const SUPPORTED_PROVIDERS = ['claude', 'openai', 'gemini', 'openwebui', '
/** /**
* Package version * Package version
*/ */
export const VERSION = '2.0.1'; export const VERSION = '2.1.0';

View File

@@ -31,12 +31,31 @@ import { AIProviderError, AIErrorType } from '../types/index.js';
import { DEFAULT_MODELS } from '../constants.js'; import { DEFAULT_MODELS } from '../constants.js';
/** /**
* Configuration for the Claude Code provider. `apiKey` is optional — * Configuration for the Claude Code provider.
* when omitted the SDK falls back to ANTHROPIC_API_KEY in the *
* environment or the local subscription credentials managed by the * Authentication options, in priority order:
* `claude` CLI. *
* 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'> { 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; apiKey?: string;
/** /**
@@ -71,6 +90,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
private readonly cwd: string | undefined; private readonly cwd: string | undefined;
private readonly allowedTools: string[]; private readonly allowedTools: string[];
private readonly maxTurns: number; private readonly maxTurns: number;
private readonly oauthToken: string | undefined;
constructor(config: ClaudeCodeConfig) { constructor(config: ClaudeCodeConfig) {
super(config as AIProviderConfig); super(config as AIProviderConfig);
@@ -79,6 +99,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
this.cwd = config.cwd; this.cwd = config.cwd;
this.allowedTools = config.allowedTools ?? []; this.allowedTools = config.allowedTools ?? [];
this.maxTurns = config.maxTurns ?? 1; 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( throw new AIProviderError(
'API key, when provided, must be a string', 'API key, when provided, must be a string',
AIErrorType.INVALID_REQUEST 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 { return {
...config, ...config,
apiKey: config.apiKey ?? '', apiKey: config.apiKey ?? '',
@@ -114,6 +143,9 @@ export class ClaudeCodeProvider extends BaseAIProvider {
// The SDK lazy-spawns the CLI per query; no connection probe is // The SDK lazy-spawns the CLI per query; no connection probe is
// necessary (and a probe would cost a real billed turn). We defer // necessary (and a probe would cost a real billed turn). We defer
// auth/model errors to the first complete()/stream() call. // auth/model errors to the first complete()/stream() call.
if (this.oauthToken) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = this.oauthToken;
}
if (this.config.apiKey) { if (this.config.apiKey) {
process.env.ANTHROPIC_API_KEY = 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 }> = { const map: Record<string, { type: AIErrorType; message: string }> = {
authentication_failed: { authentication_failed: {
type: AIErrorType.AUTHENTICATION, 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: { oauth_org_not_allowed: {
type: AIErrorType.AUTHENTICATION, type: AIErrorType.AUTHENTICATION,
@@ -330,7 +362,7 @@ export class ClaudeCodeProvider extends BaseAIProvider {
}, },
billing_error: { billing_error: {
type: AIErrorType.AUTHENTICATION, 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: { rate_limit: {
type: AIErrorType.RATE_LIMIT, type: AIErrorType.RATE_LIMIT,

178
tests/claude-code.test.ts Normal file
View 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/);
});
});
});

View File

@@ -51,7 +51,7 @@ describe('ClaudeProvider', () => {
messages: [{ role: 'user', content: 'test' }], messages: [{ role: 'user', content: 'test' }],
temperature: 1.5 temperature: 1.5
}) })
).rejects.toThrow('Temperature must be a number between 0.0 and 1.0'); ).rejects.toThrow(/temperature must be at most 1/);
}); });
it('should validate message format', async () => { it('should validate message format', async () => {
@@ -79,17 +79,18 @@ describe('ClaudeProvider', () => {
const error = new Error('Unauthorized'); const error = new Error('Unauthorized');
(error as any).status = 401; (error as any).status = 401;
const providerError = (provider as any).handleAnthropicError(error); const providerError = (provider as any).normalizeError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION); expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
expect(providerError.statusCode).toBe(401);
}); });
it('should handle rate limit errors', () => { it('should handle rate limit errors', () => {
const error = new Error('Rate limited'); const error = new Error('Rate limited');
(error as any).status = 429; (error as any).status = 429;
const providerError = (provider as any).handleAnthropicError(error); const providerError = (provider as any).normalizeError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT); expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
@@ -99,11 +100,21 @@ describe('ClaudeProvider', () => {
const error = new Error('Model not found'); const error = new Error('Model not found');
(error as any).status = 404; (error as any).status = 404;
const providerError = (provider as any).handleAnthropicError(error); const providerError = (provider as any).normalizeError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND); expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
}); });
it('should route 400 with max_tokens to mapProviderError', () => {
const error = new Error('max_tokens too large');
(error as any).status = 400;
const providerError = (provider as any).normalizeError(error);
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
expect(providerError.message).toMatch(/Max tokens/);
});
}); });
describe('message conversion', () => { describe('message conversion', () => {

View File

@@ -11,7 +11,7 @@ describe('GeminiProvider', () => {
beforeEach(() => { beforeEach(() => {
provider = new GeminiProvider({ provider = new GeminiProvider({
apiKey: 'test-api-key', apiKey: 'test-api-key',
defaultModel: 'gemini-1.5-flash' defaultModel: 'gemini-2.5-flash'
}); });
}); });
@@ -54,11 +54,11 @@ describe('GeminiProvider', () => {
const info = provider.getInfo(); const info = provider.getInfo();
expect(info.name).toBe('Gemini'); expect(info.name).toBe('Gemini');
expect(info.version).toBe('1.0.0'); expect(info.version).toBe('2.0.0');
expect(info.supportsStreaming).toBe(true); expect(info.supportsStreaming).toBe(true);
expect(info.models).toContain('gemini-1.5-flash'); expect(info.models).toContain('gemini-2.5-flash');
expect(info.models).toContain('gemini-2.5-pro');
expect(info.models).toContain('gemini-1.5-pro'); expect(info.models).toContain('gemini-1.5-pro');
expect(info.models).toContain('gemini-1.0-pro');
expect(info.maxContextLength).toBe(1048576); expect(info.maxContextLength).toBe(1048576);
expect(info.capabilities).toHaveProperty('vision', true); expect(info.capabilities).toHaveProperty('vision', true);
expect(info.capabilities).toHaveProperty('functionCalling', true); expect(info.capabilities).toHaveProperty('functionCalling', true);
@@ -79,7 +79,7 @@ describe('GeminiProvider', () => {
messages: [{ role: 'user', content: 'test' }], messages: [{ role: 'user', content: 'test' }],
temperature: 1.5 temperature: 1.5
}) })
).rejects.toThrow('Temperature must be a number between 0.0 and 1.0'); ).rejects.toThrow(/temperature must be at most 1/);
}); });
it('should validate top_p range', async () => { it('should validate top_p range', async () => {
@@ -92,7 +92,7 @@ describe('GeminiProvider', () => {
messages: [{ role: 'user', content: 'test' }], messages: [{ role: 'user', content: 'test' }],
topP: 1.5 topP: 1.5
}) })
).rejects.toThrow('Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)'); ).rejects.toThrow(/topP must be at most 1/);
}); });
it('should validate message format', async () => { it('should validate message format', async () => {
@@ -129,10 +129,12 @@ describe('GeminiProvider', () => {
}); });
describe('error handling', () => { describe('error handling', () => {
const mapError = (err: any) => (provider as any).normalizeError(err);
it('should handle authentication errors', () => { it('should handle authentication errors', () => {
const error = new Error('API key invalid'); const error = new Error('API key invalid');
const providerError = (provider as any).handleGeminiError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION); expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
@@ -142,7 +144,7 @@ describe('GeminiProvider', () => {
it('should handle rate limit errors', () => { it('should handle rate limit errors', () => {
const error = new Error('quota exceeded'); const error = new Error('quota exceeded');
const providerError = (provider as any).handleGeminiError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT); expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
@@ -152,7 +154,7 @@ describe('GeminiProvider', () => {
it('should handle model not found errors', () => { it('should handle model not found errors', () => {
const error = new Error('model not found'); const error = new Error('model not found');
const providerError = (provider as any).handleGeminiError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND); expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
@@ -162,7 +164,7 @@ describe('GeminiProvider', () => {
it('should handle invalid request errors', () => { it('should handle invalid request errors', () => {
const error = new Error('invalid length request parameters'); const error = new Error('invalid length request parameters');
const providerError = (provider as any).handleGeminiError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST); expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
@@ -171,7 +173,7 @@ describe('GeminiProvider', () => {
it('should handle network errors', () => { it('should handle network errors', () => {
const error = new Error('network connection failed'); const error = new Error('network connection failed');
const providerError = (provider as any).handleGeminiError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.NETWORK); expect(providerError.type).toBe(AIErrorType.NETWORK);
@@ -180,7 +182,7 @@ describe('GeminiProvider', () => {
it('should handle timeout errors', () => { it('should handle timeout errors', () => {
const error = new Error('request timeout'); const error = new Error('request timeout');
const providerError = (provider as any).handleGeminiError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.TIMEOUT); expect(providerError.type).toBe(AIErrorType.TIMEOUT);
@@ -189,7 +191,7 @@ describe('GeminiProvider', () => {
it('should handle unknown errors', () => { it('should handle unknown errors', () => {
const error = new Error('Unknown error'); const error = new Error('Unknown error');
const providerError = (provider as any).handleGeminiError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.UNKNOWN); expect(providerError.type).toBe(AIErrorType.UNKNOWN);
@@ -266,7 +268,7 @@ describe('GeminiProvider', () => {
stopSequences: ['STOP', 'END'] stopSequences: ['STOP', 'END']
}; };
const result = (provider as any).buildGenerationConfig(params); const result = (provider as any).buildConfig(params);
expect(result.temperature).toBe(0.8); expect(result.temperature).toBe(0.8);
expect(result.topP).toBe(0.9); expect(result.topP).toBe(0.9);
@@ -279,20 +281,29 @@ describe('GeminiProvider', () => {
messages: [{ role: 'user' as const, content: 'test' }] messages: [{ role: 'user' as const, content: 'test' }]
}; };
const result = (provider as any).buildGenerationConfig(params); const result = (provider as any).buildConfig(params);
expect(result.temperature).toBe(0.7); expect(result.temperature).toBe(0.7);
expect(result.maxOutputTokens).toBe(1000); expect(result.maxOutputTokens).toBe(1000);
}); });
it('should attach systemInstruction when passed', () => {
const params = { messages: [{ role: 'user' as const, content: 'test' }] };
const result = (provider as any).buildConfig(params, 'be helpful');
expect(result.systemInstruction).toBe('be helpful');
});
}); });
describe('response formatting', () => { describe('response formatting', () => {
// The @google/genai SDK exposes `text` as a getter on GenerateContentResponse,
// so the mock provides it directly rather than reconstructing the candidate->parts tree.
it('should format completion response correctly', () => { it('should format completion response correctly', () => {
const mockResponse = { const mockResponse = {
text: 'Hello there!',
candidates: [{ candidates: [{
content: {
parts: [{ text: 'Hello there!' }]
},
finishReason: 'STOP', finishReason: 'STOP',
safetyRatings: [], safetyRatings: [],
citationMetadata: null citationMetadata: null
@@ -304,42 +315,19 @@ describe('GeminiProvider', () => {
} }
}; };
const result = (provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash'); const result = (provider as any).formatCompletionResponse(mockResponse, 'gemini-2.5-flash');
expect(result.content).toBe('Hello there!'); expect(result.content).toBe('Hello there!');
expect(result.model).toBe('gemini-1.5-flash'); expect(result.model).toBe('gemini-2.5-flash');
expect(result.usage.promptTokens).toBe(10); expect(result.usage.promptTokens).toBe(10);
expect(result.usage.completionTokens).toBe(20); expect(result.usage.completionTokens).toBe(20);
expect(result.usage.totalTokens).toBe(30); expect(result.usage.totalTokens).toBe(30);
expect(result.metadata.finishReason).toBe('STOP'); expect(result.metadata.finishReason).toBe('STOP');
}); });
it('should handle multiple text parts', () => {
const mockResponse = {
candidates: [{
content: {
parts: [
{ text: 'Hello ' },
{ text: 'there!' },
{ functionCall: { name: 'test' } } // Non-text part should be filtered
]
},
finishReason: 'STOP'
}],
usageMetadata: {
promptTokenCount: 5,
candidatesTokenCount: 10,
totalTokenCount: 15
}
};
const result = (provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash');
expect(result.content).toBe('Hello there!');
});
it('should throw error for empty response', () => { it('should throw error for empty response', () => {
const mockResponse = { const mockResponse = {
text: '',
candidates: [], candidates: [],
usageMetadata: { usageMetadata: {
promptTokenCount: 5, promptTokenCount: 5,
@@ -349,8 +337,8 @@ describe('GeminiProvider', () => {
}; };
expect(() => { expect(() => {
(provider as any).formatCompletionResponse(mockResponse, 'gemini-1.5-flash'); (provider as any).formatCompletionResponse(mockResponse, 'gemini-2.5-flash');
}).toThrow('No candidates found in Gemini response'); }).toThrow('No text content found in Gemini response');
}); });
}); });

View File

@@ -73,7 +73,7 @@ describe('OpenAIProvider', () => {
messages: [{ role: 'user', content: 'test' }], messages: [{ role: 'user', content: 'test' }],
temperature: 1.5 temperature: 1.5
}) })
).rejects.toThrow('Temperature must be a number between 0.0 and 1.0'); ).rejects.toThrow(/temperature must be at most 1/);
}); });
it('should validate top_p range', async () => { it('should validate top_p range', async () => {
@@ -85,7 +85,7 @@ describe('OpenAIProvider', () => {
messages: [{ role: 'user', content: 'test' }], messages: [{ role: 'user', content: 'test' }],
topP: 1.5 topP: 1.5
}) })
).rejects.toThrow('Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)'); ).rejects.toThrow(/topP must be at most 1/);
}); });
it('should validate message format', async () => { it('should validate message format', async () => {
@@ -120,22 +120,24 @@ describe('OpenAIProvider', () => {
}); });
describe('error handling', () => { describe('error handling', () => {
const mapError = (err: any) => (provider as any).normalizeError(err);
it('should handle authentication errors', () => { it('should handle authentication errors', () => {
const error = new Error('Unauthorized'); const error = new Error('Unauthorized');
(error as any).status = 401; (error as any).status = 401;
const providerError = (provider as any).handleOpenAIError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION); expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
expect(providerError.message).toContain('Authentication failed'); expect(providerError.message).toContain('OpenAI API key');
}); });
it('should handle rate limit errors', () => { it('should handle rate limit errors', () => {
const error = new Error('Rate limited'); const error = new Error('Rate limited');
(error as any).status = 429; (error as any).status = 429;
const providerError = (provider as any).handleOpenAIError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT); expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
@@ -146,7 +148,7 @@ describe('OpenAIProvider', () => {
const error = new Error('Model not found'); const error = new Error('Model not found');
(error as any).status = 404; (error as any).status = 404;
const providerError = (provider as any).handleOpenAIError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND); expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
@@ -157,7 +159,7 @@ describe('OpenAIProvider', () => {
const error = new Error('Bad request'); const error = new Error('Bad request');
(error as any).status = 400; (error as any).status = 400;
const providerError = (provider as any).handleOpenAIError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST); expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
@@ -167,7 +169,7 @@ describe('OpenAIProvider', () => {
const error = new Error('Internal server error'); const error = new Error('Internal server error');
(error as any).status = 500; (error as any).status = 500;
const providerError = (provider as any).handleOpenAIError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.NETWORK); expect(providerError.type).toBe(AIErrorType.NETWORK);
@@ -176,11 +178,21 @@ describe('OpenAIProvider', () => {
it('should handle unknown errors', () => { it('should handle unknown errors', () => {
const error = new Error('Unknown error'); const error = new Error('Unknown error');
const providerError = (provider as any).handleOpenAIError(error); const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError); expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.UNKNOWN); expect(providerError.type).toBe(AIErrorType.UNKNOWN);
}); });
it('should route 429 with quota to quota-specific message', () => {
const error = new Error('quota exceeded');
(error as any).status = 429;
const providerError = mapError(error);
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
expect(providerError.message).toContain('Usage quota exceeded');
});
}); });
describe('message conversion', () => { describe('message conversion', () => {

View File

@@ -1,5 +1,11 @@
/** /**
* Tests for OpenWebUI provider implementation * Tests for OpenWebUI provider implementation
*
* The R3 refactor split message conversion, response formatting, and the
* HTTP client into openwebui-strategies.ts and openwebui-http.ts. Tests for
* those helpers should live alongside those files. This file covers the
* provider's public surface and the error-mapping hooks it adds on top of
* BaseAIProvider.
*/ */
import { describe, it, expect, beforeEach, jest } from 'bun:test'; import { describe, it, expect, beforeEach, jest } from 'bun:test';
@@ -14,7 +20,7 @@ describe('OpenWebUIProvider', () => {
mockConfig = { mockConfig = {
apiKey: 'test-bearer-token', apiKey: 'test-bearer-token',
baseUrl: 'http://localhost:3000', baseUrl: 'http://localhost:3000',
defaultModel: 'llama3.1' defaultModel: 'llama3.1:latest'
}; };
}); });
@@ -26,31 +32,32 @@ describe('OpenWebUIProvider', () => {
}); });
it('should use default values when not specified', () => { it('should use default values when not specified', () => {
const minimalConfig: OpenWebUIConfig = { apiKey: 'test' }; provider = new OpenWebUIProvider({ apiKey: 'test' });
provider = new OpenWebUIProvider(minimalConfig);
expect(provider).toBeInstanceOf(OpenWebUIProvider); expect(provider).toBeInstanceOf(OpenWebUIProvider);
}); });
it('should handle custom configuration', () => { it('should accept Ollama proxy mode', () => {
const config: OpenWebUIConfig = { provider = new OpenWebUIProvider({
apiKey: 'test', apiKey: 'test',
baseUrl: 'http://localhost:8080', baseUrl: 'http://localhost:8080',
defaultModel: 'mistral:7b', defaultModel: 'mistral:7b',
useOllamaProxy: true useOllamaProxy: true
}; });
provider = new OpenWebUIProvider(config);
expect(provider).toBeInstanceOf(OpenWebUIProvider); expect(provider).toBeInstanceOf(OpenWebUIProvider);
}); });
it('should handle trailing slash in baseUrl', () => { it('should normalize trailing slash in baseUrl', () => {
const config: OpenWebUIConfig = { provider = new OpenWebUIProvider({
apiKey: 'test', apiKey: 'test',
baseUrl: 'http://localhost:3000/', baseUrl: 'http://localhost:3000/'
defaultModel: 'llama3.1' });
};
provider = new OpenWebUIProvider(config);
expect(provider).toBeInstanceOf(OpenWebUIProvider); expect(provider).toBeInstanceOf(OpenWebUIProvider);
}); });
it('should reject malformed baseUrl', () => {
expect(() => new OpenWebUIProvider({ apiKey: 'test', baseUrl: 'not a url' }))
.toThrow(AIProviderError);
});
}); });
describe('getInfo', () => { describe('getInfo', () => {
@@ -62,7 +69,7 @@ describe('OpenWebUIProvider', () => {
const info = provider.getInfo(); const info = provider.getInfo();
expect(info.name).toBe('OpenWebUI'); expect(info.name).toBe('OpenWebUI');
expect(info.version).toBe('1.0.0'); expect(info.version).toBe('2.0.0');
expect(info.supportsStreaming).toBe(true); expect(info.supportsStreaming).toBe(true);
expect(info.maxContextLength).toBe(32768); expect(info.maxContextLength).toBe(32768);
expect(Array.isArray(info.models)).toBe(true); expect(Array.isArray(info.models)).toBe(true);
@@ -77,338 +84,113 @@ describe('OpenWebUIProvider', () => {
expect(info.models).toContain('gemma:latest'); expect(info.models).toContain('gemma:latest');
}); });
it('should have correct capabilities', () => { it('should expose capabilities', () => {
const info = provider.getInfo(); const info = provider.getInfo();
expect(info.capabilities).toEqual({ expect(info.capabilities).toMatchObject({
vision: false, vision: false,
functionCalling: false, functionCalling: false,
jsonMode: false,
systemMessages: true, systemMessages: true,
reasoning: true, localDeployment: true
codeGeneration: true,
localDeployment: true,
multiModel: true
}); });
}); });
}); });
describe('Error Handling', () => { describe('Error mapping', () => {
const mapError = (err: any) => (provider as any).normalizeError(err);
beforeEach(() => { beforeEach(() => {
provider = new OpenWebUIProvider(mockConfig); provider = new OpenWebUIProvider(mockConfig);
}); });
it('should handle connection refused errors', () => { it('should map ECONNREFUSED to NETWORK', () => {
const mockError = new Error('connect ECONNREFUSED') as any; const error = new Error('connect ECONNREFUSED') as any;
mockError.code = 'ECONNREFUSED';
const aiError = (provider as any).handleOpenWebUIError(mockError); const aiError = mapError(error);
expect(aiError).toBeInstanceOf(AIProviderError); expect(aiError).toBeInstanceOf(AIProviderError);
expect(aiError.type).toBe(AIErrorType.NETWORK); expect(aiError.type).toBe(AIErrorType.NETWORK);
expect(aiError.message).toContain('Network error connecting to OpenWebUI');
}); });
it('should handle hostname resolution errors', () => { it('should map ENOTFOUND to NETWORK', () => {
const mockError = new Error('getaddrinfo ENOTFOUND') as any; const error = new Error('getaddrinfo ENOTFOUND') as any;
mockError.code = 'ENOTFOUND';
const aiError = (provider as any).handleOpenWebUIError(mockError); const aiError = mapError(error);
expect(aiError).toBeInstanceOf(AIProviderError); expect(aiError.type).toBe(AIErrorType.NETWORK);
expect(aiError.type).toBe(AIErrorType.UNKNOWN);
expect(aiError.message).toContain('OpenWebUI error');
}); });
it('should handle 404 model not found errors', () => { it('should map 404 with "model" in message to MODEL_NOT_FOUND', () => {
const mockError = new Error('model not available') as any; const error = new Error('model not available') as any;
mockError.status = 404; error.status = 404;
mockError.message = 'model not available';
const aiError = (provider as any).handleOpenWebUIError(mockError); const aiError = mapError(error);
expect(aiError).toBeInstanceOf(AIProviderError);
expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND); expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
expect(aiError.message).toContain('OpenWebUI');
}); });
it('should handle 404 endpoint not found errors', () => { it('should map 404 without "model" in message to MODEL_NOT_FOUND with endpoint message', () => {
const mockError = new Error('Not found') as any; const error = new Error('Not found') as any;
mockError.status = 404; error.status = 404;
mockError.message = 'endpoint not found';
const aiError = (provider as any).handleOpenWebUIError(mockError); const aiError = mapError(error);
expect(aiError).toBeInstanceOf(AIProviderError); expect(aiError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
expect(aiError.type).toBe(AIErrorType.INVALID_REQUEST); expect(aiError.message).toContain('endpoint');
expect(aiError.message).toContain('OpenWebUI endpoint not found');
}); });
it('should handle timeout errors', () => { it('should map timeout errors to TIMEOUT', () => {
const mockError = new Error('Request timeout') as any; const error = new Error('Request timeout') as any;
mockError.code = 'ETIMEDOUT';
const aiError = (provider as any).handleOpenWebUIError(mockError); const aiError = mapError(error);
expect(aiError).toBeInstanceOf(AIProviderError);
expect(aiError.type).toBe(AIErrorType.TIMEOUT); expect(aiError.type).toBe(AIErrorType.TIMEOUT);
}); });
it('should handle rate limit errors', () => { it('should map 429 to RATE_LIMIT', () => {
const mockError = new Error('Rate limit exceeded') as any; const error = new Error('Rate limit exceeded') as any;
mockError.status = 429; error.status = 429;
const aiError = (provider as any).handleOpenWebUIError(mockError); const aiError = mapError(error);
expect(aiError).toBeInstanceOf(AIProviderError);
expect(aiError.type).toBe(AIErrorType.RATE_LIMIT); expect(aiError.type).toBe(AIErrorType.RATE_LIMIT);
}); });
it('should handle server errors', () => { it('should map 500 to NETWORK', () => {
const mockError = new Error('Internal server error') as any; const error = new Error('Internal server error') as any;
mockError.status = 500; error.status = 500;
const aiError = (provider as any).handleOpenWebUIError(mockError); const aiError = mapError(error);
expect(aiError).toBeInstanceOf(AIProviderError);
expect(aiError.type).toBe(AIErrorType.NETWORK); expect(aiError.type).toBe(AIErrorType.NETWORK);
}); });
it('should handle authentication errors', () => { it('should map 401 to AUTHENTICATION with OpenWebUI hint', () => {
const mockError = new Error('Unauthorized') as any; const error = new Error('Unauthorized') as any;
mockError.status = 401; error.status = 401;
const aiError = (provider as any).handleOpenWebUIError(mockError); const aiError = mapError(error);
expect(aiError).toBeInstanceOf(AIProviderError);
expect(aiError.type).toBe(AIErrorType.AUTHENTICATION); expect(aiError.type).toBe(AIErrorType.AUTHENTICATION);
expect(aiError.message).toContain('Authentication failed with OpenWebUI'); expect(aiError.message).toContain('OpenWebUI');
}); });
it('should handle unknown errors', () => { it('should map unrecognized errors to UNKNOWN', () => {
const mockError = new Error('Unknown error'); const error = new Error('Unknown error');
const aiError = (provider as any).handleOpenWebUIError(mockError); const aiError = mapError(error);
expect(aiError).toBeInstanceOf(AIProviderError);
expect(aiError.type).toBe(AIErrorType.UNKNOWN); expect(aiError.type).toBe(AIErrorType.UNKNOWN);
}); });
}); });
describe('Message Conversion', () => {
beforeEach(() => {
provider = new OpenWebUIProvider(mockConfig);
});
it('should convert messages correctly for chat API', () => {
const messages = [
{ role: 'system' as const, content: 'You are helpful' },
{ role: 'user' as const, content: 'Hello' },
{ role: 'assistant' as const, content: 'Hi there!' }
];
const converted = (provider as any).convertMessages(messages);
expect(converted).toHaveLength(3);
expect(converted[0]).toEqual({ role: 'system', content: 'You are helpful' });
expect(converted[1]).toEqual({ role: 'user', content: 'Hello' });
expect(converted[2]).toEqual({ role: 'assistant', content: 'Hi there!' });
});
it('should convert messages to prompt for Ollama API', () => {
const messages = [
{ role: 'system' as const, content: 'You are helpful' },
{ role: 'user' as const, content: 'Hello' },
{ role: 'assistant' as const, content: 'Hi there!' }
];
const prompt = (provider as any).convertMessagesToPrompt(messages);
expect(prompt).toBe('System: You are helpful\n\nHuman: Hello\n\nAssistant: Hi there!\n\nAssistant: ');
});
});
describe('Response Formatting', () => {
beforeEach(() => {
provider = new OpenWebUIProvider(mockConfig);
});
it('should format chat completion response correctly', () => {
const mockResponse = {
id: 'test-id',
object: 'chat.completion',
created: 1234567890,
model: 'llama3.1',
choices: [{
index: 0,
message: { role: 'assistant', content: 'Hello there!' },
finish_reason: 'stop'
}],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15
}
};
const response = (provider as any).formatChatResponse(mockResponse);
expect(response.content).toBe('Hello there!');
expect(response.model).toBe('llama3.1');
expect(response.id).toBe('test-id');
expect(response.usage.promptTokens).toBe(10);
expect(response.usage.completionTokens).toBe(5);
expect(response.usage.totalTokens).toBe(15);
expect(response.metadata?.finishReason).toBe('stop');
expect(response.metadata?.created).toBe(1234567890);
});
it('should format Ollama response correctly', () => {
const mockResponse = {
model: 'llama3.1',
created_at: '2024-01-01T00:00:00Z',
response: 'Hello there!',
done: true,
prompt_eval_count: 10,
eval_count: 5,
total_duration: 1000000,
eval_duration: 500000
};
const response = (provider as any).formatOllamaResponse(mockResponse);
expect(response.content).toBe('Hello there!');
expect(response.model).toBe('llama3.1');
expect(response.usage.promptTokens).toBe(10);
expect(response.usage.completionTokens).toBe(5);
expect(response.usage.totalTokens).toBe(15);
expect(response.metadata?.created).toBe(1704067200000);
expect(response.metadata?.totalDuration).toBe(1000000);
});
it('should handle response without content', () => {
const mockResponse = {
id: 'test-id',
object: 'chat.completion',
created: 1234567890,
model: 'llama3.1',
choices: [{
index: 0,
message: { role: 'assistant' }, // Message without content
finish_reason: 'stop'
}],
usage: { prompt_tokens: 5, completion_tokens: 0, total_tokens: 5 }
};
expect(() => {
(provider as any).formatChatResponse(mockResponse);
}).toThrow(AIProviderError);
});
it('should handle missing usage information', () => {
const mockResponse = {
id: 'test-id',
object: 'chat.completion',
created: 1234567890,
model: 'llama3.1',
choices: [{
index: 0,
message: { role: 'assistant', content: 'Hello there!' },
finish_reason: 'stop'
}]
// No usage information
};
const response = (provider as any).formatChatResponse(mockResponse);
expect(response.content).toBe('Hello there!');
expect(response.usage.promptTokens).toBe(0);
expect(response.usage.completionTokens).toBe(0);
expect(response.usage.totalTokens).toBe(0);
});
});
describe('Configuration Options', () => {
it('should handle custom baseUrl', () => {
const config: OpenWebUIConfig = {
apiKey: 'test',
baseUrl: 'https://my-openwebui.com'
};
provider = new OpenWebUIProvider(config);
expect(provider).toBeInstanceOf(OpenWebUIProvider);
});
it('should handle custom default model', () => {
const config: OpenWebUIConfig = {
apiKey: 'test',
defaultModel: 'mistral:7b'
};
provider = new OpenWebUIProvider(config);
expect(provider).toBeInstanceOf(OpenWebUIProvider);
});
it('should handle Ollama proxy mode', () => {
const config: OpenWebUIConfig = {
apiKey: 'test',
useOllamaProxy: true
};
provider = new OpenWebUIProvider(config);
expect(provider).toBeInstanceOf(OpenWebUIProvider);
});
it('should handle SSL verification settings', () => {
const config: OpenWebUIConfig = {
apiKey: 'test',
dangerouslyAllowInsecureConnections: false
};
provider = new OpenWebUIProvider(config);
expect(provider).toBeInstanceOf(OpenWebUIProvider);
});
});
describe('Request Generation', () => {
beforeEach(() => {
provider = new OpenWebUIProvider(mockConfig);
});
it('should generate correct headers', () => {
const headers = (provider as any).makeRequest('http://test.com', 'GET');
// Note: This would need to be mocked in a real test environment
expect(typeof headers).toBe('object');
});
});
describe('API Mode Selection', () => {
it('should use chat API by default', () => {
const config: OpenWebUIConfig = {
apiKey: 'test',
useOllamaProxy: false
};
provider = new OpenWebUIProvider(config);
expect(provider).toBeInstanceOf(OpenWebUIProvider);
});
it('should use Ollama proxy when configured', () => {
const config: OpenWebUIConfig = {
apiKey: 'test',
useOllamaProxy: true
};
provider = new OpenWebUIProvider(config);
expect(provider).toBeInstanceOf(OpenWebUIProvider);
});
});
describe('Validation', () => { describe('Validation', () => {
beforeEach(() => { beforeEach(() => {
provider = new OpenWebUIProvider(mockConfig); provider = new OpenWebUIProvider(mockConfig);
}); });
it('should validate provider is initialized before use', async () => { it('should require initialization before use', async () => {
const params: CompletionParams = { const params: CompletionParams = {
messages: [{ role: 'user', content: 'Hello' }] messages: [{ role: 'user', content: 'Hello' }]
}; };
@@ -417,31 +199,31 @@ describe('OpenWebUIProvider', () => {
}); });
}); });
describe('structured responses', () => { describe('Structured responses', () => {
it('should handle structured responses correctly', async () => { it('should parse JSON content into typed responses', async () => {
const provider = new OpenWebUIProvider({ apiKey: 'test-key' }); const p = new OpenWebUIProvider({ apiKey: 'test-key' });
const mockResponse = { const mockResponse = {
content: JSON.stringify({ name: 'John Doe', age: 30 }), content: JSON.stringify({ name: 'John Doe', age: 30 }),
model: 'llama3.1', model: 'llama3.1:latest',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 }, usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
id: 'test-id' id: 'test-id'
}; };
(provider as any).doComplete = jest.fn().mockResolvedValue(mockResponse); (p as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
(provider as any).initialized = true; (p as any).initialized = true;
const responseType = createResponseType<{ name: string; age: number }> const responseType = createResponseType<{ name: string; age: number }>(
(`{ name: string; age: number }`, `{ name: string; age: number }`,
'A user profile' 'A user profile'
); );
const response = await provider.complete<{ name: string; age: number }>({ const response = await p.complete<{ name: string; age: number }>({
messages: [{ role: 'user', content: 'test' }], messages: [{ role: 'user', content: 'test' }],
responseType responseType
}); });
expect(response.content).toEqual({ name: 'John Doe', age: 30 }); expect(response.content).toEqual({ name: 'John Doe', age: 30 });
expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 })); expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 }));
expect((provider as any).doComplete).toHaveBeenCalled(); expect((p as any).doComplete).toHaveBeenCalled();
}); });
}); });
}); });