[Bug]: Selecting a model in Studio wipes sibling keys in the config.yaml `model:` block (model.context_length / base_url / api_key)

Author: leafsys1Created Sep 17, 2026Updated Sep 17, 2026

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):

typescript
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 of default. 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 by getModelBaseUrl() (context.ts:209).

Two consequences beyond the reported one:

  1. When the request omits provider, the previous binding is lost as well — the old code deleted model.provider and never restored it (today's UI always sends it, so this is latent).
  2. There is no error, no log line and no UI hint: the only symptom is a key missing from config.yaml.

Steps to Reproduce

  1. Put a provider-independent key in the model: section — e.g. hermes config set model.context_length 256000, or edit ~/.hermes/config.yaml:
    yaml
    model:
      default: tk/deepseek-v4.1-flash
      provider: custom:newapi
      context_length: 256000
  2. In Studio, switch the default model (Settings → Models → pick any model), or call the endpoint directly:
    bash
    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"}'
    (Note: the endpoint requires a user session JWT; the AUTH_TOKEN server token only authorizes the /api/studio/media/* and /api/studio/voice/proxy/* allowlist.)
  3. Re-read the config:
    bash
    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:

yaml
model:
  default: glm-5.1
  provider: custom:glm
  context_length: 256000

base_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

yaml
model:
  default: glm-5.1
  provider: custom:glm
# context_length: 256000  <- silently gone

Reported 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:

bash
npx vitest run tests/server/config-mutating-controllers.test.ts

Additional 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:

patch (applies to main @ 2a70334)
diff
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