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
3 changes: 2 additions & 1 deletion backend/database/queries/engine-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,11 @@ 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 {
updateProvider(id: number, data: { slug?: string; name?: string; apiUrl?: string; options?: string }): void {
const db = getDatabase();
const sets: string[] = [];
const vals: (string | number)[] = [];
if (data.slug !== undefined) { sets.push('slug = ?'); vals.push(data.slug); }
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); }
Expand Down
36 changes: 32 additions & 4 deletions backend/engine/adapters/opencode/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type { OpencodeClient } from '@opencode-ai/sdk';
import type { Subprocess } from 'bun';
import { getOpenCodeMcpConfig } from '../../../mcp';
import { engineQueries, settingsQueries } from '../../../database/queries';
import { generateOpenCodeProviderConfig } from './config';
import { generateOpenCodeProviderConfig, parseCredentialMap } from './config';
import type { OpenCodeInlineAgent } from '$backend/subagents';
import { resolveBinaryWithRefresh } from '$backend/utils/cli';
import { getEngineUserConfigDir } from '$backend/utils/paths';
Expand Down Expand Up @@ -205,29 +205,55 @@ async function spawnServer(key: string, spec: ServerConfigSpec): Promise<ServerI
for (const provider of opencodeProviders) {
if (!provider.api_url) continue;

// Resolve the active account's credential so we can (a) authenticate the
// endpoint and (b) substitute `${VAR}` placeholders in the base URL.
// Multi-secret providers (e.g. Cloudflare Workers AI, whose URL embeds
// ${CLOUDFLARE_ACCOUNT_ID} and whose bearer is CLOUDFLARE_API_KEY) store
// every secret as a JSON bundle; single-key providers store a raw string.
const activeAccount = engineQueries.getActiveAccount(provider.id);
let baseURL = provider.api_url;
let apiKey: string | undefined;
if (activeAccount?.credential) {
const credMap = parseCredentialMap(activeAccount.credential);
if (credMap) {
baseURL = baseURL.replace(/\$\{(\w+)\}/g, (_m, name) => credMap[name] ?? '');
const keys = Object.keys(credMap);
const tokenKey = keys.find(k => /API[_-]?(?:KEY|TOKEN)$/i.test(k)) ?? keys.find(k => /(?:KEY|TOKEN)$/i.test(k));
if (tokenKey) apiKey = credMap[tokenKey];
} else {
apiKey = activeAccount.credential;
}
}

const models: Record<string, { name: string; limit: { context: number; output: number } }> = {};
// Per-model limits live in `modelLimits`; legacy provider-level
// contextLimit/outputLimit remain a fallback for older rows.
let defaultContext = 128000;
let defaultOutput = 16384;
let modelLimits: Record<string, { context?: number; output?: number }> = {};
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[];
modelLimits?: Record<string, { context?: number; output?: number }>;
contextLimit?: number;
outputLimit?: number;
};
if (opts.contextLimit) defaultContext = opts.contextLimit;
if (opts.outputLimit) defaultOutput = opts.outputLimit;
if (opts.modelLimits) modelLimits = opts.modelLimits;
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 } };
const lim = modelLimits[id];
models[id] = { name: modelNames[id] || id, limit: { context: lim?.context || defaultContext, output: lim?.output || defaultOutput } };
};

try {
Expand All @@ -244,8 +270,9 @@ async function spawnServer(key: string, spec: ServerConfigSpec): Promise<ServerI
// 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 baseUrl = baseURL.replace(/\/+$/, '');
const res = await fetch(`${baseUrl}/models`, {
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
signal: AbortSignal.timeout(3000),
});
if (res.ok) {
Expand All @@ -264,7 +291,8 @@ async function spawnServer(key: string, spec: ServerConfigSpec): Promise<ServerI
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 };
const providerOptions: Record<string, unknown> = { baseURL };
if (apiKey) providerOptions.apiKey = apiKey;
if (modelOrder) providerOptions._modelOrder = modelOrder;
providerSection[provider.slug] = {
npm: provider.npm || '@ai-sdk/openai-compatible',
Expand Down
50 changes: 37 additions & 13 deletions backend/ws/engine/claude/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,34 @@ interface SetupProcess {
disposed: boolean;
phase: SetupPhase;
accountName: string;
/** When set, re-authenticate this existing account in place instead of creating a new one. */
reauthAccountId: number | null;
urlEmitted: boolean;
}

/**
* Persist a freshly captured OAuth token — either updating an existing account
* in place (re-authentication) or creating a new one. Always resets the Claude
* environment so the next stream reads the new credential.
*/
function persistClaudeAccount(entry: SetupProcess, token: string): { ok: true; accountId: number } | { ok: false; error: string } {
const provider = engineQueries.getProviderBySlug('claude-code', 'anthropic');
if (!provider) return { ok: false, error: 'Anthropic provider not found in DB' };

if (entry.reauthAccountId != null) {
const existing = engineQueries.getAccount(entry.reauthAccountId);
if (!existing) return { ok: false, error: 'Account to re-authenticate not found' };
engineQueries.updateAccountCredential(entry.reauthAccountId, token);
if (entry.accountName) engineQueries.renameAccount(entry.reauthAccountId, entry.accountName);
resetEnvironment();
return { ok: true, accountId: entry.reauthAccountId };
}

const account = engineQueries.createAccount(provider.id, entry.accountName, token);
resetEnvironment();
return { ok: true, accountId: account.id };
}

const setupProcesses = new Map<string, SetupProcess>();
const userSetups = new Map<string, string>();

Expand Down Expand Up @@ -226,6 +251,7 @@ export const accountsHandler = createRouter()
disposed: false,
phase: 'waiting-url',
accountName: '',
reauthAccountId: null,
urlEmitted: false
};
setupProcesses.set(setupId, entry);
Expand Down Expand Up @@ -262,21 +288,19 @@ export const accountsHandler = createRouter()
entry.phase = 'done';
debug.log('engine', `[${setupId}] Token captured`);

const provider = engineQueries.getProviderBySlug('claude-code', 'anthropic');
if (!provider) {
const result = persistClaudeAccount(entry, token);
if (!result.ok) {
ws.emit.user(userId, 'engine:claude-account-setup-error', {
setupId,
message: 'Anthropic provider not found in DB'
message: result.error
});
cleanupSetup(setupId);
return;
}
const account = engineQueries.createAccount(provider.id, entry.accountName, token);
resetEnvironment();

ws.emit.user(userId, 'engine:claude-account-setup-complete', {
setupId,
accountId: account.id
accountId: result.accountId
});

cleanupSetup(setupId);
Expand Down Expand Up @@ -316,20 +340,18 @@ export const accountsHandler = createRouter()
const token = extractOAuthToken(clean);
if (token) {
debug.log('engine', `[${setupId}] Token found in final buffer`);
const provider = engineQueries.getProviderBySlug('claude-code', 'anthropic');
if (!provider) {
const result = persistClaudeAccount(entry, token);
if (!result.ok) {
ws.emit.user(userId, 'engine:claude-account-setup-error', {
setupId,
message: 'Anthropic provider not found in DB'
message: result.error
});
cleanupSetup(setupId);
return;
}
const account = engineQueries.createAccount(provider.id, entry.accountName, token);
resetEnvironment();
ws.emit.user(userId, 'engine:claude-account-setup-complete', {
setupId,
accountId: account.id
accountId: result.accountId
});
} else {
// Try to extract a meaningful error message
Expand All @@ -350,7 +372,8 @@ export const accountsHandler = createRouter()
data: t.Object({
setupId: t.String(),
code: t.String(),
name: t.String({ minLength: 1 })
name: t.String({ minLength: 1 }),
reauthAccountId: t.Optional(t.Number())
})
}, async ({ data, conn }) => {
const userId = ws.getUserId(conn);
Expand All @@ -371,6 +394,7 @@ export const accountsHandler = createRouter()
entry.buffer = '';
entry.phase = 'waiting-token';
entry.accountName = data.name;
entry.reauthAccountId = data.reauthAccountId ?? null;

// Write auth code to PTY, then press Enter after a short delay
// The delay ensures the PTY input buffer has processed the code before receiving Enter
Expand Down
40 changes: 36 additions & 4 deletions backend/ws/engine/codex/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ interface CodexLoginProcess {
userId: string;
accountName: string;
deviceAuth: boolean;
/** When set, re-authenticate this existing account in place instead of creating a new one. */
reauthAccountId: number | null;
timer: ReturnType<typeof setTimeout>;
}

Expand Down Expand Up @@ -157,7 +159,7 @@ function isLoginSuccess(buffer: string): boolean {
return /Successfully logged in/i.test(buffer);
}

function persistChatGptLoginResult(accountName: string): { ok: true; accountId: number } | { ok: false; error: string } {
function persistChatGptLoginResult(accountName: string, reauthAccountId?: number | null): { ok: true; accountId: number } | { ok: false; error: string } {
const provider = engineQueries.getProviderBySlug('codex', 'openai');
if (!provider) return { ok: false, error: 'OpenAI Codex provider not found in DB' };

Expand All @@ -167,6 +169,17 @@ function persistChatGptLoginResult(accountName: string): { ok: true; accountId:
}

const credential = serializeCodexCredential({ kind: 'chatgpt', authJson });

// Re-authentication: replace the existing account's blob in place so its
// name and active state survive.
if (reauthAccountId != null) {
const existing = engineQueries.getAccount(reauthAccountId);
if (!existing) return { ok: false, error: 'Account to re-authenticate not found' };
engineQueries.updateAccountCredential(reauthAccountId, credential);
if (accountName) engineQueries.renameAccount(reauthAccountId, accountName);
return { ok: true, accountId: reauthAccountId };
}

const account = engineQueries.createAccount(provider.id, accountName, credential);
return { ok: true, accountId: account.id };
}
Expand Down Expand Up @@ -285,6 +298,23 @@ export const codexAccountsHandler = createRouter()
return { success: true };
})

// Replace the API key for an api_key-mode account. ChatGPT accounts have no
// editable key — they re-authenticate via the login flow instead.
.http('engine:codex-accounts-update-api-key', {
data: t.Object({ id: t.Number(), apiKey: t.String({ minLength: 1 }) }),
response: t.Object({ success: t.Boolean() })
}, async ({ data }) => {
const account = engineQueries.getAccount(data.id);
if (!account) throw new Error('Account not found');
if (authModeOf(account) !== 'api_key') {
throw new Error('Only API-key accounts can have their key edited; ChatGPT accounts re-authenticate instead');
}
engineQueries.updateAccountCredential(data.id, serializeCodexCredential({ kind: 'api_key', apiKey: data.apiKey.trim() }));
const active = engineQueries.getActiveAccountForEngine('codex');
if (active?.id === data.id) await disposeCodexEngines();
return { success: true };
})

// ─── ChatGPT browser OAuth flow ───
//
// Spawn `codex login` in a PTY (the CLI silently no-ops when stdout
Expand All @@ -294,7 +324,8 @@ export const codexAccountsHandler = createRouter()
.on('engine:codex-account-setup-start', {
data: t.Object({
name: t.String({ minLength: 1 }),
deviceAuth: t.Optional(t.Boolean())
deviceAuth: t.Optional(t.Boolean()),
reauthAccountId: t.Optional(t.Number())
})
}, async ({ data, conn }) => {
const userId = ws.getUserId(conn);
Expand Down Expand Up @@ -363,6 +394,7 @@ export const codexAccountsHandler = createRouter()
userId,
accountName: data.name.trim(),
deviceAuth: !!data.deviceAuth,
reauthAccountId: data.reauthAccountId ?? null,
timer,
};
setupProcesses.set(setupId, entry);
Expand Down Expand Up @@ -409,7 +441,7 @@ export const codexAccountsHandler = createRouter()
if (isLoginSuccess(clean) && !entry.completed) {
entry.completed = true;
debug.log('engine', `[${setupId}] Codex login success — persisting auth.json`);
const result = persistChatGptLoginResult(entry.accountName);
const result = persistChatGptLoginResult(entry.accountName, entry.reauthAccountId);
if (result.ok) {
disposeCodexEngines().finally(() => {
ws.emit.user(userId, 'engine:codex-account-setup-complete', {
Expand Down Expand Up @@ -438,7 +470,7 @@ export const codexAccountsHandler = createRouter()
// in the final chunk right before exit.
if (isLoginSuccess(clean)) {
entry.completed = true;
const result = persistChatGptLoginResult(entry.accountName);
const result = persistChatGptLoginResult(entry.accountName, entry.reauthAccountId);
if (result.ok) {
disposeCodexEngines().finally(() => {
ws.emit.user(userId, 'engine:codex-account-setup-complete', {
Expand Down
12 changes: 12 additions & 0 deletions backend/ws/engine/copilot/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ export const copilotAccountsHandler = createRouter()
return { success: true };
})

// Replace the stored PAT for an account. When the edited account is the
// active one, drop engine instances so the next stream picks up the token.
.http('engine:copilot-accounts-update-token', {
data: t.Object({ id: t.Number(), token: t.String({ minLength: 1 }) }),
response: t.Object({ success: t.Boolean() })
}, async ({ data }) => {
engineQueries.updateAccountCredential(data.id, data.token.trim());
const active = engineQueries.getActiveAccountForEngine('copilot');
if (active?.id === data.id) await disposeCopilotEngines();
return { success: true };
})

// Restart all Copilot engine instances. Use after changing the active token
// so subsequent models:list / chat calls re-initialise with fresh credentials.
.http('engine:copilot-restart', {
Expand Down
24 changes: 23 additions & 1 deletion backend/ws/engine/opencode/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ export const openCodeProviderHandler = createRouter()
apiUrl: t.Optional(t.String()),
options: t.Optional(t.String()),
accountName: t.String({ minLength: 1 }),
credential: t.String({ minLength: 1 }),
// Empty string allowed: custom OpenAI-compatible endpoints (e.g. a
// local LLM) often need no API key. The frontend still requires a
// credential for catalog providers via its own validation.
credential: t.String(),
}),
response: t.Object({
provider: ProviderSchema
Expand Down Expand Up @@ -136,6 +139,7 @@ export const openCodeProviderHandler = createRouter()
.http('engine:opencode-provider-update', {
data: t.Object({
id: t.Number(),
slug: t.Optional(t.String({ minLength: 1 })),
name: t.Optional(t.String()),
apiUrl: t.Optional(t.String()),
options: t.Optional(t.String()),
Expand All @@ -145,7 +149,16 @@ export const openCodeProviderHandler = createRouter()
if (data.options !== undefined) {
try { JSON.parse(data.options); } catch { throw new Error('Invalid JSON for options'); }
}
// Changing a provider's slug rewrites its identity in the generated
// opencode config — reject collisions so two providers can't share one.
if (data.slug !== undefined) {
const existing = engineQueries.getProviderBySlug('opencode', data.slug);
if (existing && existing.id !== data.id) {
throw new Error(`Provider "${data.slug}" already configured`);
}
}
engineQueries.updateProvider(data.id, {
slug: data.slug,
name: data.name,
apiUrl: data.apiUrl,
options: data.options,
Expand Down Expand Up @@ -221,6 +234,15 @@ export const openCodeProviderHandler = createRouter()
return { success: true };
})

.http('engine:opencode-account-update-credential', {
// Empty string allowed — a keyless custom endpoint may clear its key.
data: t.Object({ accountId: t.Number(), credential: t.String() }),
response: t.Object({ success: t.Boolean() })
}, async ({ data }) => {
engineQueries.updateAccountCredential(data.accountId, data.credential);
return { success: true };
})

// ═══════════════════════════════════════
// Models.dev Catalog
// ═══════════════════════════════════════
Expand Down
Loading
Loading