From 082492a012584bd8ed4fdbfe2729caeec81c3671 Mon Sep 17 00:00:00 2001 From: JunYanForFunny <220676682+JunYanForFunny@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:45:08 +0800 Subject: [PATCH 1/2] feat(llm): add `openai-compatible` provider type Add a generic OpenAI-compatible LLM provider so wigolo can synthesize against authenticated remote OpenAI-compatible endpoints (OpenRouter, DeepSeek, SensNova, self-hosted vLLM, etc.) using an API key. - types: extend LLMProvider union with 'openai-compatible' - select: register provider order + WIGOLO_LLM_API_KEY env; make it explicit-only so a bare WIGOLO_LLM_API_KEY is never auto-detected (preserves the #102 contract: the generic key is ambiguous without an explicit provider) - model-select: default model + WIGOLO_LLM_MODEL_OPENAI_COMPATIBLE env - text-adapters: callOpenAICompatibleText routes to WIGOLO_LLM_BASE_URL with the key as a Bearer token (unlike the keyless ollama/custom path) - openai.ts: callOpenAICompatible JSON-extraction adapter (same base_url) - llm-fallback/key-store/select/init/flags: recognize the provider end-to-end - docs/configuration.md: document the new provider + env vars - tests: update LLMProvider union assertion, add explicit-selection test, export callOpenAICompatible in the openai.js mock --- docs/configuration.md | 7 +-- src/cli/init.ts | 4 +- src/cli/tui/flags.ts | 2 +- src/extraction/llm-fallback.ts | 3 +- src/integrations/cloud/llm/model-select.ts | 3 ++ src/integrations/cloud/llm/openai.ts | 54 +++++++++++++++++++++ src/integrations/cloud/llm/select.ts | 21 ++++++-- src/integrations/cloud/llm/text-adapters.ts | 36 ++++++++++++++ src/integrations/cloud/llm/types.ts | 2 +- src/security/key-store.ts | 2 +- tests/unit/extraction/llm-fallback.test.ts | 1 + tests/unit/extraction/llm/select.test.ts | 9 ++++ tests/unit/extraction/llm/types.test.ts | 2 +- 13 files changed, 133 insertions(+), 13 deletions(-) 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/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' >(); }); From be60305316e2ed01cee54a8410e7531d58868d35 Mon Sep 17 00:00:00 2001 From: JunYanForFunny <220676682+JunYanForFunny@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:45:08 +0800 Subject: [PATCH 2/2] fix(research): raise local-synthesis token budget for reasoning models synthesis-local defaulted to 3000 completion tokens. Reasoning-capable models spend part of that budget on a hidden 'reasoning' field and can return empty 'content', which the adapter rejects and wigolo downgrades to the heuristic fallback. Bump the budget to 8000 so reasoning models have headroom to emit content. --- src/research/synthesis-local.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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;