[Bug]: Selecting a model in Studio wipes sibling keys in the config.yaml `model:` block (model.context_length / base_url / api_key)
Ekko Studio Version
v0.7.22 ([email protected]). Still present on main (2a70334).
Agent Runtime and Version (if applicable)
Hermes Agent v0.20.5 — not required to reproduce, but it is a downstream consumer of one of the keys that gets wiped.
Bug Description
PUT /api/hermes/config/model — the endpoint behind "select a model" in the UI — rebuilds the model: section of config.yaml from scratch, so every key in that section other than default / provider is silently deleted.
packages/server/src/modules/hermes/controllers/models.ts:1104-1109 (setConfigModel):
await updateConfigYamlForProfile(profile, (config) => {
config.model = {} // <-- wipes the whole section
config.model.default = defaultModel
if (reqProvider) { config.model.provider = reqProvider }
return config
})The keys that get destroyed are supported, user-set keys:
model.context_length— an explicitly supported sibling ofdefault.getConfigContextLength()documents it verbatim (packages/server/src/modules/hermes/services/models/context.ts:150-157): "Read context_length from config.yaml, only as a sibling of default. e.g. model:\n default: gpt-5.4\n context_length: 256000". It is resolution step 5 for the Studio context gauge, and the Hermes Agent runtime reads the same key for its own context budget (gateway/run.py,tools/budget_config.py).model.base_url/model.api_key— read bygetModelBaseUrl()(context.ts:209).
Two consequences beyond the reported one:
- When the request omits
provider, the previous binding is lost as well — the old code deletedmodel.providerand never restored it (today's UI always sends it, so this is latent). - There is no error, no log line and no UI hint: the only symptom is a key missing from
config.yaml.
Steps to Reproduce
- Put a provider-independent key in the
model:section — e.g.hermes config set model.context_length 256000, or edit~/.hermes/config.yaml:model: default: tk/deepseek-v4.1-flash provider: custom:newapi context_length: 256000 - In Studio, switch the default model (Settings → Models → pick any model), or call the endpoint directly:(Note: the endpoint requires a user session JWT; the
curl -X PUT http://127.0.0.1:8648/api/hermes/config/model \ -H "Authorization: Bearer $SESSION_JWT" \ -H 'Content-Type: application/json' \ -H 'X-Hermes-Profile: default' \ --data '{"default":"glm-5.1","provider":"custom:glm"}'AUTH_TOKENserver token only authorizes the/api/studio/media/*and/api/studio/voice/proxy/*allowlist.) - Re-read the config:
grep -A4 '^model:' ~/.hermes/config.yaml
Expected Behavior
Selecting a model updates the model/provider binding and leaves every other key in the model: section alone:
model:
default: glm-5.1
provider: custom:glm
context_length: 256000base_url / api_key should be dropped only when the provider actually changes (they belong to the previous provider) — that is the intent already implemented in the provider-switch paths (providers.ts:375, anthropic-auth.ts:49, xai-auth.ts:49, minimax-auth.ts:102).
Actual Behavior
model:
default: glm-5.1
provider: custom:glm
# context_length: 256000 <- silently goneReported symptom on a custom OpenAI-compatible (self-hosted) provider: model.context_length: 256000 was set and verified present in config.yaml, then disappeared after switching the default model in Studio. It was restored with hermes config set model.context_length 256000 and disappeared again on the next switch — no error surfaced anywhere, and the setting only reappears to the user as an unexpected context-window behaviour change.
Logs / Error Messages
No error is logged. The only evidence is the config.yaml diff (see Actual Behavior) — safeFileStore.updateYaml(..., { backup: true }) keeps a timestamped backup, which contains the original model: section including the dropped keys.
Environment
- Docker / macOS / Linux / Windows / WSL: Linux
- Node Version: v26.5.0
Verification (deterministic, no UI needed)
The repo's own controller test harness exercises the real endpoint handler against a temp HERMES_HOME and the real config.yaml write path. Three regression tests were added to tests/server/config-mutating-controllers.test.ts; they fail on main and pass with the patch below.
On main (2a70334), unpatched:
✓ setConfigModel updates only the model section and preserves existing config
× setConfigModel keeps provider-independent keys in the model section
→ expected { default: 'glm-5.1', …(1) } to deeply equal { default: 'glm-5.1', …(2) }
× setConfigModel keeps the bound provider when the request omits it
→ expected { default: 'glm-5.1' } to deeply equal { default: 'glm-5.1', …(2) }
× setConfigModel drops provider-scoped credentials only when the provider changes
→ expected undefined to be 'http://127.0.0.1:8080/v1' // Object.is equality
✓ setConfigModel uses the requested profile header when auth has not populated state.profile
✓ skill toggle preserves unrelated config while adding and removing disabled skills
Test Files 1 failed (1)
Tests 3 failed | 3 passed (6)With the patch below applied:
✓ setConfigModel updates only the model section and preserves existing config
✓ setConfigModel keeps provider-independent keys in the model section
✓ setConfigModel keeps the bound provider when the request omits it
✓ setConfigModel drops provider-scoped credentials only when the provider changes
✓ setConfigModel uses the requested profile header when auth has not populated state.profile
✓ skill toggle preserves unrelated config while adding and removing disabled skills
Test Files 1 passed (1)
Tests 6 passed (6)Full tests/server suite with the patch (npx vitest run tests/server): 3432 passed, 23 failed — the 23 failures are pre-existing in this environment (agent-bridge-profile-env, studio-mcp-autoinject, agent-bridge-learn-command) and fail identically on unpatched main.
Reproduce locally:
npx vitest run tests/server/config-mutating-controllers.test.tsAdditional Context
Same bug class at two more call sites (both silently drop context_length when the binding goes away — the file-level fix below covers all three):
packages/server/src/modules/hermes/controllers/providers.ts:479— removing the last compatible custom provider.packages/server/src/modules/hermes/controllers/copilot-auth.ts:200— disabling Copilot while it is the bound provider.
The guard-style sites are fine (xai-auth.ts:50, anthropic-auth.ts:43, minimax-auth.ts:96, providers.ts:312 — they only create the object when it is missing).
The existing unit test locks in the wiping behaviour: tests/server/config-mutating-controllers.test.ts:84 asserts expect(config.model).toEqual({ default: 'glm-5.1', provider: 'custom:glm' }). Its fixture has no extra keys, so it stays green with the fix, but any fixture that adds context_length currently fails — worth keeping in mind when reviewing.
Client/server mismatch: updateDefaultModel() (packages/client/src/api/hermes/system.ts:172-181) accepts base_url and api_key, but setConfigModel only destructures { default, provider } from the body (models.ts:1091), so those two fields are silently ignored. Current callers do not pass them.
User-side workaround today: re-add the key after every model switch. The Studio-only context override (PUT /api/hermes/model-context, stored in the model_context table) is unaffected by this wipe, but it is Studio-side only — the Agent runtime and the model.context_length fallback in getModelContextLength() both read config.yaml.
Proposed fix (small, keeps the change scoped to the three call sites + a tiny helper, with regression tests). The model: section is copied and re-bound instead of replaced; provider-scoped credentials are dropped only when the provider changes:
diff --git a/packages/server/src/modules/hermes/controllers/copilot-auth.ts b/packages/server/src/modules/hermes/controllers/copilot-auth.ts
index d428445..f2b142f 100644
--- a/packages/server/src/modules/hermes/controllers/copilot-auth.ts
+++ b/packages/server/src/modules/hermes/controllers/copilot-auth.ts
@@ -10,6 +10,7 @@ import { getActiveEnvPath } from '../services/profiles/profile'
import { readAppConfig, writeAppConfig } from '../../studio/public/app-config'
import { readFile } from 'fs/promises'
import { logger } from '../../studio/public/logging'
+import { preservedModelKeys } from '../services/models/model-section'
const POLL_MAX_DURATION_MS = 15 * 60 * 1000 // 15 minutes hard ceiling
const SESSION_GC_GRACE_MS = 60 * 1000
@@ -197,7 +198,8 @@ export async function disable(ctx: any): Promise<void> {
if (typeof modelSection === 'object' && modelSection !== null) {
const provider = String(modelSection.provider || '').trim().toLowerCase()
if (provider === 'copilot') {
- cfg.model = {}
+ // Keep provider-independent settings (e.g. context_length); only the binding goes away.
+ cfg.model = preservedModelKeys(modelSection)
return { data: cfg, result: true }
}
}
diff --git a/packages/server/src/modules/hermes/controllers/models.ts b/packages/server/src/modules/hermes/controllers/models.ts
index 50dac4f..8bb7f80 100644
--- a/packages/server/src/modules/hermes/controllers/models.ts
+++ b/packages/server/src/modules/hermes/controllers/models.ts
@@ -13,6 +13,7 @@ import { readAppConfig, writeAppConfig, providerDisplayLabel, type ModelVisibili
import { listUserProfiles } from '../../studio/public/users'
import { readModelContextRecord, upsertModelContextRecord } from '../../studio/public/provider-context'
import { getModelContextLength } from '../services/models/context'
+import { isPlainModelSection } from '../services/models/model-section'
import { readProviderModelCatalogCache,
refreshConfiguredProviderModelCatalogs,
resolveProviderCatalogModels,
@@ -1102,9 +1103,17 @@ export async function setConfigModel(ctx: any) {
try {
const profile = requestScopedProfileName(ctx)
await updateConfigYamlForProfile(profile, (config) => {
- config.model = {}
- config.model.default = defaultModel
- if (reqProvider) { config.model.provider = reqProvider }
+ const current = isPlainModelSection(config.model) ? { ...config.model } : {}
+ // Credentials belong to the provider that was bound; drop them only when the
+ // provider really changes. Provider-independent keys such as
+ // model.context_length must survive a model switch.
+ if (reqProvider && current.provider !== undefined && current.provider !== reqProvider) {
+ delete current.base_url
+ delete current.api_key
+ }
+ current.default = defaultModel
+ if (reqProvider) { current.provider = reqProvider }
+ config.model = current
return config
})
ctx.body = { success: true }
diff --git a/packages/server/src/modules/hermes/controllers/providers.ts b/packages/server/src/modules/hermes/controllers/providers.ts
index e123e34..18db0bf 100644
--- a/packages/server/src/modules/hermes/controllers/providers.ts
+++ b/packages/server/src/modules/hermes/controllers/providers.ts
@@ -18,6 +18,7 @@ import { refreshProviderModels, restoreProviderModels } from '../services/provid
import { appendProviderAuditEvent } from '../../studio/public/provider-audit'
import { invalidateProviderRuntime } from '../../studio/public/provider-runtime'
import { OPENCODE_FREE_PROVIDER, OPENCODE_FREE_BASE_URL, isOpenCodeFreeModel } from '../../studio/contracts/opencode-free'
+import { preservedModelKeys } from '../services/models/model-section'
const OPTIONAL_API_KEY_PROVIDERS = new Set(['cliproxyapi', 'xai-oauth', 'openai-codex', 'claude-oauth', 'minimax-oauth', OPENCODE_FREE_PROVIDER])
const DIRECT_CONFIG_PROVIDERS = new Set(['xai-oauth', 'openai-codex', 'claude-oauth', 'minimax-oauth', OPENCODE_FREE_PROVIDER])
@@ -476,7 +477,9 @@ export async function remove(ctx: any) {
delete config.model.base_url
delete config.model.api_key
} else {
- config.model = {}
+ // No compatible provider left: drop the binding but keep provider-independent
+ // settings such as model.context_length.
+ config.model = preservedModelKeys(config.model)
}
}
return { data: config, result: true }
diff --git a/packages/server/src/modules/hermes/services/models/model-section.ts b/packages/server/src/modules/hermes/services/models/model-section.ts
new file mode 100644
index 0000000..f8d5ada
--- /dev/null
+++ b/packages/server/src/modules/hermes/services/models/model-section.ts
@@ -0,0 +1,26 @@
+/**
+ * Helpers for the top-level `model:` section of config.yaml.
+ *
+ * The section holds both the active model/provider binding and
+ * provider-independent settings such as `context_length` (a documented sibling
+ * of `default`, see services/models/context.ts). Mutating controllers must not
+ * drop the latter when they rebind the former.
+ */
+
+/** Keys that bind a model/provider, or hold credentials scoped to that provider. */
+const MODEL_BINDING_KEYS = new Set(['default', 'provider', 'base_url', 'api_key'])
+
+export function isPlainModelSection(value: unknown): value is Record<string, any> {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/**
+ * Copy the `model:` section, keeping every key that is not part of the
+ * model/provider binding itself (e.g. `context_length`).
+ */
+export function preservedModelKeys(section: unknown): Record<string, any> {
+ if (!isPlainModelSection(section)) return {}
+ return Object.fromEntries(
+ Object.entries(section).filter(([key]) => !MODEL_BINDING_KEYS.has(key)),
+ )
+}
diff --git a/tests/server/config-mutating-controllers.test.ts b/tests/server/config-mutating-controllers.test.ts
index 2352cd5..1853231 100644
--- a/tests/server/config-mutating-controllers.test.ts
+++ b/tests/server/config-mutating-controllers.test.ts
@@ -85,6 +85,63 @@ describe('config mutating controllers', () => {
expect(config.terminal.backend).toBe('local')
})
+ it('setConfigModel keeps provider-independent keys in the model section', async () => {
+ await writeFile(join(hermesHome, 'config.yaml'), [
+ 'model:',
+ ' default: old',
+ ' provider: custom:glm',
+ ' context_length: 256000',
+ '',
+ ].join('\n'), 'utf-8')
+ const { setConfigModel } = await loadModelsController()
+
+ await setConfigModel(makeCtx({ default: 'glm-5.1', provider: 'custom:glm' }))
+
+ const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
+ expect(config.model).toEqual({ default: 'glm-5.1', provider: 'custom:glm', context_length: 256000 })
+ })
+
+ it('setConfigModel keeps the bound provider when the request omits it', async () => {
+ await writeFile(join(hermesHome, 'config.yaml'), [
+ 'model:',
+ ' default: old',
+ ' provider: custom:glm',
+ ' context_length: 256000',
+ '',
+ ].join('\n'), 'utf-8')
+ const { setConfigModel } = await loadModelsController()
+
+ await setConfigModel(makeCtx({ default: 'glm-5.1' }))
+
+ const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
+ expect(config.model).toEqual({ default: 'glm-5.1', provider: 'custom:glm', context_length: 256000 })
+ })
+
+ it('setConfigModel drops provider-scoped credentials only when the provider changes', async () => {
+ const fixture = [
+ 'model:',
+ ' default: old',
+ ' provider: custom:old',
+ ' context_length: 256000',
+ ' base_url: http://127.0.0.1:8080/v1',
+ ' api_key: sk-test',
+ '',
+ ].join('\n')
+ const { setConfigModel } = await loadModelsController()
+
+ await writeFile(join(hermesHome, 'config.yaml'), fixture, 'utf-8')
+ await setConfigModel(makeCtx({ default: 'glm-5.1', provider: 'custom:old' }))
+ let config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
+ expect(config.model.base_url).toBe('http://127.0.0.1:8080/v1')
+ expect(config.model.api_key).toBe('sk-test')
+ expect(config.model.context_length).toBe(256000)
+
+ await writeFile(join(hermesHome, 'config.yaml'), fixture, 'utf-8')
+ await setConfigModel(makeCtx({ default: 'glm-5.1', provider: 'custom:new' }))
+ config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any
+ expect(config.model).toEqual({ default: 'glm-5.1', provider: 'custom:new', context_length: 256000 })
+ })
+
it('setConfigModel uses the requested profile header when auth has not populated state.profile', async () => {
const researchDir = join(hermesHome, 'profiles', 'research')
await mkdir(researchDir, { recursive: true })Source: EKKOLearnAI/hermes-studio