Skip to content
Merged
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
4 changes: 0 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,3 @@ temp-videos/

# Terminal output cache
.terminal-output-cache/

/okegas
/okegas (1)
/okegas (2)
2 changes: 2 additions & 0 deletions backend/auth/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ export const ADMIN_ONLY_ROUTES = new Set([
'engine:opencode-provider-add',
'engine:opencode-provider-remove',
'engine:opencode-provider-toggle',
'engine:opencode-provider-update',
'engine:opencode-provider-update-options',
'engine:opencode-provider-fetch-models',
'engine:opencode-account-add',
'engine:opencode-account-switch',
'engine:opencode-account-delete',
Expand Down
12 changes: 12 additions & 0 deletions backend/database/queries/engine-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,18 @@ export const engineQueries = {
return db.prepare(`SELECT * FROM engine_providers WHERE id = ?`).get(id) as EngineProvider;
},

updateProvider(id: number, data: { name?: string; apiUrl?: string; options?: string }): void {
const db = getDatabase();
const sets: string[] = [];
const vals: (string | number)[] = [];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
if (data.apiUrl !== undefined) { sets.push('api_url = ?'); vals.push(data.apiUrl); }
if (data.options !== undefined) { sets.push('options = ?'); vals.push(data.options); }
if (sets.length === 0) return;
vals.push(id);
db.prepare(`UPDATE engine_providers SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
},

updateProviderOptions(id: number, options: string): void {
const db = getDatabase();
db.prepare(`UPDATE engine_providers SET options = ? WHERE id = ?`).run(options, id);
Expand Down
9 changes: 9 additions & 0 deletions backend/engine/adapters/opencode/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ export function generateOpenCodeProviderConfig(): OpenCodeProviderConfigResult {

for (const provider of providers) {
const activeAccount = engineQueries.getActiveAccount(provider.id);

// Custom providers (api_url set) are OpenAI-compatible endpoints that
// don't need env-var management — the config injection in server.ts
// handles npm + baseURL + model discovery from /v1/models.
if (provider.api_url) {
enabledProviders.push(provider.slug);
continue;
}

if (!activeAccount) continue;

enabledProviders.push(provider.slug);
Expand Down
11 changes: 10 additions & 1 deletion backend/engine/adapters/opencode/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,17 @@ export async function fetchOpenCodeModels(client: OpencodeClient): Promise<Engin

for (const provider of providers) {
const providerModels: Record<string, Model> = provider.models ?? {};
const modelOrder = (provider.options._modelOrder as string[]) ?? [];

for (const [, model] of Object.entries(providerModels)) {
const entries = modelOrder.length > 0
? [...Object.entries(providerModels)].sort((a, b) => {
const ai = modelOrder.indexOf(a[0]);
const bi = modelOrder.indexOf(b[0]);
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
})
: Object.entries(providerModels);

for (const [, model] of entries) {
models.push({
engine: {
type: 'opencode',
Expand Down
82 changes: 81 additions & 1 deletion backend/engine/adapters/opencode/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { join } from 'path';
import type { OpencodeClient } from '@opencode-ai/sdk';
import type { Subprocess } from 'bun';
import { getOpenCodeMcpConfig } from '../../../mcp';
import { settingsQueries } from '../../../database/queries';
import { engineQueries, settingsQueries } from '../../../database/queries';
import { generateOpenCodeProviderConfig } from './config';
import type { OpenCodeInlineAgent } from '$backend/subagents';
import { resolveBinaryWithRefresh } from '$backend/utils/cli';
Expand Down Expand Up @@ -195,6 +195,86 @@ async function spawnServer(key: string, spec: ServerConfigSpec): Promise<ServerI
if (Object.keys(mcpConfig).length > 0) mergedConfig.mcp = mcpConfig;
if (providerConfig.enabledProviders.length > 0) mergedConfig.enabled_providers = providerConfig.enabledProviders;
if (spec.inlineAgents && Object.keys(spec.inlineAgents).length > 0) mergedConfig.agent = spec.inlineAgents;

// Inject provider definitions for custom OpenAI-compatible providers
// (those with api_url set). Unlike models.dev catalog providers, custom
// providers need their npm + baseURL passed explicitly so the opencode
// server can discover models from their /v1/models endpoint.
const providerSection: Record<string, unknown> = {};
const opencodeProviders = engineQueries.getEnabledProviders('opencode');
for (const provider of opencodeProviders) {
if (!provider.api_url) continue;

const models: Record<string, { name: string; limit: { context: number; output: number } }> = {};
let defaultContext = 128000;
let defaultOutput = 16384;
let modelNames: Record<string, string> = {};
let hiddenSet = new Set<string>();
try {
const opts = JSON.parse(provider.options || '{}') as {
models?: string[];
modelNames?: Record<string, string>;
hiddenModels?: string[];
contextLimit?: number;
outputLimit?: number;
};
if (opts.contextLimit) defaultContext = opts.contextLimit;
if (opts.outputLimit) defaultOutput = opts.outputLimit;
if (opts.modelNames) modelNames = opts.modelNames;
if (opts.hiddenModels) hiddenSet = new Set(opts.hiddenModels);
} catch {
// malformed options — proceed to auto-discover
}

const addModel = (id: string) => {
models[id] = { name: modelNames[id] || id, limit: { context: defaultContext, output: defaultOutput } };
};

try {
const opts = JSON.parse(provider.options || '{}') as { models?: string[] };
if (opts.models && opts.models.length > 0) {
for (const id of opts.models) {
if (!hiddenSet.has(id)) addModel(id);
}
}
} catch {
// malformed options — proceed to auto-discover
}

// Auto-discover models from /v1/models if none stored in options
if (Object.keys(models).length === 0) {
try {
const baseUrl = provider.api_url.replace(/\/+$/, '');
const res = await fetch(`${baseUrl}/models`, {
signal: AbortSignal.timeout(3000),
});
if (res.ok) {
const body = await res.json() as { data?: { id: string }[] };
for (const m of body.data ?? []) {
addModel(m.id);
}
}
} catch {
// Provider may not be running — models will be empty.
}
}

let modelOrder: string[] | undefined;
try {
const o = JSON.parse(provider.options || '{}') as { models?: string[] };
if (o.models && o.models.length > 0) modelOrder = o.models.filter(id => !hiddenSet.has(id));
} catch { /* ignore */ }
const providerOptions: Record<string, unknown> = { baseURL: provider.api_url };
if (modelOrder) providerOptions._modelOrder = modelOrder;
providerSection[provider.slug] = {
npm: provider.npm || '@ai-sdk/openai-compatible',
name: provider.name,
options: providerOptions,
models: models,
};
}
if (Object.keys(providerSection).length > 0) mergedConfig.provider = providerSection;

const configContent = Object.keys(mergedConfig).length > 0 ? JSON.stringify(mergedConfig) : '{}';

const proc = Bun.spawn(args, {
Expand Down
49 changes: 49 additions & 0 deletions backend/ws/engine/opencode/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,26 @@ export const openCodeProviderHandler = createRouter()
return { success: true };
})

.http('engine:opencode-provider-update', {
data: t.Object({
id: t.Number(),
name: t.Optional(t.String()),
apiUrl: t.Optional(t.String()),
options: t.Optional(t.String()),
}),
response: t.Object({ success: t.Boolean() })
}, async ({ data }) => {
if (data.options !== undefined) {
try { JSON.parse(data.options); } catch { throw new Error('Invalid JSON for options'); }
}
engineQueries.updateProvider(data.id, {
name: data.name,
apiUrl: data.apiUrl,
options: data.options,
});
return { success: true };
})

.http('engine:opencode-provider-toggle', {
data: t.Object({ id: t.Number(), enabled: t.Boolean() }),
response: t.Object({ success: t.Boolean() })
Expand Down Expand Up @@ -250,6 +270,35 @@ export const openCodeProviderHandler = createRouter()
return { catalog, cachedAt: new Date().toISOString() };
})

// ═══════════════════════════════════════
// Model Discovery
// ═══════════════════════════════════════

.http('engine:opencode-provider-fetch-models', {
data: t.Object({ id: t.Number() }),
response: t.Object({
models: t.Array(t.Object({ id: t.String(), name: t.Optional(t.String()) }))
})
}, async ({ data }) => {
const providers = engineQueries.getProvidersWithAccounts('opencode');
const provider = providers.find(p => p.id === data.id);
if (!provider) throw new Error('Provider not found');
if (!provider.api_url) throw new Error('Provider has no API URL');

const baseUrl = provider.api_url.replace(/\/+$/, '');
const activeAccount = provider.accounts.find(a => a.is_active === 1) || provider.accounts[0];
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (activeAccount?.credential) {
headers['Authorization'] = `Bearer ${activeAccount.credential}`;
}

const res = await fetch(`${baseUrl}/models`, { headers, signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json() as { data?: { id: string; name?: string }[] };
const models = (body.data || []).map(m => ({ id: m.id, name: m.name }));
return { models };
})

// ═══════════════════════════════════════
// Server Restart
// ═══════════════════════════════════════
Expand Down
Loading
Loading