diff --git a/packages/server/src/controllers/hermes/models.ts b/packages/server/src/controllers/hermes/models.ts index 070d15f..062e85a 100644 --- a/packages/server/src/controllers/hermes/models.ts +++ b/packages/server/src/controllers/hermes/models.ts @@ -2,7 +2,7 @@ import { readFile } from 'fs/promises' import { existsSync, readFileSync } from 'fs' import { join } from 'path' import { getActiveEnvPath, getActiveAuthPath, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile' -import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers' +import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, updateConfigYamlForProfile, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers' import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers' import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models' import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config' @@ -200,6 +200,10 @@ function requestedProfileName(ctx: any): string { return typeof queryProfile === 'string' && queryProfile.trim() ? queryProfile.trim() : '' } +function requestScopedProfileName(ctx: any): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + function visibleProfileNamesForUser(ctx: any): string[] { const diskProfiles = listProfileNamesFromDisk() const user = ctx.state?.user @@ -859,7 +863,7 @@ export async function setModelAlias(ctx: any) { export async function getConfigModels(ctx: any) { try { - const config = await readConfigYaml() + const config = await readConfigYamlForProfile(requestScopedProfileName(ctx)) ctx.body = buildModelGroups(config) } catch (err: any) { ctx.status = 500 @@ -875,7 +879,8 @@ export async function setConfigModel(ctx: any) { return } try { - await updateConfigYaml((config) => { + const profile = requestScopedProfileName(ctx) + await updateConfigYamlForProfile(profile, (config) => { config.model = {} config.model.default = defaultModel if (reqProvider) { config.model.provider = reqProvider } diff --git a/packages/server/src/controllers/hermes/providers.ts b/packages/server/src/controllers/hermes/providers.ts index 75d0898..735bc1b 100644 --- a/packages/server/src/controllers/hermes/providers.ts +++ b/packages/server/src/controllers/hermes/providers.ts @@ -1,17 +1,25 @@ import { existsSync, readFileSync } from 'fs' import { writeFile } from 'fs/promises' -import { getActiveAuthPath } from '../../services/hermes/hermes-profile' -import * as hermesCli from '../../services/hermes/hermes-cli' -import { updateConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services/config-helpers' +import { join } from 'path' +import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' +import { updateConfigYamlForProfile, saveEnvValueForProfile, PROVIDER_ENV_MAP } from '../../services/config-helpers' import { PROVIDER_PRESETS } from '../../shared/providers' import { logger } from '../../services/logger' const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi', 'xai-oauth']) const DIRECT_CONFIG_PROVIDERS = new Set(['xai-oauth']) -async function clearStoredAuthProvider(poolKey: string) { +function requestedProfile(ctx: any): string { + return ctx.state?.profile?.name || getActiveProfileName() || 'default' +} + +function authPathForProfile(profile: string): string { + return join(getProfileDir(profile), 'auth.json') +} + +async function clearStoredAuthProvider(profile: string, poolKey: string) { try { - const authPath = getActiveAuthPath() + const authPath = authPathForProfile(profile) if (!existsSync(authPath)) return const auth = JSON.parse(readFileSync(authPath, 'utf-8')) @@ -49,9 +57,10 @@ export async function create(ctx: any) { ctx.status = 400; ctx.body = { error: 'Missing API key' }; return } try { + const profile = requestedProfile(ctx) const poolKey = providerKey || `custom:${name.trim().toLowerCase().replace(/ /g, '-')}` const isBuiltin = poolKey in PROVIDER_ENV_MAP - await updateConfigYaml(async (config) => { + await updateConfigYamlForProfile(profile, async (config) => { if (typeof config.model !== 'object' || config.model === null) { config.model = {} } if (!isBuiltin) { if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] } @@ -79,12 +88,12 @@ export async function create(ctx: any) { config.model.provider = poolKey } else { if (PROVIDER_ENV_MAP[poolKey].api_key_env) { - await saveEnvValue(PROVIDER_ENV_MAP[poolKey].api_key_env, api_key) - if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValue(PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) } + await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].api_key_env, api_key) + if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) } config.model.default = model config.model.provider = poolKey } else if (DIRECT_CONFIG_PROVIDERS.has(poolKey)) { - if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValue(PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) } + if (PROVIDER_ENV_MAP[poolKey].base_url_env) { await saveEnvValueForProfile(profile, PROVIDER_ENV_MAP[poolKey].base_url_env, base_url) } config.model.default = model config.model.provider = poolKey } else { @@ -131,9 +140,10 @@ export async function update(ctx: any) { name?: string; base_url?: string; api_key?: string; model?: string } try { + const profile = requestedProfile(ctx) const isCustom = poolKey.startsWith('custom:') if (isCustom) { - const found = await updateConfigYaml((config) => { + const found = await updateConfigYamlForProfile(profile, (config) => { if (!Array.isArray(config.custom_providers)) return { data: config, result: false, write: false } const entry = (config.custom_providers as any[]).find((e: any) => { return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey @@ -153,7 +163,7 @@ export async function update(ctx: any) { if (!envMapping?.api_key_env) { ctx.status = 400; ctx.body = { error: `Cannot update credentials for "${poolKey}"` }; return } - if (api_key !== undefined) { await saveEnvValue(envMapping.api_key_env, api_key) } + if (api_key !== undefined) { await saveEnvValueForProfile(profile, envMapping.api_key_env, api_key) } } // TODO: Test if provider works without gateway restart // try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') } @@ -166,8 +176,9 @@ export async function update(ctx: any) { export async function remove(ctx: any) { const poolKey = decodeURIComponent(ctx.params.poolKey) try { + const profile = requestedProfile(ctx) const isCustom = poolKey.startsWith('custom:') - const removed = await updateConfigYaml(async (config) => { + const removed = await updateConfigYamlForProfile(profile, async (config) => { if (isCustom) { const idx = Array.isArray(config.custom_providers) ? (config.custom_providers as any[]).findIndex((e: any) => { @@ -179,8 +190,8 @@ export async function remove(ctx: any) { } else { const envMapping = PROVIDER_ENV_MAP[poolKey] if (envMapping?.api_key_env) { - await saveEnvValue(envMapping.api_key_env, '') - if (envMapping.base_url_env) { await saveEnvValue(envMapping.base_url_env, '') } + await saveEnvValueForProfile(profile, envMapping.api_key_env, '') + if (envMapping.base_url_env) { await saveEnvValueForProfile(profile, envMapping.base_url_env, '') } } } if (config.model?.provider === poolKey) { @@ -208,7 +219,7 @@ export async function remove(ctx: any) { ctx.status = 404; ctx.body = { error: `Provider "${poolKey}" not found` }; return } } - await clearStoredAuthProvider(poolKey) + await clearStoredAuthProvider(profile, poolKey) // TODO: Test if provider works without gateway restart // try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') } ctx.body = { success: true } diff --git a/packages/server/src/services/config-helpers.ts b/packages/server/src/services/config-helpers.ts index 359ca10..14e307d 100644 --- a/packages/server/src/services/config-helpers.ts +++ b/packages/server/src/services/config-helpers.ts @@ -2,7 +2,7 @@ import { readFile, chmod } from 'fs/promises' import { readdir, stat } from 'fs/promises' import { existsSync, readFileSync } from 'fs' import { join } from 'path' -import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath, getProfileDir } from './hermes/hermes-profile' +import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getProfileDir } from './hermes/hermes-profile' import { logger } from './logger' import { safeFileStore } from './safe-file-store' @@ -71,13 +71,15 @@ export interface ModelGroup { // --- Config YAML helpers --- const configPath = () => getActiveConfigPath() +const configPathForProfile = (profile: string) => join(getProfileDir(profile), 'config.yaml') +const envPathForProfile = (profile: string) => join(getProfileDir(profile), '.env') export async function readConfigYaml(): Promise> { return safeFileStore.readYaml(configPath()) } export async function readConfigYamlForProfile(profile: string): Promise> { - return safeFileStore.readYaml(join(getProfileDir(profile), 'config.yaml')) + return safeFileStore.readYaml(configPathForProfile(profile)) } export async function writeConfigYaml(config: Record): Promise { @@ -90,6 +92,13 @@ export async function updateConfigYaml( return safeFileStore.updateYaml(configPath(), updater, { backup: true }) } +export async function updateConfigYamlForProfile( + profile: string, + updater: (config: Record) => Record | { data: Record; result: T; write?: boolean } | Promise | { data: Record; result: T; write?: boolean }>, +): Promise { + return safeFileStore.updateYaml(configPathForProfile(profile), updater, { backup: true }) +} + export function stripLegacyApiServerGatewayConfig(config: Record): { config: Record; changed: boolean } { if (!config.platforms || typeof config.platforms !== 'object' || Array.isArray(config.platforms)) { return { config, changed: false } @@ -112,9 +121,8 @@ function assertValidEnvKey(key: string): void { } } -export async function saveEnvValue(key: string, value: string): Promise { +async function saveEnvValueAtPath(envPath: string, key: string, value: string): Promise { assertValidEnvKey(key) - const envPath = getActiveEnvPath() await safeFileStore.updateText(envPath, (raw) => { const remove = !value const lines = raw.split('\n') @@ -143,6 +151,14 @@ export async function saveEnvValue(key: string, value: string): Promise { try { await chmod(envPath, 0o600) } catch { /* ignore */ } } +export async function saveEnvValue(key: string, value: string): Promise { + await saveEnvValueAtPath(getActiveEnvPath(), key, value) +} + +export async function saveEnvValueForProfile(profile: string, key: string, value: string): Promise { + await saveEnvValueAtPath(envPathForProfile(profile), key, value) +} + // --- File helpers --- export async function safeReadFile(filePath: string): Promise { diff --git a/tests/server/provider-delete-controller.test.ts b/tests/server/provider-delete-controller.test.ts index b046cbc..50de11e 100644 --- a/tests/server/provider-delete-controller.test.ts +++ b/tests/server/provider-delete-controller.test.ts @@ -15,17 +15,18 @@ async function loadProvidersController() { return import('../../packages/server/src/controllers/hermes/providers') } -function makeCtx(poolKey: string) { +function makeCtx(poolKey: string, overrides: Record = {}) { return { params: { poolKey: encodeURIComponent(poolKey) }, request: { body: {} }, status: 200, body: undefined as unknown, + ...overrides, } } -function readAuth() { - return JSON.parse(readFileSync(join(hermesHome, 'auth.json'), 'utf-8')) +function readAuth(profileDir = hermesHome) { + return JSON.parse(readFileSync(join(profileDir, 'auth.json'), 'utf-8')) } describe('providers controller delete', () => { @@ -153,4 +154,86 @@ describe('providers controller delete', () => { expect(ctx.body).toEqual({ success: true }) expect(existsSync(join(hermesHome, 'auth.json'))).toBe(false) }) + + it('deletes provider state from the request-scoped profile only', async () => { + const researchDir = join(hermesHome, 'profiles', 'research') + mkdirSync(researchDir, { recursive: true }) + writeFileSync(join(hermesHome, 'config.yaml'), [ + 'model:', + ' provider: deepseek', + ' default: keep-default-model', + '', + ].join('\n')) + writeFileSync(join(hermesHome, '.env'), [ + ['DEEPSEEK_API_KEY', 'keep-default-key'].join('='), + ['OPENROUTER_API_KEY', 'keep-default-openrouter'].join('='), + '', + ].join('\n')) + writeFileSync(join(hermesHome, 'auth.json'), JSON.stringify({ + providers: { + deepseek: { access_token: 'keep-default-token' }, + }, + credential_pool: { + deepseek: [{ label: 'keep-default' }], + }, + }, null, 2)) + writeFileSync(join(researchDir, 'config.yaml'), [ + 'model:', + ' provider: deepseek', + ' default: research-model', + 'custom_providers:', + ' - name: keep-provider', + ' base_url: https://keep.invalid/v1', + ' api_key: placeholder', + ' model: keep-model', + '', + ].join('\n')) + writeFileSync(join(researchDir, '.env'), [ + ['DEEPSEEK_API_KEY', 'remove-research-key'].join('='), + ['OPENROUTER_API_KEY', 'keep-research-openrouter'].join('='), + '', + ].join('\n')) + writeFileSync(join(researchDir, 'auth.json'), JSON.stringify({ + providers: { + deepseek: { access_token: 'remove-research-token' }, + openrouter: { access_token: 'keep-research-token' }, + }, + credential_pool: { + deepseek: [{ label: 'remove-research' }], + openrouter: [{ label: 'keep-research' }], + }, + }, null, 2)) + + const { remove } = await loadProvidersController() + const ctx = makeCtx('deepseek', { state: { profile: { name: 'research' } } }) + + await remove(ctx) + + expect(ctx.body).toEqual({ success: true }) + + const defaultEnvAfter = readFileSync(join(hermesHome, '.env'), 'utf-8') + expect(defaultEnvAfter).toContain(['DEEPSEEK_API_KEY', 'keep-default-key'].join('=')) + expect(defaultEnvAfter).toContain(['OPENROUTER_API_KEY', 'keep-default-openrouter'].join('=')) + expect(readFileSync(join(hermesHome, 'config.yaml'), 'utf-8')).toContain('keep-default-model') + expect(readAuth()).toEqual({ + providers: { + deepseek: { access_token: 'keep-default-token' }, + }, + credential_pool: { + deepseek: [{ label: 'keep-default' }], + }, + }) + + const researchEnvAfter = readFileSync(join(researchDir, '.env'), 'utf-8') + expect(researchEnvAfter).not.toContain('DEEPSEEK_API_KEY') + expect(researchEnvAfter).toContain(['OPENROUTER_API_KEY', 'keep-research-openrouter'].join('=')) + const researchConfigAfter = readFileSync(join(researchDir, 'config.yaml'), 'utf-8') + expect(researchConfigAfter).toContain('keep-provider') + expect(researchConfigAfter).toContain('keep-model') + const researchAuthAfter = readAuth(researchDir) + expect(researchAuthAfter.providers).not.toHaveProperty('deepseek') + expect(researchAuthAfter.credential_pool).not.toHaveProperty('deepseek') + expect(researchAuthAfter.providers.openrouter).toEqual({ access_token: 'keep-research-token' }) + expect(researchAuthAfter.credential_pool.openrouter).toEqual([{ label: 'keep-research' }]) + }) })