feat: harden intelligence runtime and llm providers

This commit is contained in:
2026-05-16 21:18:34 +02:00
parent 7e85a54c32
commit 85f97bb2a6
22 changed files with 732 additions and 201 deletions

View File

@@ -9,6 +9,7 @@ import { MiniMaxProvider } from "./minimax.mjs";
import { MistralProvider } from "./mistral.mjs";
import { OllamaProvider } from "./ollama.mjs";
import { GrokProvider } from "./grok.mjs";
import { OpenAICompatibleProvider } from "./openai-compatible.mjs";
export { LLMProvider } from "./provider.mjs";
export { AnthropicProvider } from "./anthropic.mjs";
@@ -20,6 +21,7 @@ export { MiniMaxProvider } from "./minimax.mjs";
export { MistralProvider } from "./mistral.mjs";
export { OllamaProvider } from "./ollama.mjs";
export { GrokProvider } from "./grok.mjs";
export { OpenAICompatibleProvider } from "./openai-compatible.mjs";
/**
* Create an LLM provider based on config.
@@ -30,14 +32,42 @@ export function createLLMProvider(llmConfig) {
if (!llmConfig?.provider) return null;
const { provider, apiKey, model } = llmConfig;
const common = {
apiKey,
model,
baseUrl: llmConfig.baseUrl,
temperature: llmConfig.temperature,
maxTokens: llmConfig.maxTokens,
timeoutMs: llmConfig.timeoutMs,
openRouterSiteUrl: llmConfig.openRouterSiteUrl,
openRouterAppName: llmConfig.openRouterAppName,
};
switch (provider.toLowerCase()) {
case "anthropic":
return new AnthropicProvider({ apiKey, model });
case "openai":
return new OpenAIProvider({ apiKey, model });
return new OpenAIProvider(common);
case "openai-compatible":
case "compatible":
case "local-openai":
return new OpenAICompatibleProvider({
...common,
name: provider.toLowerCase(),
model: model || 'local-model',
requiresApiKey: Boolean(apiKey),
});
case "lmstudio":
case "lm-studio":
return new OpenAICompatibleProvider({
...common,
name: 'lmstudio',
baseUrl: llmConfig.baseUrl || 'http://localhost:1234/v1',
model: model || 'local-model',
requiresApiKey: false,
});
case "openrouter":
return new OpenRouterProvider({ apiKey, model });
return new OpenRouterProvider(common);
case "gemini":
return new GeminiProvider({ apiKey, model });
case "codex":
@@ -47,7 +77,7 @@ export function createLLMProvider(llmConfig) {
case "mistral":
return new MistralProvider({ apiKey, model });
case "ollama":
return new OllamaProvider({ model, baseUrl: llmConfig.baseUrl });
return new OllamaProvider(common);
case 'grok':
return new GrokProvider({ apiKey, model });
default:

View File

@@ -1,49 +1,20 @@
// Ollama Provider — raw fetch, no SDK
// Uses Ollama's OpenAI-compatible Chat Completions API
// No API key required — fully local inference
// Ollama Provider — OpenAI-compatible Chat Completions API
import { LLMProvider } from './provider.mjs';
import { OpenAICompatibleProvider } from './openai-compatible.mjs';
export class OllamaProvider extends LLMProvider {
export class OllamaProvider extends OpenAICompatibleProvider {
constructor(config) {
super(config);
this.name = 'ollama';
this.baseUrl = (config.baseUrl || 'http://localhost:11434').replace(/\/+$/, '');
this.model = config.model || 'llama3.1:8b';
}
get isConfigured() { return !!this.model; }
async complete(systemPrompt, userMessage, opts = {}) {
const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.model,
max_tokens: opts.maxTokens || 4096,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
}),
signal: AbortSignal.timeout(opts.timeout || 120000),
const rawBaseUrl = config.baseUrl || 'http://localhost:11434';
const baseUrl = rawBaseUrl.replace(/\/+$/, '').endsWith('/v1')
? rawBaseUrl
: `${rawBaseUrl.replace(/\/+$/, '')}/v1`;
super({
...config,
name: 'ollama',
baseUrl,
model: config.model || 'llama3.1:8b',
requiresApiKey: false,
timeoutMs: config.timeoutMs || 120000,
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`Ollama API ${res.status}: ${err.substring(0, 200)}`);
}
const data = await res.json();
const text = data.choices?.[0]?.message?.content || '';
return {
text,
usage: {
inputTokens: data.usage?.prompt_tokens || 0,
outputTokens: data.usage?.completion_tokens || 0,
},
model: data.model || this.model,
};
}
}

View File

@@ -0,0 +1,77 @@
// OpenAI-compatible provider for OpenRouter, LM Studio, Ollama, Groq, xAI, and local endpoints.
import { LLMProvider } from './provider.mjs';
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
export class OpenAICompatibleProvider extends LLMProvider {
constructor(config = {}) {
super(config);
this.name = config.name || 'openai-compatible';
this.apiKey = config.apiKey || null;
this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
this.model = config.model || 'gpt-4o-mini';
this.temperature = config.temperature ?? 0.2;
this.maxTokens = config.maxTokens || 2000;
this.timeoutMs = config.timeoutMs || 90000;
this.extraHeaders = config.extraHeaders || {};
this.requiresApiKey = config.requiresApiKey ?? true;
this.useMaxCompletionTokens = Boolean(config.useMaxCompletionTokens);
}
get isConfigured() {
return Boolean(this.model && (!this.requiresApiKey || this.apiKey));
}
get status() {
if (!this.model) return { state: 'misconfigured', reason: 'LLM_MODEL is required' };
if (this.requiresApiKey && !this.apiKey) return { state: 'misconfigured', reason: 'LLM_API_KEY is required' };
return { state: 'configured', provider: this.name, model: this.model, baseUrl: this.baseUrl };
}
async complete(systemPrompt, userMessage, opts = {}) {
const maxTokens = opts.maxTokens || this.maxTokens;
const timeout = opts.timeout || this.timeoutMs;
const body = {
model: this.model,
temperature: opts.temperature ?? this.temperature,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
};
body[this.useMaxCompletionTokens ? 'max_completion_tokens' : 'max_tokens'] = maxTokens;
const headers = {
'Content-Type': 'application/json',
...this.extraHeaders,
};
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
const res = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
const detail = err.substring(0, 300);
if (res.status === 401 || res.status === 403) throw new Error(`${this.name} auth failed (${res.status}): ${detail}`);
if (res.status === 408 || res.status === 429) throw new Error(`${this.name} rate limited or timed out (${res.status}): ${detail}`);
throw new Error(`${this.name} API ${res.status}: ${detail}`);
}
const data = await res.json();
const text = data.choices?.[0]?.message?.content || data.choices?.[0]?.text || '';
return {
text,
usage: {
inputTokens: data.usage?.prompt_tokens || data.usage?.input_tokens || 0,
outputTokens: data.usage?.completion_tokens || data.usage?.output_tokens || 0,
},
model: data.model || this.model,
};
}
}

View File

@@ -1,50 +1,15 @@
// OpenAI Provider — raw fetch, no SDK
// OpenAI Provider — OpenAI-compatible chat completions
import { LLMProvider } from './provider.mjs';
import { OpenAICompatibleProvider } from './openai-compatible.mjs';
export class OpenAIProvider extends LLMProvider {
export class OpenAIProvider extends OpenAICompatibleProvider {
constructor(config) {
super(config);
this.name = 'openai';
this.apiKey = config.apiKey;
this.model = config.model || 'gpt-5.4';
}
get isConfigured() { return !!this.apiKey; }
async complete(systemPrompt, userMessage, opts = {}) {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
max_completion_tokens: opts.maxTokens || 4096,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
}),
signal: AbortSignal.timeout(opts.timeout || 60000),
super({
...config,
name: 'openai',
baseUrl: config.baseUrl || 'https://api.openai.com/v1',
model: config.model || 'gpt-4o-mini',
useMaxCompletionTokens: true,
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`OpenAI API ${res.status}: ${err.substring(0, 200)}`);
}
const data = await res.json();
const text = data.choices?.[0]?.message?.content || '';
return {
text,
usage: {
inputTokens: data.usage?.prompt_tokens || 0,
outputTokens: data.usage?.completion_tokens || 0,
},
model: data.model || this.model,
};
}
}

View File

@@ -1,52 +1,20 @@
// OpenRouter Provider — raw fetch, no SDK
// OpenRouter Provider — OpenAI-compatible chat completions
import { LLMProvider } from './provider.mjs';
import { OpenAICompatibleProvider } from './openai-compatible.mjs';
export class OpenRouterProvider extends LLMProvider {
export class OpenRouterProvider extends OpenAICompatibleProvider {
constructor(config) {
super(config);
this.name = 'openrouter';
this.apiKey = config.apiKey;
this.model = config.model || 'openrouter/auto';
}
get isConfigured() { return !!this.apiKey; }
async complete(systemPrompt, userMessage, opts = {}) {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
super({
...config,
name: 'openrouter',
baseUrl: config.baseUrl || 'https://openrouter.ai/api/v1',
model: config.model || 'openrouter/free',
extraHeaders: {
'HTTP-Referer': 'https://github.com/calesthio/Crucix',
'X-Title': 'Crucix',
...(config.openRouterSiteUrl ? { 'HTTP-Referer': config.openRouterSiteUrl } : {}),
...(config.openRouterAppName ? { 'X-Title': config.openRouterAppName } : {}),
},
body: JSON.stringify({
model: this.model,
max_tokens: opts.maxTokens || 4096,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
}),
signal: AbortSignal.timeout(opts.timeout || 60000),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`OpenRouter API ${res.status}: ${err.substring(0, 200)}`);
}
const data = await res.json();
const text = data.choices?.[0]?.message?.content || '';
return {
text,
usage: {
inputTokens: data.usage?.prompt_tokens || 0,
outputTokens: data.usage?.completion_tokens || 0,
},
model: data.model || this.model,
};
}
}