-
-
Notifications
You must be signed in to change notification settings - Fork 359
feat: add OrcaRouter as a named LLM provider #302
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 |
|---|---|---|
|
|
@@ -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, | ||
|
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. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline src/integrations/cloud/llm/model-select.ts --items all
rg -n -C 4 'WIGOLO_LLM_MODEL_ORCAROUTER|modelOverride|callOrcaRouter|extractWithLLM' src testsRepository: KnockOutEZ/wigolo Length of output: 39669 🏁 Script executed: #!/bin/bash
set -euo pipefail
cat -n src/integrations/cloud/llm/model-select.ts
sed -n '1,150p' src/extraction/llm-fallback.ts
sed -n '1,230p' tests/unit/extraction/llm-fallback.test.ts
sed -n '1,120p' src/integrations/cloud/llm/types.tsRepository: KnockOutEZ/wigolo Length of output: 13622 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
fallback = Path("src/extraction/llm-fallback.ts").read_text()
select = Path("src/integrations/cloud/llm/model-select.ts").read_text()
adapters = {
name: Path(f"src/integrations/cloud/llm/{name}.ts").read_text()
for name in ("anthropic", "openai", "gemini", "groq", "orcarouter")
}
call = re.search(
r"result\s*=\s*await\s+ADAPTERS\[provider\]\((.*?)\n\s*\),\s*apiKey",
fallback,
re.S,
)
assert call, "adapter dispatch not found"
dispatch = call.group(1)
print("dispatch_has_modelOverride:", "modelOverride" in dispatch)
print("resolveModel_reads_orcarouter_env:",
"orcarouter: 'WIGOLO_LLM_MODEL_ORCAROUTER'" in select)
for name, source in adapters.items():
has_override = "opts.modelOverride" in source
print(f"{name}_uses_modelOverride:", has_override)
assert has_override, f"{name} does not consume modelOverride"
assert "WIGOLO_LLM_MODEL_ORCAROUTER" in select
assert "modelOverride" not in dispatch
PYRepository: KnockOutEZ/wigolo Length of output: 272 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '85,112p' src/extraction/llm-fallback.ts
rg -n 'const model = opts\.modelOverride|WIGOLO_LLM_MODEL_ORCAROUTER|resolveModel|ADAPTERS\[provider\]' \
src/integrations/cloud/llm src/extraction/llm-fallback.ts
python3 - <<'PY'
from pathlib import Path
fallback = Path("src/extraction/llm-fallback.ts").read_text().splitlines()
start = next(i for i, line in enumerate(fallback) if "ADAPTERS[provider]" in line)
dispatch = "\n".join(fallback[start:start + 4])
print(dispatch)
print("dispatch_has_modelOverride:", "modelOverride" in dispatch)
assert "modelOverride" not in dispatch
select = Path("src/integrations/cloud/llm/model-select.ts").read_text()
assert "orcarouter: 'WIGOLO_LLM_MODEL_ORCAROUTER'" in select
print("resolveModel_reads_orcarouter_env: True")
for name in ("anthropic", "openai", "gemini", "groq", "orcarouter"):
source = Path(f"src/integrations/cloud/llm/{name}.ts").read_text()
assert "opts.modelOverride" in source
print(f"{name}_uses_modelOverride: True")
PYRepository: KnockOutEZ/wigolo Length of output: 2272 Resolve and propagate the configured extraction model.
🤖 Prompt for AI Agents |
||
| }; | ||
|
|
||
| export async function extractWithLLM( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<LLMExtractResult> { | ||
| 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<string, unknown>; | ||
| raw: string; | ||
| responseModel: string | undefined; | ||
| } | ||
|
|
||
| async function runOnce( | ||
| client: OpenAI, | ||
| model: string, | ||
| messages: Array<{ role: 'user' | 'assistant'; content: string }>, | ||
| signal: AbortSignal | undefined, | ||
| ): Promise<CallOnceResult> { | ||
| 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<string, unknown>; | ||
| 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, unknown>): 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<string, unknown>, | ||
| model: string, | ||
| start: number, | ||
| ): LLMExtractResult { | ||
| return { | ||
| values, | ||
| provider: 'orcarouter', | ||
| model, | ||
| cached: false, | ||
| latencyMs: Date.now() - start, | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -165,6 +165,39 @@ export async function callGroqText(opts: TextCallOpts, apiKey: string): Promise< | |
| }; | ||
| } | ||
|
|
||
| export async function callOrcaRouterText(opts: TextCallOpts, apiKey: string): Promise<TextCallResult> { | ||
| 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; | ||
|
Comment on lines
+168
to
+180
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. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Document OrcaRouter vision support in
🤖 Prompt for AI Agents |
||
| 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<TextCallResult> | ||
|
|
@@ -173,4 +206,5 @@ export const TEXT_ADAPTERS: Record< | |
| openai: callOpenAIText, | ||
| gemini: callGeminiText, | ||
| groq: callGroqText, | ||
| orcarouter: callOrcaRouterText, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Document
WIGOLO_LLM_MODEL_ORCAROUTER.The provider table documents
ORCAROUTER_API_KEY, but it does not document the provider-specific model override stated in this PR. AddWIGOLO_LLM_MODEL_ORCAROUTERand describe its precedence relative toWIGOLO_LLM_MODEL.🤖 Prompt for AI Agents