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
9 changes: 5 additions & 4 deletions gateway/src/agents/claude_code.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,12 @@ class ClaudeCodeAdapter {
};
}

models() {
return cachedModels("claude-code", () => this._fetchModels());
models(options = {}) {
const profileId = options.profileId || 'none';
return cachedModels(`claude-code:${profileId}`, () => this._fetchModels(options));
}

async _fetchModels() {
async _fetchModels(options = {}) {
const envModels = (process.env.CLAUDE_CODE_MODELS || '')
.split(',')
.map((value) => value.trim())
Expand All @@ -74,7 +75,7 @@ class ClaudeCodeAdapter {
return envModels.map((id) => ({ id, displayName: id, raw: { id } }));
}

const profileKey = this.profileStore?.getKeyForProvider('anthropic');
const profileKey = this.profileStore?.getKeyForProviderById(options.profileId, 'anthropic');
const apiKey = profileKey?.key || null;
const baseUrl = profileKey?.baseUrl || null;
if (apiKey) {
Expand Down
46 changes: 43 additions & 3 deletions gateway/src/agents/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,39 @@ class CodexAdapter {
};
}

models() {
return cachedModels('codex', () => this._fetchModels());
models(options = {}) {
const profileId = options.profileId || 'none';
return cachedModels(`codex:${profileId}`, () => this._fetchModels(options));
}

async _fetchModels() {
async _fetchModels(options = {}) {
const profileKey = this.profileStore?.getKeyForProviderById(
options.profileId, 'openai');
if (profileKey?.key) {
try {
const res = await fetch(openAIModelsUrl(profileKey.baseUrl), {
headers: {
authorization: `Bearer ${profileKey.key}`,
accept: 'application/json',
},
signal: AbortSignal.timeout(8000),
});
if (res.ok) {
const body = await res.json();
const models = readOpenAIModels(body)
.filter((model) => model.id)
.map((model) => ({
id: model.id,
displayName: model.display_name || model.name || model.id,
raw: model,
}));
if (models.length > 0) return models;
}
} catch (err) {
console.warn(`[codex] Failed to fetch models from OpenAI profile: ${err.message}`);
}
}

const result = await runCapture(this.command, ['debug', 'models', '--bundled']);
if (result.exitCode === 0) {
try {
Expand Down Expand Up @@ -170,6 +198,18 @@ function compactCodexModel(model) {
};
}

function openAIModelsUrl(baseUrl) {
const root = (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
return `${root}/models`;
}

function readOpenAIModels(body) {
if (Array.isArray(body)) return body;
if (Array.isArray(body?.data)) return body.data;
if (Array.isArray(body?.models)) return body.models;
return [];
}

module.exports = {
CodexAdapter,
CODEX_COMMANDS,
Expand Down
10 changes: 8 additions & 2 deletions gateway/src/agents/opencode.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ class OpenCodeAdapter {
};
}

models() {
return cachedModels("opencode", () => this._fetchModels());
models(options = {}) {
const profileId = options.profileId || 'none';
return cachedModels(`opencode:${profileId}`, () => this._fetchModels(options));
}

async _fetchModels() {
Expand Down Expand Up @@ -287,6 +288,11 @@ class OpenCodeAdapter {
extraEnv.OPENAI_API_KEY = openaiKey.key;
if (openaiKey.baseUrl) extraEnv.OPENAI_BASE_URL = openaiKey.baseUrl;
}
const opencodeKey = this.profileStore?.getKeyForProviderById(profileId, 'opencode');
if (opencodeKey?.key && !extraEnv.OPENAI_API_KEY) {
extraEnv.OPENAI_API_KEY = opencodeKey.key;
if (opencodeKey.baseUrl) extraEnv.OPENAI_BASE_URL = opencodeKey.baseUrl;
}
const googleKey = this.profileStore?.getKeyForProviderById(profileId, 'google');
if (googleKey?.key) {
extraEnv.GOOGLE_API_KEY = googleKey.key;
Expand Down
106 changes: 91 additions & 15 deletions gateway/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class ProfileStore {
constructor(file) {
this.file = file || DEFAULT_PROFILES_PATH;
this.profiles = [];
this.agentSettings = { agents: {} };
this.saveQueue = Promise.resolve();
}

Expand All @@ -52,13 +53,24 @@ class ProfileStore {
try {
const raw = await fs.readFile(this.file, 'utf8');
const parsed = JSON.parse(raw);
this.profiles = Array.isArray(parsed) ? parsed : [];
if (Array.isArray(parsed)) {
this.profiles = parsed;
this.agentSettings = { agents: {} };
} else if (parsed && typeof parsed === 'object') {
this.profiles = Array.isArray(parsed.profiles) ? parsed.profiles : [];
this.agentSettings = normalizeAgentSettings(parsed.agentSettings);
} else {
this.profiles = [];
this.agentSettings = { agents: {} };
}
} catch (error) {
if (error.code === 'ENOENT') {
this.profiles = [];
this.agentSettings = { agents: {} };
await this.save();
} else if (error instanceof SyntaxError) {
this.profiles = [];
this.agentSettings = { agents: {} };
await this.save();
} else {
throw error;
Expand Down Expand Up @@ -99,7 +111,7 @@ class ProfileStore {
* Create a new profile. If it's the first profile, it becomes active
* automatically.
*/
async create({ name, keys, defaultModel }) {
async create({ name, keys }) {
if (!name || typeof name !== 'string') {
throw Object.assign(new Error('Profile name is required'), { statusCode: 400 });
}
Expand All @@ -110,7 +122,6 @@ class ProfileStore {
name,
isCurrent: isFirst,
keys: keys && typeof keys === 'object' ? keys : {},
defaultModel: defaultModel && typeof defaultModel === 'object' ? defaultModel : {},
createdAt: Date.now(),
};

Expand Down Expand Up @@ -142,10 +153,6 @@ class ProfileStore {
};
}
}
if (patch.defaultModel !== undefined && typeof patch.defaultModel === 'object') {
profile.defaultModel = patch.defaultModel;
}

await this.save();
return profile;
}
Expand Down Expand Up @@ -203,14 +210,57 @@ class ProfileStore {
return { key: entry.key || null, baseUrl: entry.baseUrl || null };
}

/**
* From the active profile, return the default model ID for a given agent.
* Returns null if no active profile or agent not configured.
*/
getDefaultModel(agentId) {
const active = this.getActive();
if (!active || !active.defaultModel) return null;
return active.defaultModel[agentId] || null;
return this.getAgentSettings(agentId).defaultModel;
}

getAgentProfileId(agentId) {
return this.getAgentSettings(agentId).profileId;
}

getAgentSettings(agentId) {
const settings = this.agentSettings.agents[agentId] || {};
return {
profileId: settings.profileId || null,
defaultModel: settings.defaultModel || null,
};
}

listAgentSettings(agentIds = []) {
const ids = new Set([
...agentIds,
...Object.keys(this.agentSettings.agents || {}),
]);
return [...ids].map((agentId) => ({
agentId,
...this.getAgentSettings(agentId),
}));
}

async updateAgentSettings(agentId, patch) {
if (!agentId || typeof agentId !== 'string') {
throw Object.assign(new Error('agentId is required'), { statusCode: 400 });
}
const current = this.getAgentSettings(agentId);
const next = { ...current };
if (Object.prototype.hasOwnProperty.call(patch, 'profileId')) {
if (patch.profileId !== null && typeof patch.profileId !== 'string') {
throw Object.assign(new Error('profileId must be a string or null'), { statusCode: 400 });
}
if (patch.profileId && !this.get(patch.profileId)) {
throw Object.assign(new Error('profile not found'), { statusCode: 404 });
}
next.profileId = patch.profileId || null;
}
if (Object.prototype.hasOwnProperty.call(patch, 'defaultModel')) {
if (patch.defaultModel !== null && typeof patch.defaultModel !== 'string') {
throw Object.assign(new Error('defaultModel must be a string or null'), { statusCode: 400 });
}
next.defaultModel = patch.defaultModel || null;
}
this.agentSettings.agents[agentId] = next;
await this.save();
return this.getAgentSettings(agentId);
}

/**
Expand All @@ -231,7 +281,18 @@ class ProfileStore {
await fs.mkdir(path.dirname(this.file), { recursive: true });
const temp = `${this.file}.${process.pid}.${crypto.randomUUID()}.tmp`;
try {
await fs.writeFile(temp, `${JSON.stringify(this.profiles, null, 2)}\n`, 'utf8');
await fs.writeFile(
temp,
`${JSON.stringify(
{
profiles: this.profiles,
agentSettings: this.agentSettings,
},
null,
2,
)}\n`,
'utf8',
);
await fs.rename(temp, this.file);
} catch (error) {
// Clean up temp file on failure.
Expand All @@ -241,4 +302,19 @@ class ProfileStore {
}
}

function normalizeAgentSettings(value) {
const settings = { agents: {} };
const agents = value && typeof value === 'object' && value.agents && typeof value.agents === 'object'
? value.agents
: {};
for (const [agentId, entry] of Object.entries(agents)) {
if (!entry || typeof entry !== 'object') continue;
settings.agents[agentId] = {
profileId: typeof entry.profileId === 'string' ? entry.profileId : null,
defaultModel: typeof entry.defaultModel === 'string' ? entry.defaultModel : null,
};
}
return settings;
}

module.exports = { ProfileStore, maskProfile };
54 changes: 47 additions & 7 deletions gateway/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ async function createGatewayServer({ dataFile, adapters, profilesFile } = {}) {
url,
store,
registry,
profileStore,
});
}

Expand Down Expand Up @@ -179,6 +180,7 @@ async function createGatewayServer({ dataFile, adapters, profilesFile } = {}) {
response,
segments,
profileStore,
registry,
});
}

Expand Down Expand Up @@ -238,13 +240,20 @@ async function handleProjects({ request, response, segments, store, registry, pr
const body = await readJson(request);
const adapter = registry.get(body.agentId);
if (!adapter) throw httpError(400, `unknown agent: ${body.agentId}`);
const agentSettings = profileStore.getAgentSettings(body.agentId);
const profileId = body.profileId || agentSettings.profileId;
if (profileId && !profileStore.get(profileId)) {
throw httpError(404, 'profile not found');
}
const modelId = body.modelId || agentSettings.defaultModel || null;
let nativeSession = null;
if (adapter.createSession) {
try {
nativeSession = await adapter.createSession({
project,
modelId: body.modelId,
modelId,
title: body.title || `${adapter.displayName} session`,
profileId,
});
} catch (_) {
nativeSession = null;
Expand All @@ -253,12 +262,11 @@ async function handleProjects({ request, response, segments, store, registry, pr
const rawExtra = {};
if (body.sandbox) rawExtra.sandbox = body.sandbox;
if (body.permissionMode) rawExtra.permissionMode = body.permissionMode;
const activeProfile = profileStore.getActive();
if (activeProfile) rawExtra.profileId = activeProfile.id;
if (profileId) rawExtra.profileId = profileId;
const session = await store.createSession({
project,
agentId: body.agentId,
modelId: body.modelId,
modelId,
title: body.title || nativeSession?.title || `${adapter.displayName} session`,
agentSessionId: nativeSession?.agentSessionId,
raw: { ...(nativeSession ? { agentSession: nativeSession.raw } : {}), ...rawExtra },
Expand All @@ -269,7 +277,7 @@ async function handleProjects({ request, response, segments, store, registry, pr
throw httpError(404, 'not found');
}

async function handleAgents({ request, response, segments, url, store, registry }) {
async function handleAgents({ request, response, segments, url, store, registry, profileStore }) {
if (segments.length === 1 && request.method === 'GET') {
return sendJson(response, await registry.list());
}
Expand All @@ -279,7 +287,10 @@ async function handleAgents({ request, response, segments, url, store, registry
return sendJson(response, await adapter.metadata());
}
if (segments.length === 3 && segments[2] === 'models' && request.method === 'GET') {
return sendJson(response, { models: await adapter.models() });
const requestedProfileId = url.searchParams.get('profileId');
const profileId = requestedProfileId || profileStore.getAgentProfileId(segments[1]);
if (profileId && !profileStore.get(profileId)) throw httpError(404, 'profile not found');
return sendJson(response, { models: await adapter.models({ profileId }) });
}
if (segments.length === 3 && segments[2] === 'commands' && request.method === 'GET') {
const projectId = url.searchParams.get('projectId');
Expand All @@ -293,7 +304,7 @@ async function handleAgents({ request, response, segments, url, store, registry
throw httpError(404, 'not found');
}

async function handleSettings({ request, response, segments, profileStore }) {
async function handleSettings({ request, response, segments, profileStore, registry }) {
// GET /settings/active-profile
if (segments.length === 2 && segments[1] === 'active-profile' && request.method === 'GET') {
const active = profileStore.getActive();
Expand Down Expand Up @@ -375,9 +386,38 @@ async function handleSettings({ request, response, segments, profileStore }) {
}
}

// /settings/agents routes
if (segments.length >= 2 && segments[1] === 'agents') {
if (segments.length === 2 && request.method === 'GET') {
const agents = (await registry.list()).map((agent) => agent.id);
return sendJson(response, {
agents: profileStore
.listAgentSettings(agents)
.map((setting) => formatAgentSetting(profileStore, setting)),
profiles: profileStore.list(),
});
}
if (segments.length === 3 && request.method === 'PATCH') {
const agentId = segments[2];
if (!registry.get(agentId)) throw httpError(404, 'agent not found');
const updated = await profileStore.updateAgentSettings(agentId, await readJson(request));
return sendJson(response, formatAgentSetting(profileStore, { agentId, ...updated }));
}
}

throw httpError(404, 'not found');
}

function formatAgentSetting(profileStore, setting) {
const profile = setting.profileId ? profileStore.get(setting.profileId) : null;
return {
agentId: setting.agentId,
profileId: setting.profileId || null,
profile: profile ? maskProfile(profile) : null,
defaultModel: setting.defaultModel || null,
};
}

async function handleSessions({
request,
response,
Expand Down
Loading
Loading