diff --git a/docs/configuration.md b/docs/configuration.md index 53800d610..bcf88ed9f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -99,11 +99,12 @@ 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). | +| `WIGOLO_LLM_PROVIDER` | unset | `anthropic`, `openai`, `gemini`, `groq`, `ollama` (any local OpenAI-compatible server), or `openai-compatible` (any **authenticated** remote OpenAI-compatible endpoint — see below). | | `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` / `GROQ_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_API_KEY` | unset | Generic key slot (used by `init --provider=...`; stored in the OS keychain, never passed as a flag). **Also the API key for the `openai-compatible` provider.** | | `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. | +| `WIGOLO_LLM_MODEL_OPENAI_COMPATIBLE` | `gpt-4o-mini` | Model for the `openai-compatible` provider (set this to your endpoint's model id). | +| `WIGOLO_LLM_BASE_URL` | `http://localhost:11434` | Custom base URL for the `ollama` **and** `openai-compatible` providers — point `openai-compatible` at your OpenAI-compatible `/v1` endpoint. | | `WIGOLO_LLM_CACHE_TTL_DAYS` | `7` | Cache lifetime for LLM outputs. | | `WIGOLO_LLM_MAX_CALLS_PER_REQUEST` | `1` | Hard cap on LLM calls per tool request. | diff --git a/src/cli/init.ts b/src/cli/init.ts index 26f9d737e..a394a9900 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', 'openai-compatible']; /** * One-line hint printed on a `--no-warmup` init: nothing was pre-downloaded, so @@ -59,7 +59,7 @@ const INIT_USAGE = [ ' --agents= Comma-separated agent ids to auto-wire (optional; omit to set up the engine only and point any MCP client at wigolo yourself)', ' --skip-verify Skip the post-install verify step', ' --plain Force plain (non-TUI) output', - ' --provider= LLM provider for research/agent: anthropic|openai|gemini|ollama', + ' --provider= LLM provider for research/agent: anthropic|openai|gemini|groq|ollama|openai-compatible', ' --search= Search backend: core|searxng|hybrid', ' --help, -h Show this message', '', diff --git a/src/cli/tui/flags.ts b/src/cli/tui/flags.ts index 4e8c9cab4..8de3d9ad8 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', 'openai-compatible'] 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/extraction/llm-fallback.ts b/src/extraction/llm-fallback.ts index 529ea7a6a..de2105962 100644 --- a/src/extraction/llm-fallback.ts +++ b/src/extraction/llm-fallback.ts @@ -1,6 +1,6 @@ import { getConfig } from '../config.js'; import { callAnthropic } from '../integrations/cloud/llm/anthropic.js'; -import { callOpenAI } from '../integrations/cloud/llm/openai.js'; +import { callOpenAI, callOpenAICompatible } from '../integrations/cloud/llm/openai.js'; import { callGemini } from '../integrations/cloud/llm/gemini.js'; import { callGroq } from '../integrations/cloud/llm/groq.js'; import { @@ -43,6 +43,7 @@ const ADAPTERS: Record< openai: callOpenAI, gemini: callGemini, groq: callGroq, + 'openai-compatible': callOpenAICompatible, }; export async function extractWithLLM( diff --git a/src/integrations/cloud/llm/model-select.ts b/src/integrations/cloud/llm/model-select.ts index 21de3ac35..3464a4a03 100644 --- a/src/integrations/cloud/llm/model-select.ts +++ b/src/integrations/cloud/llm/model-select.ts @@ -11,6 +11,8 @@ const PROVIDER_DEFAULTS: Record = { openai: 'gpt-4o-mini', gemini: 'gemini-2.5-flash-lite', groq: 'llama-3.3-70b-versatile', + // Generic default; override per-endpoint with WIGOLO_LLM_MODEL_OPENAI_COMPATIBLE. + 'openai-compatible': 'gpt-4o-mini', }; const PROVIDER_ENV: Record = { @@ -18,6 +20,7 @@ const PROVIDER_ENV: Record = { openai: 'WIGOLO_LLM_MODEL_OPENAI', gemini: 'WIGOLO_LLM_MODEL_GEMINI', groq: 'WIGOLO_LLM_MODEL_GROQ', + 'openai-compatible': 'WIGOLO_LLM_MODEL_OPENAI_COMPATIBLE', }; export function resolveModel( diff --git a/src/integrations/cloud/llm/openai.ts b/src/integrations/cloud/llm/openai.ts index 07a10c391..6896b30ca 100644 --- a/src/integrations/cloud/llm/openai.ts +++ b/src/integrations/cloud/llm/openai.ts @@ -1,4 +1,5 @@ import OpenAI from 'openai'; +import { getConfig } from '../../../config.js'; import type { LLMCallOpts, LLMExtractResult } from './types.js'; const DEFAULT_MODEL = 'gpt-4o-mini'; @@ -47,3 +48,56 @@ export async function callOpenAI( latencyMs: Date.now() - start, }; } + +export async function callOpenAICompatible( + opts: LLMCallOpts, + apiKey: string, +): Promise { + // OpenAI-compatible endpoint via WIGOLO_LLM_BASE_URL, with the API key sent + // as a Bearer token (unlike the keyless ollama/custom backend). + const baseURL = getConfig().llmBaseUrl ?? undefined; + if (!baseURL) { + throw new Error( + 'openai-compatible: WIGOLO_LLM_BASE_URL is not set; point it at your OpenAI-compatible /v1 endpoint', + ); + } + const client = new OpenAI({ apiKey, baseURL }); + const model = opts.modelOverride ?? DEFAULT_MODEL; + const start = Date.now(); + + const response = await client.chat.completions.create( + { + model, + messages: [{ role: 'user', content: opts.prompt }], + response_format: { + type: 'json_schema', + json_schema: { + name: 'extract', + schema: opts.jsonSchema, + strict: true, + }, + }, + }, + { signal: opts.signal }, + ); + + const content = response.choices?.[0]?.message?.content; + if (!content) { + throw new Error('openai-compatible: empty content in response'); + } + + let values: Record; + try { + values = JSON.parse(content); + } catch (e) { + throw new Error(`openai-compatible: invalid JSON in response: ${(e as Error).message}`); + } + + return { + values, + provider: 'openai-compatible', + model: response.model ?? model, + cached: false, + latencyMs: Date.now() - start, + }; +} diff --git a/src/integrations/cloud/llm/select.ts b/src/integrations/cloud/llm/select.ts index 3b52d1e07..10c0c5b2e 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', 'openai-compatible']; const PROVIDER_ENV: Record = { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', gemini: 'GEMINI_API_KEY', groq: 'GROQ_API_KEY', + 'openai-compatible': 'WIGOLO_LLM_API_KEY', }; // Extra env var names accepted for a provider's key, beyond the canonical one @@ -19,6 +20,15 @@ const PROVIDER_ENV_ALIASES: Partial> = { gemini: ['GOOGLE_API_KEY'], }; +// Providers that have NO provider-specific key var — their key is the generic +// WIGOLO_LLM_API_KEY. Because that var is ambiguous without an explicit provider +// (see issue #102), these providers are NEVER auto-detected; they are only +// selected when WIGOLO_LLM_PROVIDER names them explicitly. `openai-compatible` +// is the only such provider today. +const EXPLICIT_ONLY_PROVIDERS: ReadonlySet = new Set([ + 'openai-compatible', +]); + /** Read a provider's API key from env, accepting the canonical var or an alias. */ export function providerKeyFromEnv( p: LLMProvider, @@ -43,8 +53,11 @@ export function selectProvider( if (providerKeyFromEnv(p, env) || env.WIGOLO_LLM_API_KEY) return p; } // Auto-detect: WIGOLO_LLM_API_KEY is ambiguous without an explicit provider, - // so it is intentionally NOT consulted in this loop. + // so it is intentionally NOT consulted in this loop. Explicit-only providers + // (e.g. openai-compatible, whose key IS WIGOLO_LLM_API_KEY) are skipped so a + // bare generic key never auto-selects them. for (const p of PROVIDER_ORDER) { + if (EXPLICIT_ONLY_PROVIDERS.has(p)) continue; if (providerKeyFromEnv(p, env)) return p; } return null; @@ -89,8 +102,10 @@ export async function selectProviderWithKeyStore( // Explicit provider specified but key not found — fall through to auto-detect } - // Auto-detect: first provider with any key (keychain → file → env) + // Auto-detect: first provider with any key (keychain → file → env). + // Explicit-only providers are skipped (see EXPLICIT_ONLY_PROVIDERS above). for (const p of PROVIDER_ORDER) { + if (EXPLICIT_ONLY_PROVIDERS.has(p)) continue; const key = await resolveProviderKey(p, opts); if (key) return { provider: p, key }; } diff --git a/src/integrations/cloud/llm/text-adapters.ts b/src/integrations/cloud/llm/text-adapters.ts index 6fe93bf00..d7f8f573f 100644 --- a/src/integrations/cloud/llm/text-adapters.ts +++ b/src/integrations/cloud/llm/text-adapters.ts @@ -8,6 +8,7 @@ // MCP server startup (caught by the cold-start e2e timing test). import type { LLMProvider } from './types.js'; +import { getConfig } from '../../../config.js'; /** Optional image attached to a text call, for vision-capable models. */ export interface TextCallImage { @@ -165,6 +166,40 @@ export async function callGroqText(opts: TextCallOpts, apiKey: string): Promise< }; } +export async function callOpenAICompatibleText(opts: TextCallOpts, apiKey: string): Promise { + const { default: OpenAI } = await import('openai'); + // Routed to an arbitrary OpenAI-compatible endpoint via WIGOLO_LLM_BASE_URL + // (e.g. OpenRouter, DeepSeek, SensNova, a self-hosted vLLM). Unlike the + // keyless `ollama`/custom backend, this provider sends the API key as a + // Bearer token so authenticated remote endpoints work. + const baseURL = getConfig().llmBaseUrl ?? undefined; + if (!baseURL) { + throw new Error( + 'openai-compatible: WIGOLO_LLM_BASE_URL is not set; point it at your OpenAI-compatible /v1 endpoint', + ); + } + const client = new OpenAI({ apiKey, baseURL }); + const start = Date.now(); + const response = await client.chat.completions.create( + { + model: opts.model, + max_completion_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS, + messages: [{ role: 'user', content: opts.prompt }], + }, + { signal: opts.signal }, + ); + const text = response.choices?.[0]?.message?.content; + if (typeof text !== 'string' || text.trim().length === 0) { + throw new Error('openai-compatible: empty content in response'); + } + return { + text, + provider: 'openai-compatible', + model: response.model ?? opts.model, + latencyMs: Date.now() - start, + }; +} + export const TEXT_ADAPTERS: Record< LLMProvider, (opts: TextCallOpts, apiKey: string) => Promise @@ -173,4 +208,5 @@ export const TEXT_ADAPTERS: Record< openai: callOpenAIText, gemini: callGeminiText, groq: callGroqText, + 'openai-compatible': callOpenAICompatibleText, }; diff --git a/src/integrations/cloud/llm/types.ts b/src/integrations/cloud/llm/types.ts index 98cc0a721..8b3c98d1b 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' | 'openai-compatible'; export interface LLMExtractResult { values: Record; diff --git a/src/research/synthesis-local.ts b/src/research/synthesis-local.ts index c6a91f6cc..bad7f559c 100644 --- a/src/research/synthesis-local.ts +++ b/src/research/synthesis-local.ts @@ -6,7 +6,11 @@ const log = createLogger('research'); const DEFAULT_MAX_SOURCES = 8; const DEFAULT_MAX_CHARS_PER_SOURCE = 4000; const DEFAULT_TIMEOUT_MS = 60_000; -const DEFAULT_MAX_TOKENS = 3000; +// 8k tokens of headroom: reasoning-capable models (e.g. some OpenAI-compatible +// endpoints) spend part of the budget on a hidden `reasoning` field and can +// otherwise return empty `content`, which the adapter rejects and wigolo +// downgrades to the heuristic fallback. +const DEFAULT_MAX_TOKENS = 8000; export interface LocalSynthesisOptions { maxSources?: number; diff --git a/src/security/key-store.ts b/src/security/key-store.ts index 7866866b8..15eed289d 100644 --- a/src/security/key-store.ts +++ b/src/security/key-store.ts @@ -48,7 +48,7 @@ export const PICKER_PROVIDERS: ReadonlyArray = [ ]; // All providers that can have keystore entries (including groq via env only) -const STORE_PROVIDERS: ReadonlyArray = ['anthropic', 'openai', 'gemini', 'groq']; +const STORE_PROVIDERS: ReadonlyArray = ['anthropic', 'openai', 'gemini', 'groq', 'openai-compatible']; /** * Process-lifetime memo for resolveProviderKey, keyed by `provider:dataDir`. diff --git a/tests/unit/extraction/llm-fallback.test.ts b/tests/unit/extraction/llm-fallback.test.ts index 8f0ea09a6..cc80c2bfe 100644 --- a/tests/unit/extraction/llm-fallback.test.ts +++ b/tests/unit/extraction/llm-fallback.test.ts @@ -7,6 +7,7 @@ vi.mock('../../../src/integrations/cloud/llm/anthropic.js', () => ({ })); vi.mock('../../../src/integrations/cloud/llm/openai.js', () => ({ callOpenAI: vi.fn(), + callOpenAICompatible: vi.fn(), })); vi.mock('../../../src/integrations/cloud/llm/gemini.js', () => ({ callGemini: vi.fn(), diff --git a/tests/unit/extraction/llm/select.test.ts b/tests/unit/extraction/llm/select.test.ts index 157dada8c..0f807d32f 100644 --- a/tests/unit/extraction/llm/select.test.ts +++ b/tests/unit/extraction/llm/select.test.ts @@ -59,6 +59,15 @@ describe('selectProvider', () => { expect(selectProvider({ WIGOLO_LLM_API_KEY: 'x' })).toBeNull(); }); + it('selects openai-compatible only when WIGOLO_LLM_PROVIDER names it explicitly', () => { + expect( + selectProvider({ + WIGOLO_LLM_PROVIDER: 'openai-compatible', + WIGOLO_LLM_API_KEY: 'x', + }), + ).toBe('openai-compatible'); + }); + it('provider-specific var wins over WIGOLO_LLM_API_KEY', () => { expect( selectProvider({ diff --git a/tests/unit/extraction/llm/types.test.ts b/tests/unit/extraction/llm/types.test.ts index d8ffe9dc1..8438bf3d4 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' | 'openai-compatible' >(); });