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:
81
apps/desktop/e2e/new-bot-config-on-disk.spec.ts
Normal file
81
apps/desktop/e2e/new-bot-config-on-disk.spec.ts
Normal 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)
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user