-
-
Notifications
You must be signed in to change notification settings - Fork 363
feat(llm): support authenticated OpenAI-compatible endpoints (openai-compatible)
#276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Restore Line 143 rejects 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
Suggested change
🤖 Prompt for AI Agents |
||||||
| const VALID_SEARCH_BACKENDS = ['core', 'searxng', 'hybrid'] as const; | ||||||
|
|
||||||
| function parseInitOnlyFlags(args: readonly string[]): { provider?: string; search?: string; interactive: boolean; wizard: boolean; warmup: boolean } { | ||||||
|
|
||||||
| 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'; | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.tsRepository: 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\(' srcRepository: KnockOutEZ/wigolo Length of output: 12696 Use the selected extraction model for OpenAI-compatible fallback requests.
🤖 Prompt for AI Agents |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 testsRepository: 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:
💡 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.
🤖 Prompt for AI Agents |
||
| }, | ||
| { 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, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 testsRepository: 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) || trueRepository: KnockOutEZ/wigolo Length of output: 4432 🌐 Web query:
💡 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
Proposed fix- max_completion_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
+ max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| 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> | ||||||||||||||||||||||||||||||
|
|
@@ -173,4 +208,5 @@ export const TEXT_ADAPTERS: Record< | |||||||||||||||||||||||||||||
| openai: callOpenAIText, | ||||||||||||||||||||||||||||||
| gemini: callGeminiText, | ||||||||||||||||||||||||||||||
| groq: callGroqText, | ||||||||||||||||||||||||||||||
| 'openai-compatible': callOpenAICompatibleText, | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.