fix(cli): inherit the launch profile's custom provider gateway with its model

A profile created without a clone source copies the launch profile's
`model` block so it can run. When that block points at a custom
`providers:` gateway (self-hosted / local endpoint), `model.provider:
my-gateway` alone is "Unknown provider 'my-gateway'" on the new bot's very
first turn — created, but unable to run, the same on-arrival failure
#101885 / #94071 describe for the built-in case (fixed in 01a3e9a44c).

launch_model_seed() returns the model block plus exactly that provider's
definition; both the CLI seed (_seed_model_config, also behind the
Desktop's profiles.create) and the gateway inheritance path
(_inherit_launch_model) write the same shape.

Live repro: e2e/new-bot-config-on-disk.spec.ts (New Bot → Fresh profile →
Create; asserts the created profile's config.yaml on disk and that the
intro turn completes) — red on origin/main with the banner "Unknown
provider 'mock'", green here.
This commit is contained in:
teknium1
2026-09-16 13:18:12 -07:00
committed by Teknium
parent d7ebee3c02
commit b5a9efea06
4 changed files with 129 additions and 4 deletions

View File

@@ -0,0 +1,81 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { expect, test } from './test'
// A bot created from the New Bot dialog WITHOUT a clone source (the "inherited
// from launch profile" path) must land on disk with a usable `model:` block in
// its own config.yaml and its introduction turn must actually run — a profile
// whose config.yaml lacks the block is dead on arrival ("No LLM provider
// configured" on the first message). Verified against storage truth (the
// created profile's config.yaml under HERMES_HOME), not the toast.
let fixture: MockBackendFixture | null = null
type Page = MockBackendFixture['page']
async function openBots(page: Page): Promise<void> {
const tab = page
.getByRole('button', { name: 'Bots', exact: true })
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
.first()
await tab.click()
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
}
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
// eslint-disable-next-line no-empty-pattern
test.afterEach(async ({}, info) => {
if (!fixture) {
return
}
await info.attach('native-window', { body: await fixture.page.screenshot(), contentType: 'image/png' })
const cfg = join(fixture.sandbox.hermesHome, 'profiles', 'fresh-scout', 'config.yaml')
await info.attach('created-config', {
body: existsSync(cfg) ? readFileSync(cfg) : Buffer.from('<missing>'),
contentType: 'text/plain'
})
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('a fresh (non-clone) bot gets a runnable model block on disk', async () => {
test.setTimeout(300_000)
const page = fixture!.page
await openBots(page)
await page.getByRole('button', { name: 'New bot or group chat' }).click()
await page.getByRole('menuitem', { name: 'New Bot' }).click()
const dialog = page.getByRole('dialog', { name: 'New Bot' })
await dialog.getByPlaceholder('inbox-triage').fill('fresh-scout')
await dialog.getByPlaceholder('Inbox Triage').fill('Fresh Scout')
await dialog.getByRole('button', { name: 'Advanced' }).click()
// "Fresh profile" = no clone source; the model must be inherited, not cloned.
await dialog.getByRole('combobox').filter({ hasText: /default|Fresh profile/ }).first().click()
await page.getByRole('option', { name: 'Fresh profile (bundled skills)' }).click()
await dialog.getByRole('button', { name: 'Create Bot' }).click()
await expect(dialog).toBeHidden({ timeout: 60_000 })
// Storage truth: the created profile's own config.yaml carries the model block.
const cfgPath = join(fixture!.sandbox.hermesHome, 'profiles', 'fresh-scout', 'config.yaml')
await expect.poll(() => existsSync(cfgPath), { timeout: 30_000 }).toBe(true)
const cfg = readFileSync(cfgPath, 'utf8')
expect(cfg).toMatch(/^model:/m)
expect(cfg).toMatch(/provider:\s*mock/)
expect(cfg).toMatch(/default:\s*mock-model/)
// The inherited model rides a custom `providers:` gateway; its definition must travel with it
// or the first turn dies with "Unknown provider 'mock'".
expect(cfg).toMatch(/^providers:\n\s+mock:/m)
})

View File

@@ -588,6 +588,22 @@ def _read_config_model(profile_dir: Path) -> tuple:
return None, None
def launch_model_seed(source_cfg: dict) -> dict:
"""The config a fresh profile needs to run the launch profile's model: its ``model`` block plus,
when that block points at a custom ``providers:`` gateway (self-hosted / local endpoint), that
provider's definition — ``model.provider: my-gateway`` alone is "Unknown provider" on the first
turn. ``{}`` when the launch profile has no model."""
model_cfg = source_cfg.get("model")
if not model_cfg:
return {}
seed = {"model": model_cfg}
providers = source_cfg.get("providers")
name = model_cfg.get("provider") if isinstance(model_cfg, dict) else None
if isinstance(providers, dict) and name in providers:
seed["providers"] = {name: providers[name]}
return seed
def _seed_model_config(profile_dir: Path) -> None:
"""Copy (not link) the active profile's model block into a fresh profile so it is usable;
profiles stay independent islands afterwards."""
@@ -599,9 +615,9 @@ def _seed_model_config(profile_dir: Path) -> None:
from hermes_constants import get_hermes_home
from hermes_cli.config import read_user_config_raw
source = get_hermes_home() / "config.yaml"
model_cfg = read_user_config_raw(source).get("model") if source.is_file() else None
if model_cfg:
config_path.write_text(yaml.safe_dump({"model": model_cfg}, sort_keys=False), encoding="utf-8")
seed = launch_model_seed(read_user_config_raw(source)) if source.is_file() else {}
if seed:
config_path.write_text(yaml.safe_dump(seed, sort_keys=False), encoding="utf-8")
def _check_gateway_running(profile_dir: Path) -> bool:

View File

@@ -163,6 +163,25 @@ class TestCreateProfile:
assert cfg["model"]["default"] == "some/model"
def test_fresh_profile_inherits_its_custom_provider_gateway(self, profile_env):
"""The inherited model may point at a custom `providers:` gateway (self-hosted / local
endpoint). Copying `model` alone left the new bot with `model.provider: my-gateway` and
"Unknown provider 'my-gateway'" on its first turn (#101885 / #94071 class); the provider
definition must travel with the model it backs, and nothing else from `providers:` does.
"""
default_home = profile_env / ".hermes"
(default_home / "config.yaml").write_text(
"model:\n provider: my-gateway\n default: my-finetune\n"
"providers:\n my-gateway:\n api: https://llm.internal.example.com/v1\n key_env: GW_KEY\n"
" unrelated:\n api: https://other.example.com/v1\n"
)
profile_dir = create_profile("coder", no_alias=True)
cfg = yaml.safe_load((profile_dir / "config.yaml").read_text())
assert cfg["model"] == {"provider": "my-gateway", "default": "my-finetune"}
assert cfg["providers"] == {"my-gateway": {"api": "https://llm.internal.example.com/v1", "key_env": "GW_KEY"}}
def test_fresh_profile_model_is_copied_not_linked(self, profile_env):
"""Profiles stay independent islands.

View File

@@ -314,10 +314,19 @@ def _inherit_launch_model(path) -> bool:
dst_model = (read_user_config_raw() or {}).get("model") or {}
if dst_model.get("provider") and dst_model.get("default"):
return False
model_cfg = (load_config_readonly() or {}).get("model") or {}
launch_cfg = load_config_readonly() or {}
model_cfg = launch_cfg.get("model") or {}
if not (model_cfg.get("provider") and model_cfg.get("default")):
return False
_pin_profile_model(path, str(model_cfg["provider"]), str(model_cfg["default"]))
# A custom `providers:` gateway travels with the model it backs (same seed as the CLI path).
custom = _lazy("hermes_cli.profiles", "launch_model_seed")(launch_cfg).get("providers")
if custom:
from hermes_cli.config import load_config, save_config
with _hermes_home_scope(path):
cfg = load_config()
cfg["providers"] = {**(cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}), **custom}
save_config(cfg)
return True