From 38e46cc49a8c6ba05e4037259f1484886e00e27e Mon Sep 17 00:00:00 2001 From: XiaoHuo888 Date: Thu, 13 Aug 2026 06:11:36 +0800 Subject: [PATCH] feat: add OrcaRouter as a first-class LLM provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `orcarouter` to the provider registry (auto-detect order, env key, model default/env), with dedicated text + structured-extraction adapters hitting https://api.orcarouter.ai/v1. - TEXT_ADAPTERS: callOrcaRouterText (OpenAI-compatible, vision via data: URL) — used by research/agent synthesis - ADAPTERS: callOrcaRouter (json_object + client-side schema validation + one retry) — used by extract's structured fallback - select/model-select/keystore/init/TUI wiring: ORCAROUTER_API_KEY, WIGOLO_LLM_MODEL_ORCAROUTER, --provider validation, config wizard, keystore storage (env-only in the picker, mirroring groq) - vision gate in browser-pool accepts orcarouter - tests + docs updated Co-Authored-By: Claude --- README.md | 4 +- docs/configuration.md | 4 +- src/cli/init.ts | 2 +- src/cli/tui/actions/verify-e2e.ts | 2 +- src/cli/tui/extras-prompt.ts | 1 + src/cli/tui/flags.ts | 2 +- src/cli/tui/schema/llm.ts | 5 + src/extraction/llm-fallback.ts | 2 + src/fetch/browser-pool.ts | 4 +- src/integrations/cloud/llm/model-select.ts | 2 + src/integrations/cloud/llm/orcarouter.ts | 97 +++++++++++++++++++ src/integrations/cloud/llm/run.ts | 2 +- src/integrations/cloud/llm/select.ts | 3 +- src/integrations/cloud/llm/text-adapters.ts | 34 +++++++ src/integrations/cloud/llm/types.ts | 2 +- src/security/key-store.ts | 4 +- tests/integration/llm-fallback-e2e.test.ts | 20 +++- .../integration/zero-touch-just-works.test.ts | 1 + .../agent/pipeline.llm-gate-keystore.test.ts | 1 + .../cli/tui/actions/provider-actions.test.ts | 6 +- .../unit/cli/tui/schema/llm-no-custom.test.ts | 2 +- tests/unit/cli/tui/schema/llm.test.ts | 3 +- tests/unit/extraction/llm-fallback.test.ts | 1 + tests/unit/extraction/llm/orcarouter.test.ts | 79 +++++++++++++++ tests/unit/extraction/llm/select.test.ts | 3 +- tests/unit/extraction/llm/types.test.ts | 2 +- .../cloud/llm/text-adapters-vision.test.ts | 21 +++- tests/unit/integrations/llm-runner.test.ts | 5 + .../pipeline.llm-gate-keystore.test.ts | 1 + tests/unit/research/synthesis-local.test.ts | 1 + 30 files changed, 295 insertions(+), 21 deletions(-) create mode 100644 src/integrations/cloud/llm/orcarouter.ts create mode 100644 tests/unit/extraction/llm/orcarouter.test.ts diff --git a/README.md b/README.md index 010696944..d912a29e0 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ export WIGOLO_LLM_PROVIDER=gemini export GEMINI_API_KEY= # grab one at aistudio.google.com/apikey — the free tier is plenty ``` -Any provider works (`anthropic` · `openai` · `groq`), or stay fully local and keyless with `WIGOLO_LLM_PROVIDER=ollama` (or any OpenAI-compatible URL). Set it in your shell or your agent's MCP `env` block. Providers, models, and the keyless local-model ladder are in the [configuration guide](docs/configuration.md). +Any provider works (`anthropic` · `openai` · `groq` · `orcarouter`), or stay fully local and keyless with `WIGOLO_LLM_PROVIDER=ollama` (or any OpenAI-compatible URL). Set it in your shell or your agent's MCP `env` block. Providers, models, and the keyless local-model ladder are in the [configuration guide](docs/configuration.md). ## What your agent gets back @@ -302,7 +302,7 @@ A clean install works out of the box. Three settings raise output quality: ```bash # 1. Synthesis — the biggest lever (research / agent / search-answer write real prose) -export WIGOLO_LLM_PROVIDER=gemini # or anthropic / openai / groq / ollama (keyless) +export WIGOLO_LLM_PROVIDER=gemini # or anthropic / openai / groq / orcarouter / ollama (keyless) export GEMINI_API_KEY= # 2. Wider retrieval funnel diff --git a/docs/configuration.md b/docs/configuration.md index 53800d610..256c970e9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,8 +99,8 @@ Core tools never need an LLM. Configuring one adds answer synthesis (`format: "a | Env var | Default | What it does | | --- | --- | --- | -| `WIGOLO_LLM_PROVIDER` | unset | `anthropic`, `openai`, `gemini`, `groq`, or `ollama` (any local OpenAI-compatible server). | -| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROQ_API_KEY` | unset | Per-provider keys, read from env. | +| `WIGOLO_LLM_PROVIDER` | unset | `anthropic`, `openai`, `gemini`, `groq`, `orcarouter`, or `ollama` (any local OpenAI-compatible server). | +| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROQ_API_KEY` / `ORCAROUTER_API_KEY` | unset | Per-provider keys, read from env. | | `WIGOLO_LLM_API_KEY` | unset | Generic key slot (used by `init --provider=...`; stored in the OS keychain, never passed as a flag). | | `WIGOLO_LLM_MODEL` | provider default | Override the model name. | | `WIGOLO_LLM_BASE_URL` | `http://localhost:11434` | Custom base URL for the `ollama` provider — point it at any OpenAI-compatible endpoint. | diff --git a/src/cli/init.ts b/src/cli/init.ts index 26f9d737e..588d11b3a 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -24,7 +24,7 @@ export async function maybePrintOllamaHint(print: (line: string) => void): Promi } } -const KEYSTORE_PROVIDERS: readonly LLMProvider[] = ['anthropic', 'openai', 'gemini', 'groq']; +const KEYSTORE_PROVIDERS: readonly LLMProvider[] = ['anthropic', 'openai', 'gemini', 'groq', 'orcarouter']; /** * One-line hint printed on a `--no-warmup` init: nothing was pre-downloaded, so diff --git a/src/cli/tui/actions/verify-e2e.ts b/src/cli/tui/actions/verify-e2e.ts index 93b90a0b5..ede461666 100644 --- a/src/cli/tui/actions/verify-e2e.ts +++ b/src/cli/tui/actions/verify-e2e.ts @@ -412,7 +412,7 @@ function buildDefaultExtractProbe(): () => Promise { function buildDefaultSynthesisProbe(): () => Promise { const SYNTHESIS_SKIP_DETAIL = - 'no provider key configured — add a provider key via `wigolo config` (or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY) to enable synthesis'; + 'no provider key configured — add a provider key via `wigolo config` (or set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / ORCAROUTER_API_KEY) to enable synthesis'; return async (): Promise => { // Resolve a provider key through SP4's secure key store (keychain → diff --git a/src/cli/tui/extras-prompt.ts b/src/cli/tui/extras-prompt.ts index 30273c9b6..72e364e72 100644 --- a/src/cli/tui/extras-prompt.ts +++ b/src/cli/tui/extras-prompt.ts @@ -9,6 +9,7 @@ const PROVIDER_CHOICES: ReadonlyArray<{ name: string; value: LLMProvider }> = [ { name: 'Anthropic (Claude)', value: 'anthropic' }, { name: 'OpenAI (GPT)', value: 'openai' }, { name: 'Google Gemini', value: 'gemini' }, + { name: 'OrcaRouter', value: 'orcarouter' }, ]; // Index signature lets us pass an ExtrasChoices straight to saveInitConfig diff --git a/src/cli/tui/flags.ts b/src/cli/tui/flags.ts index 4e8c9cab4..5256f265c 100644 --- a/src/cli/tui/flags.ts +++ b/src/cli/tui/flags.ts @@ -140,7 +140,7 @@ function parseCommon(args: readonly string[], known: ReadonlySet): Raw { return raw; } -const VALID_PROVIDERS = ['anthropic', 'openai', 'gemini', 'ollama'] as const; +const VALID_PROVIDERS = ['anthropic', 'openai', 'gemini', 'ollama', 'orcarouter'] as const; const VALID_SEARCH_BACKENDS = ['core', 'searxng', 'hybrid'] as const; function parseInitOnlyFlags(args: readonly string[]): { provider?: string; search?: string; interactive: boolean; wizard: boolean; warmup: boolean } { diff --git a/src/cli/tui/schema/llm.ts b/src/cli/tui/schema/llm.ts index 38bfe29d4..34c7e2003 100644 --- a/src/cli/tui/schema/llm.ts +++ b/src/cli/tui/schema/llm.ts @@ -14,6 +14,11 @@ export const llmCategory: CategoryDef = { { value: 'anthropic', label: 'Anthropic (Claude)' }, { value: 'openai', label: 'OpenAI (GPT)' }, { value: 'gemini', label: 'Google Gemini' }, + { + value: 'orcarouter', + label: 'OrcaRouter', + hint: 'OpenAI-compatible gateway with gateway-level, zero-trust security for AI agents', + }, { value: 'ollama', label: 'Ollama (local LLM server)', diff --git a/src/extraction/llm-fallback.ts b/src/extraction/llm-fallback.ts index 529ea7a6a..832ea0834 100644 --- a/src/extraction/llm-fallback.ts +++ b/src/extraction/llm-fallback.ts @@ -3,6 +3,7 @@ import { callAnthropic } from '../integrations/cloud/llm/anthropic.js'; import { callOpenAI } from '../integrations/cloud/llm/openai.js'; import { callGemini } from '../integrations/cloud/llm/gemini.js'; import { callGroq } from '../integrations/cloud/llm/groq.js'; +import { callOrcaRouter } from '../integrations/cloud/llm/orcarouter.js'; import { ensureLLMCacheTable, insertLLMCache, @@ -43,6 +44,7 @@ const ADAPTERS: Record< openai: callOpenAI, gemini: callGemini, groq: callGroq, + orcarouter: callOrcaRouter, }; export async function extractWithLLM( diff --git a/src/fetch/browser-pool.ts b/src/fetch/browser-pool.ts index 8531fd714..51762770d 100644 --- a/src/fetch/browser-pool.ts +++ b/src/fetch/browser-pool.ts @@ -1449,7 +1449,7 @@ export class MultiBrowserPool { }); } - /** True when a vision-capable cloud provider (anthropic/openai/gemini) is + /** True when a vision-capable cloud provider (anthropic/openai/gemini/orcarouter) is * configured. Groq (weak vision) and the text-only custom backend don't * count, so ai-solve is skipped cleanly when only those are present. */ private async visionProviderAvailable(): Promise { @@ -1457,7 +1457,7 @@ export class MultiBrowserPool { const { selectProviderWithKeyStore } = await import('../integrations/cloud/llm/select.js'); const resolved = await selectProviderWithKeyStore(process.env, { dataDir: getConfig().dataDir }); if (!resolved) return false; - return resolved.provider === 'anthropic' || resolved.provider === 'openai' || resolved.provider === 'gemini'; + return resolved.provider === 'anthropic' || resolved.provider === 'openai' || resolved.provider === 'gemini' || resolved.provider === 'orcarouter'; } catch { return false; } diff --git a/src/integrations/cloud/llm/model-select.ts b/src/integrations/cloud/llm/model-select.ts index 21de3ac35..2e3e6b62f 100644 --- a/src/integrations/cloud/llm/model-select.ts +++ b/src/integrations/cloud/llm/model-select.ts @@ -11,6 +11,7 @@ const PROVIDER_DEFAULTS: Record = { openai: 'gpt-4o-mini', gemini: 'gemini-2.5-flash-lite', groq: 'llama-3.3-70b-versatile', + orcarouter: 'orcarouter/auto', }; const PROVIDER_ENV: Record = { @@ -18,6 +19,7 @@ const PROVIDER_ENV: Record = { openai: 'WIGOLO_LLM_MODEL_OPENAI', gemini: 'WIGOLO_LLM_MODEL_GEMINI', groq: 'WIGOLO_LLM_MODEL_GROQ', + orcarouter: 'WIGOLO_LLM_MODEL_ORCAROUTER', }; export function resolveModel( diff --git a/src/integrations/cloud/llm/orcarouter.ts b/src/integrations/cloud/llm/orcarouter.ts new file mode 100644 index 000000000..6eda76b09 --- /dev/null +++ b/src/integrations/cloud/llm/orcarouter.ts @@ -0,0 +1,97 @@ +import OpenAI from 'openai'; +import type { LLMCallOpts, LLMExtractResult } from './types.js'; +import { validateAgainstSchema, type ValidationError } from './validate.js'; + +const DEFAULT_MODEL = 'orcarouter/auto'; +const BASE_URL = 'https://api.orcarouter.ai/v1'; + +export async function callOrcaRouter( + opts: LLMCallOpts, + apiKey: string, +): Promise { + const client = new OpenAI({ apiKey, baseURL: BASE_URL }); + const model = opts.modelOverride ?? DEFAULT_MODEL; + const start = Date.now(); + + const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [ + { role: 'user', content: buildPrompt(opts.prompt, opts.jsonSchema) }, + ]; + + const first = await runOnce(client, model, messages, opts.signal); + let errors = validateAgainstSchema(first.values, opts.jsonSchema); + if (errors.length === 0) { + return done(first.values, first.responseModel ?? model, start); + } + + // Retry once with validation errors fed back to the model. + messages.push({ role: 'assistant', content: first.raw }); + messages.push({ role: 'user', content: retryPrompt(errors) }); + + const second = await runOnce(client, model, messages, opts.signal); + errors = validateAgainstSchema(second.values, opts.jsonSchema); + if (errors.length > 0) { + throw new Error( + `orcarouter: response failed schema validation after retry: ${formatErrors(errors)}`, + ); + } + return done(second.values, second.responseModel ?? model, start); +} + +interface CallOnceResult { + values: Record; + raw: string; + responseModel: string | undefined; +} + +async function runOnce( + client: OpenAI, + model: string, + messages: Array<{ role: 'user' | 'assistant'; content: string }>, + signal: AbortSignal | undefined, +): Promise { + const response = await client.chat.completions.create( + { + model, + messages, + response_format: { type: 'json_object' }, + }, + { signal }, + ); + const content = response.choices?.[0]?.message?.content; + if (!content) { + throw new Error('orcarouter: empty content in response'); + } + let values: Record; + try { + values = JSON.parse(content); + } catch (e) { + throw new Error(`orcarouter: invalid JSON in response: ${(e as Error).message}`); + } + return { values, raw: content, responseModel: response.model }; +} + +function buildPrompt(prompt: string, schema: Record): string { + return `${prompt}\n\nReturn JSON matching this schema:\n${JSON.stringify(schema)}`; +} + +function retryPrompt(errors: ValidationError[]): string { + return `Your previous response failed schema validation:\n${formatErrors(errors)}\nReturn corrected JSON only.`; +} + +function formatErrors(errors: ValidationError[]): string { + return errors.map((e) => `${e.path}: ${e.message}`).join('; '); +} + +function done( + values: Record, + model: string, + start: number, +): LLMExtractResult { + return { + values, + provider: 'orcarouter', + model, + cached: false, + latencyMs: Date.now() - start, + }; +} diff --git a/src/integrations/cloud/llm/run.ts b/src/integrations/cloud/llm/run.ts index 052c7ded0..5342a2757 100644 --- a/src/integrations/cloud/llm/run.ts +++ b/src/integrations/cloud/llm/run.ts @@ -1,6 +1,6 @@ // Unified entry point for LLM calls across wigolo. Selects a backend from // env (or keystore) and delegates: -// - cloud provider name (anthropic/openai/gemini/groq) → SDK adapter +// - cloud provider name (anthropic/openai/gemini/groq/orcarouter) → SDK adapter // - OpenAI-compatible URL (http://...) → POST /v1/chat/completions // // SP4: keys are resolved via resolveProviderKey (keychain → file → env) and diff --git a/src/integrations/cloud/llm/select.ts b/src/integrations/cloud/llm/select.ts index 3b52d1e07..e6d095387 100644 --- a/src/integrations/cloud/llm/select.ts +++ b/src/integrations/cloud/llm/select.ts @@ -3,13 +3,14 @@ import type { KeyStoreOpts } from '../../../security/key-store.js'; import { readPersistedConfig, defaultConfigPath } from '../../../persisted-config.js'; import { resolveCustomBackend } from './custom-backend.js'; -const PROVIDER_ORDER: LLMProvider[] = ['anthropic', 'openai', 'gemini', 'groq']; +const PROVIDER_ORDER: LLMProvider[] = ['anthropic', 'openai', 'gemini', 'groq', 'orcarouter']; const PROVIDER_ENV: Record = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', gemini: 'GEMINI_API_KEY', groq: 'GROQ_API_KEY', + orcarouter: 'ORCAROUTER_API_KEY', }; // Extra env var names accepted for a provider's key, beyond the canonical one diff --git a/src/integrations/cloud/llm/text-adapters.ts b/src/integrations/cloud/llm/text-adapters.ts index 6fe93bf00..f54ad426d 100644 --- a/src/integrations/cloud/llm/text-adapters.ts +++ b/src/integrations/cloud/llm/text-adapters.ts @@ -165,6 +165,39 @@ export async function callGroqText(opts: TextCallOpts, apiKey: string): Promise< }; } +export async function callOrcaRouterText(opts: TextCallOpts, apiKey: string): Promise { + const { default: OpenAI } = await import('openai'); + const client = new OpenAI({ apiKey, baseURL: 'https://api.orcarouter.ai/v1' }); + const start = Date.now(); + const content = opts.image + ? [ + { type: 'text' as const, text: opts.prompt }, + { + type: 'image_url' as const, + image_url: { url: `data:${opts.image.mediaType};base64,${opts.image.data}` }, + }, + ] + : opts.prompt; + const response = await client.chat.completions.create( + { + model: opts.model, + max_completion_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS, + messages: [{ role: 'user', content }], + }, + { signal: opts.signal }, + ); + const text = response.choices?.[0]?.message?.content; + if (typeof text !== 'string' || text.trim().length === 0) { + throw new Error('orcarouter: empty content in response'); + } + return { + text, + provider: 'orcarouter', + model: response.model ?? opts.model, + latencyMs: Date.now() - start, + }; +} + export const TEXT_ADAPTERS: Record< LLMProvider, (opts: TextCallOpts, apiKey: string) => Promise @@ -173,4 +206,5 @@ export const TEXT_ADAPTERS: Record< openai: callOpenAIText, gemini: callGeminiText, groq: callGroqText, + orcarouter: callOrcaRouterText, }; diff --git a/src/integrations/cloud/llm/types.ts b/src/integrations/cloud/llm/types.ts index 98cc0a721..c7d4aea44 100644 --- a/src/integrations/cloud/llm/types.ts +++ b/src/integrations/cloud/llm/types.ts @@ -1,4 +1,4 @@ -export type LLMProvider = 'anthropic' | 'openai' | 'gemini' | 'groq'; +export type LLMProvider = 'anthropic' | 'openai' | 'gemini' | 'groq' | 'orcarouter'; export interface LLMExtractResult { values: Record; diff --git a/src/security/key-store.ts b/src/security/key-store.ts index 7866866b8..f06fc2cf3 100644 --- a/src/security/key-store.ts +++ b/src/security/key-store.ts @@ -47,8 +47,8 @@ export const PICKER_PROVIDERS: ReadonlyArray = [ 'custom', ]; -// All providers that can have keystore entries (including groq via env only) -const STORE_PROVIDERS: ReadonlyArray = ['anthropic', 'openai', 'gemini', 'groq']; +// All providers that can have keystore entries (including groq/orcarouter via env only) +const STORE_PROVIDERS: ReadonlyArray = ['anthropic', 'openai', 'gemini', 'groq', 'orcarouter']; /** * Process-lifetime memo for resolveProviderKey, keyed by `provider:dataDir`. diff --git a/tests/integration/llm-fallback-e2e.test.ts b/tests/integration/llm-fallback-e2e.test.ts index 91706f2ac..08fe78758 100644 --- a/tests/integration/llm-fallback-e2e.test.ts +++ b/tests/integration/llm-fallback-e2e.test.ts @@ -45,6 +45,7 @@ describe('llm-fallback e2e per provider', () => { delete process.env.OPENAI_API_KEY; delete process.env.GOOGLE_API_KEY; delete process.env.GROQ_API_KEY; + delete process.env.ORCAROUTER_API_KEY; delete process.env.WIGOLO_LLM_PROVIDER; resetConfig(); initDatabase(':memory:'); @@ -134,7 +135,23 @@ describe('llm-fallback e2e per provider', () => { expect(out.provider).toBe('groq'); }); - it('no-key returns warning listing all four env vars', async () => { + it('orcarouter full path', async () => { + process.env.ORCAROUTER_API_KEY = 'k'; + openaiCreate.mockResolvedValue({ + choices: [{ message: { content: '{"price":"$5"}' } }], + model: 'orcarouter/auto', + }); + const out = await extractWithLLM({ + html: '

', + jsonSchema: schema, + partial: {}, + missing: ['price'], + }); + expect(out.values).toEqual({ price: '$5' }); + expect(out.provider).toBe('orcarouter'); + }); + + it('no-key returns warning listing all five env vars', async () => { const out = await extractWithLLM({ html: '

', jsonSchema: schema, @@ -146,6 +163,7 @@ describe('llm-fallback e2e per provider', () => { expect(w).toMatch(/OPENAI_API_KEY/); expect(w).toMatch(/GEMINI_API_KEY/); expect(w).toMatch(/GROQ_API_KEY/); + expect(w).toMatch(/ORCAROUTER_API_KEY/); }); it('budget exhaustion blocks call', async () => { diff --git a/tests/integration/zero-touch-just-works.test.ts b/tests/integration/zero-touch-just-works.test.ts index 09d1622fa..dfb852154 100644 --- a/tests/integration/zero-touch-just-works.test.ts +++ b/tests/integration/zero-touch-just-works.test.ts @@ -65,6 +65,7 @@ const RELEVANT_ENV_VARS = [ 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GROQ_API_KEY', + 'ORCAROUTER_API_KEY', 'WIGOLO_DATA_DIR', 'WIGOLO_CONFIG_PATH', ]; diff --git a/tests/unit/agent/pipeline.llm-gate-keystore.test.ts b/tests/unit/agent/pipeline.llm-gate-keystore.test.ts index 31d044cdf..7f5df15fb 100644 --- a/tests/unit/agent/pipeline.llm-gate-keystore.test.ts +++ b/tests/unit/agent/pipeline.llm-gate-keystore.test.ts @@ -59,6 +59,7 @@ const KEY_ENV_VARS = [ 'OPENAI_API_KEY', 'GOOGLE_API_KEY', 'GROQ_API_KEY', + 'ORCAROUTER_API_KEY', 'WIGOLO_LLM_PROVIDER', 'WIGOLO_LLM_API_KEY', ]; diff --git a/tests/unit/cli/tui/actions/provider-actions.test.ts b/tests/unit/cli/tui/actions/provider-actions.test.ts index 84da1a774..413f9f2a7 100644 --- a/tests/unit/cli/tui/actions/provider-actions.test.ts +++ b/tests/unit/cli/tui/actions/provider-actions.test.ts @@ -93,11 +93,15 @@ describe('provider actions (SP4)', () => { }); }); -describe('PICKER_PROVIDERS — groq hidden from picker (locked decision)', () => { +describe('PICKER_PROVIDERS — groq/orcarouter hidden from picker (locked decision)', () => { it('does NOT include groq', () => { expect(PICKER_PROVIDERS).not.toContain('groq'); }); + it('does NOT include orcarouter', () => { + expect(PICKER_PROVIDERS).not.toContain('orcarouter'); + }); + it('includes the four picker-visible providers', () => { expect(PICKER_PROVIDERS).toContain('anthropic'); expect(PICKER_PROVIDERS).toContain('openai'); diff --git a/tests/unit/cli/tui/schema/llm-no-custom.test.ts b/tests/unit/cli/tui/schema/llm-no-custom.test.ts index 9c22f42d7..0b0b2f3c6 100644 --- a/tests/unit/cli/tui/schema/llm-no-custom.test.ts +++ b/tests/unit/cli/tui/schema/llm-no-custom.test.ts @@ -18,7 +18,7 @@ describe('llmCategory — no custom provider (Fix D)', () => { const values = provider?.options?.map((o) => o.value) ?? []; expect(values).not.toContain('custom'); // The keyed cloud providers remain; slice-3 appended the keyless 'ollama' lever. - expect(values).toEqual(['anthropic', 'openai', 'gemini', 'ollama']); + expect(values).toEqual(['anthropic', 'openai', 'gemini', 'orcarouter', 'ollama']); }); it('llmCategory has exactly 2 fields after removing custom + endpoint URL field', () => { diff --git a/tests/unit/cli/tui/schema/llm.test.ts b/tests/unit/cli/tui/schema/llm.test.ts index 6999f53ac..cba52c187 100644 --- a/tests/unit/cli/tui/schema/llm.test.ts +++ b/tests/unit/cli/tui/schema/llm.test.ts @@ -13,7 +13,7 @@ describe('llmCategory', () => { expect(keys).toEqual(['llmProvider', 'llmApiKey']); }); - it('provider is a select offering anthropic, openai, gemini, and the keyless ollama lever', () => { + it('provider is a select offering anthropic, openai, gemini, orcarouter, and the keyless ollama lever', () => { const provider = llmCategory.fields.find((f) => f.settingsPath === 'llmProvider'); expect(provider).toBeDefined(); expect(provider?.kind).toBe('select'); @@ -21,6 +21,7 @@ describe('llmCategory', () => { 'anthropic', 'openai', 'gemini', + 'orcarouter', 'ollama', ]); expect(provider?.options?.map((o) => o.value)).not.toContain('custom'); diff --git a/tests/unit/extraction/llm-fallback.test.ts b/tests/unit/extraction/llm-fallback.test.ts index 8f0ea09a6..2dc32a4f5 100644 --- a/tests/unit/extraction/llm-fallback.test.ts +++ b/tests/unit/extraction/llm-fallback.test.ts @@ -70,6 +70,7 @@ describe('extractWithLLM', () => { expect(out.values).toEqual({}); expect(out.warnings.join(' ')).toMatch(/ANTHROPIC_API_KEY/); expect(out.warnings.join(' ')).toMatch(/OPENAI_API_KEY/); + expect(out.warnings.join(' ')).toMatch(/ORCAROUTER_API_KEY/); expect(out.provider).toBe('anthropic'); }); diff --git a/tests/unit/extraction/llm/orcarouter.test.ts b/tests/unit/extraction/llm/orcarouter.test.ts new file mode 100644 index 000000000..519c54767 --- /dev/null +++ b/tests/unit/extraction/llm/orcarouter.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const create = vi.fn(); +vi.mock('openai', () => ({ + default: class { + chat = { completions: { create } }; + }, +})); + +import { callOrcaRouter } from '../../../../src/integrations/cloud/llm/orcarouter.js'; + +const schema = { + type: 'object', + required: ['price'], + properties: { price: { type: 'string' } }, +}; + +describe('callOrcaRouter', () => { + beforeEach(() => create.mockReset()); + + it('returns parsed JSON values', async () => { + create.mockResolvedValue({ + choices: [{ message: { content: '{"price":"$10"}' } }], + model: 'orcarouter/auto', + }); + const out = await callOrcaRouter({ prompt: 'p', jsonSchema: schema }, 'k'); + expect(out.values).toEqual({ price: '$10' }); + expect(out.provider).toBe('orcarouter'); + }); + + it('uses the OrcaRouter base URL and json_object response_format', async () => { + create.mockResolvedValue({ + choices: [{ message: { content: '{"price":"$1"}' } }], + model: 'm', + }); + await callOrcaRouter({ prompt: 'p', jsonSchema: schema }, 'k'); + expect(create.mock.calls[0][0].response_format).toEqual({ + type: 'json_object', + }); + }); + + it('retries once on schema validation failure', async () => { + create + .mockResolvedValueOnce({ + choices: [{ message: { content: '{"price":42}' } }], + model: 'm', + }) + .mockResolvedValueOnce({ + choices: [{ message: { content: '{"price":"$5"}' } }], + model: 'm', + }); + const out = await callOrcaRouter({ prompt: 'p', jsonSchema: schema }, 'k'); + expect(out.values).toEqual({ price: '$5' }); + expect(create).toHaveBeenCalledTimes(2); + const retryReq = create.mock.calls[1][0]; + expect(retryReq.messages.length).toBeGreaterThan(1); + }); + + it('throws after retry still invalid', async () => { + create.mockResolvedValue({ + choices: [{ message: { content: '{"price":42}' } }], + model: 'm', + }); + await expect( + callOrcaRouter({ prompt: 'p', jsonSchema: schema }, 'k'), + ).rejects.toThrow(/schema/i); + expect(create).toHaveBeenCalledTimes(2); + }); + + it('throws on missing required field', async () => { + create.mockResolvedValue({ + choices: [{ message: { content: '{}' } }], + model: 'm', + }); + await expect( + callOrcaRouter({ prompt: 'p', jsonSchema: schema }, 'k'), + ).rejects.toThrow(); + }); +}); diff --git a/tests/unit/extraction/llm/select.test.ts b/tests/unit/extraction/llm/select.test.ts index 157dada8c..29030df96 100644 --- a/tests/unit/extraction/llm/select.test.ts +++ b/tests/unit/extraction/llm/select.test.ts @@ -12,10 +12,11 @@ describe('selectProvider', () => { ).toBe('anthropic'); }); - it('falls through to openai → gemini → groq', () => { + it('falls through to openai → gemini → groq → orcarouter', () => { expect(selectProvider({ OPENAI_API_KEY: 'x' })).toBe('openai'); expect(selectProvider({ GOOGLE_API_KEY: 'x' })).toBe('gemini'); expect(selectProvider({ GROQ_API_KEY: 'x' })).toBe('groq'); + expect(selectProvider({ ORCAROUTER_API_KEY: 'x' })).toBe('orcarouter'); }); it('WIGOLO_LLM_PROVIDER override forces a specific provider', () => { diff --git a/tests/unit/extraction/llm/types.test.ts b/tests/unit/extraction/llm/types.test.ts index d8ffe9dc1..7249259b2 100644 --- a/tests/unit/extraction/llm/types.test.ts +++ b/tests/unit/extraction/llm/types.test.ts @@ -9,7 +9,7 @@ import type { describe('llm types', () => { it('LLMProvider is union of supported providers', () => { expectTypeOf().toEqualTypeOf< - 'anthropic' | 'openai' | 'gemini' | 'groq' + 'anthropic' | 'openai' | 'gemini' | 'groq' | 'orcarouter' >(); }); diff --git a/tests/unit/integrations/cloud/llm/text-adapters-vision.test.ts b/tests/unit/integrations/cloud/llm/text-adapters-vision.test.ts index a0a3d74db..fbbc9feb7 100644 --- a/tests/unit/integrations/cloud/llm/text-adapters-vision.test.ts +++ b/tests/unit/integrations/cloud/llm/text-adapters-vision.test.ts @@ -29,7 +29,7 @@ vi.mock('groq-sdk', () => ({ }, })); -const { callAnthropicText, callOpenAIText, callGeminiText, callGroqText } = await import( +const { callAnthropicText, callOpenAIText, callGeminiText, callGroqText, callOrcaRouterText } = await import( '../../../../../src/integrations/cloud/llm/text-adapters.js' ); @@ -114,4 +114,23 @@ describe('text adapters — vision (image) input', () => { expect(body.messages).toEqual([{ role: 'user', content: 'plain' }]); }); }); + + describe('orcarouter', () => { + it('builds a multimodal content array with a data: url when image is present', async () => { + openaiCreate.mockResolvedValue({ choices: [{ message: { content: 'ok' } }], model: 'm' }); + await callOrcaRouterText({ prompt: 'describe', model: 'm', image: IMG }, 'k'); + const body = openaiCreate.mock.calls[0][0]; + expect(body.messages[0].content).toEqual([ + { type: 'text', text: 'describe' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,aGVsbG8=' } }, + ]); + }); + + it('sends bare string content when no image (byte-identical text path)', async () => { + openaiCreate.mockResolvedValue({ choices: [{ message: { content: 'ok' } }], model: 'm' }); + await callOrcaRouterText({ prompt: 'plain', model: 'm' }, 'k'); + const body = openaiCreate.mock.calls[0][0]; + expect(body.messages).toEqual([{ role: 'user', content: 'plain' }]); + }); + }); }); diff --git a/tests/unit/integrations/llm-runner.test.ts b/tests/unit/integrations/llm-runner.test.ts index aff611c77..113b05797 100644 --- a/tests/unit/integrations/llm-runner.test.ts +++ b/tests/unit/integrations/llm-runner.test.ts @@ -13,6 +13,7 @@ vi.mock('../../../src/integrations/cloud/llm/text-adapters.js', () => { openai: make('openai'), gemini: make('gemini'), groq: make('groq'), + orcarouter: make('orcarouter'), }, }; }); @@ -35,10 +36,12 @@ describe('runLlmText', () => { delete process.env.WIGOLO_LLM_MODEL_ANTHROPIC; delete process.env.WIGOLO_LLM_MODEL_OPENAI; delete process.env.WIGOLO_LLM_MODEL_GROQ; + delete process.env.WIGOLO_LLM_MODEL_ORCAROUTER; delete process.env.ANTHROPIC_API_KEY; delete process.env.OPENAI_API_KEY; delete process.env.GOOGLE_API_KEY; delete process.env.GROQ_API_KEY; + delete process.env.ORCAROUTER_API_KEY; }); afterEach(() => { process.env = originalEnv; @@ -131,6 +134,7 @@ describe('runLlmText — ollama alias', () => { delete process.env.OPENAI_API_KEY; delete process.env.GOOGLE_API_KEY; delete process.env.GROQ_API_KEY; + delete process.env.ORCAROUTER_API_KEY; }); afterEach(() => { process.env = originalEnv; @@ -222,6 +226,7 @@ describe('runLlmJson', () => { delete process.env.GOOGLE_API_KEY; delete process.env.OPENAI_API_KEY; delete process.env.GROQ_API_KEY; + delete process.env.ORCAROUTER_API_KEY; }); afterEach(() => { process.env = originalEnv; diff --git a/tests/unit/research/pipeline.llm-gate-keystore.test.ts b/tests/unit/research/pipeline.llm-gate-keystore.test.ts index 8e1d46cee..2c5be1106 100644 --- a/tests/unit/research/pipeline.llm-gate-keystore.test.ts +++ b/tests/unit/research/pipeline.llm-gate-keystore.test.ts @@ -88,6 +88,7 @@ const KEY_ENV_VARS = [ 'OPENAI_API_KEY', 'GOOGLE_API_KEY', 'GROQ_API_KEY', + 'ORCAROUTER_API_KEY', 'WIGOLO_LLM_PROVIDER', 'WIGOLO_LLM_API_KEY', ]; diff --git a/tests/unit/research/synthesis-local.test.ts b/tests/unit/research/synthesis-local.test.ts index 33c7a646f..c573e4bc3 100644 --- a/tests/unit/research/synthesis-local.test.ts +++ b/tests/unit/research/synthesis-local.test.ts @@ -349,6 +349,7 @@ describe('synthesizeLocal LLM gate is keystore-aware', () => { 'OPENAI_API_KEY', 'GOOGLE_API_KEY', 'GROQ_API_KEY', + 'ORCAROUTER_API_KEY', 'WIGOLO_LLM_PROVIDER', 'WIGOLO_LLM_API_KEY', 'WIGOLO_LLM_MODEL',