Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `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. |

Expand Down
4 changes: 2 additions & 2 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -59,7 +59,7 @@ const INIT_USAGE = [
' --agents=<csv> 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=<name> LLM provider for research/agent: anthropic|openai|gemini|ollama',
' --provider=<name> LLM provider for research/agent: anthropic|openai|gemini|groq|ollama|openai-compatible',
' --search=<backend> Search backend: core|searxng|hybrid',
' --help, -h Show this message',
'',
Expand Down
2 changes: 1 addition & 1 deletion src/cli/tui/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ function parseCommon(args: readonly string[], known: ReadonlySet<string>): Raw {
return raw;
}

const VALID_PROVIDERS = ['anthropic', 'openai', 'gemini', 'ollama'] as const;
const VALID_PROVIDERS = ['anthropic', 'openai', 'gemini', 'ollama', 'openai-compatible'] as const;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restore groq in VALID_PROVIDERS.

Line 143 rejects wigolo init --provider=groq. The init usage text and provider contract support groq. Add it to this allowlist.

Proposed fix
-const VALID_PROVIDERS = ['anthropic', 'openai', 'gemini', 'ollama', 'openai-compatible'] as const;
+const VALID_PROVIDERS = ['anthropic', 'openai', 'gemini', 'groq', 'ollama', 'openai-compatible'] as const;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const VALID_PROVIDERS = ['anthropic', 'openai', 'gemini', 'ollama', 'openai-compatible'] as const;
const VALID_PROVIDERS = ['anthropic', 'openai', 'gemini', 'groq', 'ollama', 'openai-compatible'] as const;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/tui/flags.ts` at line 143, Update the VALID_PROVIDERS allowlist to
include 'groq', preserving the existing provider entries and validation behavior
so --provider=groq is accepted by init.

const VALID_SEARCH_BACKENDS = ['core', 'searxng', 'hybrid'] as const;

function parseInitOnlyFlags(args: readonly string[]): { provider?: string; search?: string; interactive: boolean; wizard: boolean; warmup: boolean } {
Expand Down
3 changes: 2 additions & 1 deletion src/extraction/llm-fallback.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -43,6 +43,7 @@ const ADAPTERS: Record<
openai: callOpenAI,
gemini: callGemini,
groq: callGroq,
'openai-compatible': callOpenAICompatible,
};

export async function extractWithLLM(
Expand Down
3 changes: 3 additions & 0 deletions src/integrations/cloud/llm/model-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ const PROVIDER_DEFAULTS: Record<LLMProvider, string> = {
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<LLMProvider, string> = {
anthropic: 'WIGOLO_LLM_MODEL_ANTHROPIC',
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(
Expand Down
54 changes: 54 additions & 0 deletions src/integrations/cloud/llm/openai.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,3 +48,56 @@ export async function callOpenAI(
latencyMs: Date.now() - start,
};
}

export async function callOpenAICompatible(
opts: LLMCallOpts,
apiKey: string,
): Promise<LLMExtractResult> {
// 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;
Comment on lines +64 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/integrations/cloud/llm --items all --type function
rg -n -C 4 'WIGOLO_LLM_MODEL_OPENAI_COMPATIBLE|modelOverride|callOpenAICompatible|modelId' \
  src/integrations/cloud/llm src/extraction src/config.ts

Repository: KnockOutEZ/wigolo

Length of output: 16733


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n=== openai.ts relevant implementation ===\n'
cat -n src/integrations/cloud/llm/openai.ts | sed -n '1,120p'

printf '\n=== llm-fallback.ts relevant implementation ===\n'
cat -n src/extraction/llm-fallback.ts | sed -n '1,150p'

printf '\n=== model-select.ts relevant implementation ===\n'
cat -n src/integrations/cloud/llm/model-select.ts | sed -n '1,80p'

printf '\n=== all callOpenAICompatible call sites ===\n'
rg -n -C 3 'callOpenAICompatible\(' src

printf '\n=== ExtractWithLLM resolved provider/model call sites ===\n'
rg -n -C 4 'extractWithLLM\(' src

Repository: KnockOutEZ/wigolo

Length of output: 12696


Use the selected extraction model for OpenAI-compatible fallback requests.

extractWithLLM resolves the provider and uses provider:default for cache identity, but the adapter receives no modelOverride. For OpenAI-compatible extraction, import resolveModel and pass WIGOLO_LLM_MODEL_OPENAI_COMPATIBLE/fallback values so the OpenAI-compatible model is requested and cached under a model-stable ID.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/integrations/cloud/llm/openai.ts` around lines 64 - 65, Update
extractWithLLM and the OpenAI-compatible adapter flow to import and use
resolveModel, passing the resolved WIGOLO_LLM_MODEL_OPENAI_COMPATIBLE value or
its existing fallback as modelOverride. Ensure the selected model is used for
the OpenAI request and reflected in the cache identity instead of
provider:default.

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,
},
},
Comment on lines +72 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/integrations/cloud/llm/openai.ts --match callOpenAICompatible --view expanded
rg -n -C 4 --glob '*.ts' \
  'callOpenAICompatible|response_format|json_schema|json_object' src tests

Repository: KnockOutEZ/wigolo

Length of output: 9584


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== openai.ts relevant implementation =="
cat -n src/integrations/cloud/llm/openai.ts | sed -n '1,130p'

echo
echo "== text adapters relevant implementation =="
cat -n src/integrations/cloud/llm/text-adapters.ts | sed -n '150,220p'

echo
echo "== all WIGOLO_LLM_BASE_URL / openai-compatible references =="
rg -n -C 3 'WIGOLO_LLM_BASE_URL|openai-compatible|OpenAI-compatible|DeepSeek|response_format|json_schema|json_object' src tests README.md docs 2>/dev/null || true

echo
echo "== validate.ts =="
cat -n src/integrations/cloud/llm/validate.ts | sed -n '1,160p'

Repository: KnockOutEZ/wigolo

Length of output: 42977


🌐 Web query:

DeepSeek API create chat completion response_format json_object response_format documentation

💡 Result:

The DeepSeek API supports a JSON output mode that ensures the model generates valid JSON strings [1][2]. To use this feature, you must configure the response_format parameter in your Chat Completions API request [1][3]. Key requirements and implementation details: Parameter Configuration: Set the response_format parameter to {"type": "json_object"} [1][2]. The default value for this parameter is "text" [1]. Prompt Guidance: It is mandatory to explicitly instruct the model to produce JSON in your system or user message [1][2]. You should include the word "json" in your prompt and provide an example of the desired JSON structure [2]. Failing to provide this instruction may cause the model to generate unintended whitespace or experience processing delays, as it will attempt to satisfy the JSON requirement without clear formatting guidance [1][4]. Token Management: Ensure the max_tokens parameter is set to an appropriate limit to prevent the generated JSON string from being truncated prematurely [2][3]. The JSON Output feature is designed to be compatible with the OpenAI API format, facilitating integration into existing workflows [3][5]. When enabled, the model output can be parsed directly as a JSON object [1][2]. Additionally, be aware that the API may occasionally return empty content when using this mode, in which case modifying your prompt may help [2].

Citations:


Avoid requiring Structured Outputs from every OpenAI-compatible endpoint.

callOpenAICompatible always sends response_format.type = "json_schema" with strict: true, but documented OpenAI-compatible providers such as DeepSeek expose JSON mode as response_format.type = "json_object". This makes the advertised provider incompatible for extraction. Add endpoint capabilities and either use json_object with local schema validation or reject endpoints that do not support Structured Outputs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/integrations/cloud/llm/openai.ts` around lines 72 - 79, Update
callOpenAICompatible to avoid unconditionally requiring Structured Outputs: add
endpoint capability handling and use the provider-supported response_format,
including json_object for endpoints such as DeepSeek, with local opts.jsonSchema
validation when needed; otherwise reject endpoints lacking Structured Outputs
with a clear error.

},
{ signal: opts.signal },
);

const content = response.choices?.[0]?.message?.content;
if (!content) {
throw new Error('openai-compatible: empty content in response');
}

let values: Record<string, unknown>;
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,
};
}
21 changes: 18 additions & 3 deletions src/integrations/cloud/llm/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LLMProvider, string> = {
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
Expand All @@ -19,6 +20,15 @@ const PROVIDER_ENV_ALIASES: Partial<Record<LLMProvider, readonly string[]>> = {
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<LLMProvider> = new Set<LLMProvider>([
'openai-compatible',
]);

/** Read a provider's API key from env, accepting the canonical var or an alias. */
export function providerKeyFromEnv(
p: LLMProvider,
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
}
Expand Down
36 changes: 36 additions & 0 deletions src/integrations/cloud/llm/text-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -165,6 +166,40 @@ export async function callGroqText(opts: TextCallOpts, apiKey: string): Promise<
};
}

export async function callOpenAICompatibleText(opts: TextCallOpts, apiKey: string): Promise<TextCallResult> {
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 },
Comment on lines +183 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/integrations/cloud/llm/text-adapters.ts \
  --match callOpenAICompatibleText --view expanded
rg -n -C 4 --glob '*.ts' \
  'callOpenAICompatibleText|max_completion_tokens|max_tokens|openai-compatible' src tests

Repository: KnockOutEZ/wigolo

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '80,105p' src/integrations/cloud/llm/text-adapters.ts
sed -n '140,170p' src/integrations/cloud/llm/text-adapters.ts
sed -n '165,192p' src/integrations/cloud/llm/text-adapters.ts

echo "Package openai version:"
node - <<'JS'
try {
  const fs = require('fs');
  const p = JSON.parse(fs.readFileSync('package.json', 'utf8'));
  for (const k of ['dependencies','devDependencies','packageManager']) {
    console.log(k, p[k] || '<not set>');
  }
} catch (e) { console.log('package.json unavailable:', e.message); }
JS

echo "Lockfile references:"
(grep -n '"openai"' package-lock.json 2>/dev/null || true)
(grep -n '"openai"' pnpm-lock.yaml 2>/dev/null || true)
(grep -n 'openai@' yarn.lock 2>/dev/null || true) || true

Repository: KnockOutEZ/wigolo

Length of output: 4432


🌐 Web query:

DeepSeek API chat completions max_tokens max_completion_tokens documentation

💡 Result:

The DeepSeek API uses max_tokens as the parameter to limit the maximum number of tokens generated in a chat completion [1][2][3]. It does not support the max_completion_tokens parameter used by some other providers (such as OpenAI's o-series models) [4][5]. If an application sends max_completion_tokens to the DeepSeek API, it will typically be ignored or rejected, which can lead to issues where token limits are not respected [5]. To ensure compatibility with the DeepSeek API, applications should remap any max_completion_tokens inputs to max_tokens [4][5]. Key details regarding max_tokens: - Function: Caps the number of tokens generated in the completion [1][3]. - Context Limit: The total number of input tokens plus generated tokens remains subject to the model's overall context window limit [1][3]. - Limits: While the default is often 4096, some configurations or beta endpoints allow for higher limits (e.g., up to 8192) [2][6]. - Compatibility: When using tools or SDKs designed for OpenAI/Anthropic, developers often need to explicitly handle this field mapping to maintain compatibility [7][4][5].

Citations:


Use max_tokens for the generic compatible limit.

callOpenAICompatibleText targets arbitrary WIGOLO_LLM_BASE_URL backends, including DeepSeek-compatible endpoints. These backends may reject or ignore max_completion_tokens, so set the OpenAI-compatible token cap with max_tokens; keep max_completion_tokens for adapters that target providers that support it.

Proposed fix
-      max_completion_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
+      max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 response = await client.chat.completions.create(
{
model: opts.model,
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
messages: [{ role: 'user', content: opts.prompt }],
},
{ signal: opts.signal },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/integrations/cloud/llm/text-adapters.ts` around lines 183 - 189, Update
callOpenAICompatibleText’s chat.completions.create request to use max_tokens for
the generic compatible token limit, while retaining max_completion_tokens in
provider-specific adapters that support it.

);
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<TextCallResult>
Expand All @@ -173,4 +208,5 @@ export const TEXT_ADAPTERS: Record<
openai: callOpenAIText,
gemini: callGeminiText,
groq: callGroqText,
'openai-compatible': callOpenAICompatibleText,
};
2 changes: 1 addition & 1 deletion src/integrations/cloud/llm/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
Expand Down
6 changes: 5 additions & 1 deletion src/research/synthesis-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/security/key-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const PICKER_PROVIDERS: ReadonlyArray<LLMProvider | 'custom'> = [
];

// All providers that can have keystore entries (including groq via env only)
const STORE_PROVIDERS: ReadonlyArray<LLMProvider> = ['anthropic', 'openai', 'gemini', 'groq'];
const STORE_PROVIDERS: ReadonlyArray<LLMProvider> = ['anthropic', 'openai', 'gemini', 'groq', 'openai-compatible'];

/**
* Process-lifetime memo for resolveProviderKey, keyed by `provider:dataDir`.
Expand Down
1 change: 1 addition & 0 deletions tests/unit/extraction/llm-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/extraction/llm/select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/extraction/llm/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
describe('llm types', () => {
it('LLMProvider is union of supported providers', () => {
expectTypeOf<LLMProvider>().toEqualTypeOf<
'anthropic' | 'openai' | 'gemini' | 'groq'
'anthropic' | 'openai' | 'gemini' | 'groq' | 'openai-compatible'
>();
});

Expand Down