Files
2026-07-13 13:12:00 +08:00

3086 lines
136 KiB
Python

"""Tests for ``omnigent setup --no-internal-beta`` (CLI model-provider config).
Drives the click command tree with :class:`click.testing.CliRunner` and
piped stdin, then asserts on the **exact config mutations** written to a
tmp ``~/.omnigent/config.yaml`` (isolated via ``OMNIGENT_CONFIG_HOME``)
and the secret store (forced to the file backend via
``OMNIGENT_DISABLE_KEYRING``). Each test asserts on the persisted YAML
shape, not just the command's exit code, so a regression in the
add/set-default/remove write paths surfaces here rather than silently.
``configure harnesses`` is a **three-level** picker. Level 1 shows every
harness on a single compact row — the name on the left, then an aligned
``✓``/``✗`` status column — in 0.3 priority order: ``1=Claude``,
``2=Codex``, ``3=Cursor``, ``4=OpenCode``, ``5=Hermes``, ``6=Pi``,
``7=Antigravity``, ``8=Qwen Code``, ``9=Goose``, ``10=Copilot``, ``11=Kiro``,
``12=Kimi Code``, ``13=Quit``. There is no "More" folding — every harness is
visible at once — and the actionable hint (install command / next step)
renders only for the highlighted row, as the selector's description line.
Selecting a harness drills into level 2 — its configured credentials, then ``+ Add a
credential`` and ``← Back``. So an empty harness's level 2 is ``1=+Add 2=Back``;
with one credential it's ``1=<credential> 2=+Add 3=Back``. Selecting a
credential opens level 3 — ``Make default`` (only when not already the
default), ``Remove``, and ``← Back``. Going back / exiting is also Esc on a TTY
or ``q`` on the numbered fallback. Under ``CliRunner`` stdin is not a TTY, so
the selector routes through the **numbered fallback** (1-based; ``q`` aborts).
The add menu is scoped to the
harness entered (Claude → Anthropic key / Claude sub / gateway / databricks;
Codex → OpenAI key / ChatGPT sub / OpenRouter key / gateway / other /
databricks). The add flow no longer asks "make default?": a credential
auto-becomes the default for any family with no existing default.
The per-family-default invariant (a Claude default and a Codex default
coexist; selecting a provider sets only its harness's default) is the most
load-bearing behavior — it has dedicated coverage below.
"""
from __future__ import annotations
import os
import pytest
import tomllib
import yaml
from click.testing import CliRunner
from omnigent.cli import cli
from omnigent.onboarding import secrets
from omnigent.onboarding.configure_models import (
add_menu_options,
add_menu_options_for_family,
build_bedrock_provider_entry,
credential_label,
kind_glyph,
provider_display_name,
)
from omnigent.onboarding.provider_config import (
ANTHROPIC_FAMILY,
GEMINI_FAMILY,
OPENAI_FAMILY,
get_default_provider,
load_config,
load_providers,
)
@pytest.fixture()
def isolated_config(tmp_path, monkeypatch):
"""Isolate config + secrets to a tmp dir with the file secret backend.
Sets ``OMNIGENT_CONFIG_HOME`` so config and secrets land under
*tmp_path*, ``OMNIGENT_DISABLE_KEYRING`` so the secret store uses the
``0600`` JSON file (no OS keychain dependency in CI), and clears any
ambient vendor keys so detection is deterministic.
:param tmp_path: Pytest temp directory.
:param monkeypatch: Pytest monkeypatch fixture.
:returns: The tmp config-home directory path.
"""
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path))
monkeypatch.setenv("OMNIGENT_DISABLE_KEYRING", "1")
for var in (
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
"ANTIGRAVITY_API_KEY",
"OPENROUTER_API_KEY",
"DATABRICKS_TOKEN",
"CURSOR_API_KEY",
):
monkeypatch.delenv(var, raising=False)
# Redirect CLI-detected credential homes so a developer's real
# ~/.claude / ~/.codex logins don't leak into ambient detection.
monkeypatch.setenv("HOME", str(tmp_path))
# Stub out the two ambient-detection helpers that read real machine
# state regardless of HOME / env-var isolation:
# - _ollama_reachable: TCP-probes localhost:11434; a running Ollama
# would otherwise add an entry to the harness menu and shift option
# numbers, making input sequences non-deterministic.
# - _claude_login_detected: on macOS falls back to `claude auth status`
# which reads the Keychain (not HOME), so a real Claude subscription
# leaks through even with HOME redirected to tmp_path.
monkeypatch.setattr("omnigent.onboarding.ambient._ollama_reachable", lambda: False)
monkeypatch.setattr("omnigent.onboarding.ambient._claude_login_detected", lambda: False)
return tmp_path
@pytest.fixture(autouse=True)
def _harnesses_installed(monkeypatch):
"""Default: pretend the harness CLIs are installed and login/logout succeed.
The level-2 picker gates on an installed CLI (offering ``npm install`` when
missing), and adding/removing a *subscription* now drives the harness's own
login/logout. These tests exercise the *credential* flow, not the real
interactive vendor commands, so stub ``harness_cli_installed`` → True and
``harness_login`` / ``harness_logout`` → True. The install-gate and the
login/logout behaviors have dedicated tests that override these.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed",
lambda family: True,
)
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_login",
lambda family: True,
)
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_logout",
lambda family: True,
)
def _config_yaml(config_home) -> dict[str, object]:
"""Read and parse the isolated config.yaml.
:param config_home: The tmp config-home directory.
:returns: The parsed config mapping, or ``{}`` when no file exists.
"""
path = os.path.join(config_home, "config.yaml")
if not os.path.exists(path):
return {}
with open(path) as f:
return yaml.safe_load(f) or {}
def test_configure_models_list_groups_configured_providers(isolated_config) -> None:
"""``omnigent config list`` renders each configured provider grouped by harness.
Seeds two providers directly, then asserts the listing shows both
names, their kind words, the Claude/Codex harness groups, and the
per-family default markers. A failure means the grouped-listing
renderer dropped a provider or mislabeled a default — the exact thing
the design's grouped view promises.
"""
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{
"providers": {
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "keychain:anthropic",
"models": {"default": "claude-sonnet-4-6"},
},
},
"openai": {
"kind": "key",
"default": True,
"openai": {
"base_url": "https://api.openai.com/v1",
"api_key_ref": "keychain:openai",
"models": {"default": "gpt-5.5"},
},
},
}
},
f,
)
result = CliRunner().invoke(cli, ["config", "list"])
assert result.exit_code == 0, result.output
# Grouped under each harness family (Claude / Codex); both providers
# appear with their kind word — proves neither row was dropped.
assert "Claude" in result.output
assert "Codex" in result.output
assert "anthropic" in result.output
assert "openai" in result.output
assert result.output.count("key") >= 2
# anthropic is the Claude default and openai the Codex default, so the
# default marker appears at least twice. A miss means the default
# cross-check picked the wrong family or omitted the marker.
assert result.output.count("✓ default") >= 2
def test_configure_models_add_key_provider_writes_entry_and_secret(isolated_config) -> None:
"""Adding a ``key`` provider writes the family entry + stores the secret.
Pipes the interactive add flow (flat menu "Anthropic — API key" →
paste key → pick default model → make default → quit). Asserts the
exact ``providers:`` entry shape AND that the pasted key reached the
secret store under ``keychain:anthropic``. A failure here means the add
flow wrote a malformed entry or lost the secret.
"""
# L1: 1=Claude → L2 (no credentials): 1=+Add → scoped anthropic add menu
# 1="Anthropic — API key" → paste key → default model blank (= catalog
# default) → L2: q=back → L1: q=exit.
stdin = "\n".join(["1", "1", "1", "sk-ant-test-key", "", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
providers = cfg["providers"]
# The entry is keyed by provider name with kind=key and an anthropic
# family carrying a keychain ref (never an inline key).
assert "anthropic" in providers
entry = providers["anthropic"]
assert entry["kind"] == "key"
assert entry["anthropic"]["api_key_ref"] == "keychain:anthropic"
assert entry["anthropic"]["base_url"] == "https://api.anthropic.com"
# A default model was picked, so the family carries a models.default.
assert entry["anthropic"]["models"]["default"]
# The pasted key is in the secret store under the provider name — the
# keychain ref above resolves to this value at runtime.
assert secrets.load_secret("anthropic") == "sk-ant-test-key"
# The entry round-trips through the parser and is the anthropic default.
parsed = load_providers(cfg)
assert parsed["anthropic"].kind == "key"
assert get_default_provider(cfg, "anthropic").name == "anthropic"
def test_configure_models_add_key_persists_catalog_default_when_declined(
isolated_config,
) -> None:
"""Declining the model pick still persists a catalog ``models.default``.
Reproduces the reported persistence bug: a user adds (or re-adds) a
``key`` provider and declines "Pick a default model?". Previously the
entry was written with NO ``models.default`` — so a later re-add silently
dropped a previously-pinned default. The add flow must instead fall back
to the bundled catalog's default model for that provider, so an anthropic
``key`` provider always carries a real ``models.default``.
"""
from omnigent.onboarding.providers import default_chat_model
# L1 1=Claude → L2 1=+Add → anthropic menu 1=Anthropic key → key →
# default model blank (declined) → L2 q=back → L1 q=exit. Blank model
# must still persist the catalog default rather than no pin.
stdin = "\n".join(["1", "1", "1", "sk-ant-test-key", "", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
entry = cfg["providers"]["anthropic"]
# Even though the user declined, the family carries the catalog default.
expected = default_chat_model("anthropic")
assert expected is not None
assert entry["anthropic"]["models"]["default"] == expected
def test_configure_models_readd_key_does_not_drop_default(isolated_config) -> None:
"""Re-adding a ``key`` provider never leaves it without a ``models.default``.
Seeds an anthropic ``key`` provider with a pinned default, then re-adds
anthropic through the flow while declining the model pick. The re-add
replaces the whole entry (deep-merge is one level deep), so without the
catalog fallback the pin would vanish. Asserts the re-added entry still
carries a (catalog) default rather than dropping ``models`` entirely.
"""
from omnigent.onboarding.providers import default_chat_model
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{
"providers": {
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "keychain:anthropic",
"models": {"default": "claude-opus-4-6-20260205"},
},
}
}
},
f,
)
# L1 1=Claude → L2 (1=anthropic 2=+Add): re-add via 2=+Add → anthropic
# menu 1=Anthropic key → key → blank model → L2 q=back → L1 q=exit.
stdin = "\n".join(["1", "2", "1", "sk-ant-new-key", "", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
entry = cfg["providers"]["anthropic"]
# The re-added entry carries a default (the catalog's), not a missing one.
assert "models" in entry["anthropic"], "re-add dropped the models block"
assert entry["anthropic"]["models"]["default"] == default_chat_model("anthropic")
def test_configure_models_add_gateway_openrouter_chat_wire(isolated_config) -> None:
"""Adding an OpenRouter gateway records the openai family with `wire_api: chat`.
The gateway path is the long-tail (OpenRouter / LiteLLM). It asks which
harness surfaces it serves (Codex/OpenAI, Claude/Anthropic) and — for the
OpenAI surface — the wire protocol. OpenRouter is Chat-Completions-only,
so the user picks Chat; persisting `wire_api: chat` is what makes
OpenRouter actually work (the default Responses API 404s on it). Here the
user serves only the Codex surface.
"""
# L1 2=Codex → L2 1=+Add → scoped openai menu 3="Gateway — custom base
# URL + key" (order: OpenAI key, ChatGPT sub, Gateway, OpenRouter,
# Databricks, Other) → name; base_url; key; surfaces select 2="Codex /
# OpenAI only"; wire 2=Chat; default model "qwen/q" → L2 q=back → L1 q=exit.
stdin = (
"\n".join(
[
"2",
"1",
"3",
"openrouter",
"https://openrouter.ai/api/v1",
"sk-or-test",
"2",
"2",
"qwen/q",
"q",
"q",
]
)
+ "\n"
)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
entry = cfg["providers"]["openrouter"]
assert entry["kind"] == "gateway"
# Only the openai surface (Codex-only pick).
assert "openai" in entry
assert "anthropic" not in entry
assert entry["openai"]["base_url"] == "https://openrouter.ai/api/v1"
assert entry["openai"]["api_key_ref"] == "keychain:openrouter"
# The wire-protocol pick is persisted — the fix that makes OpenRouter work.
assert entry["openai"]["wire_api"] == "chat"
# The gateway's default model is pinned (no catalog default for a gateway).
assert entry["openai"]["models"]["default"] == "qwen/q"
assert secrets.load_secret("openrouter") == "sk-or-test"
def test_configure_models_set_default_preserves_other_family(isolated_config) -> None:
"""Setting an openai default leaves the anthropic default intact.
This is the per-family coexistence invariant. Seed an anthropic
default (Claude) and a non-default openai key, then make the openai key
the Codex default by selecting its row in the tree. The anthropic
default must survive (it serves a different family); the openai key
becomes the Codex default. A failure means set-default clobbered a
sibling in another family — the exact regression the wholesale-write
path guards against.
"""
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{
"providers": {
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "keychain:anthropic",
"models": {"default": "claude-sonnet-4-6"},
},
},
"openai": {
"kind": "key",
"openai": {
"base_url": "https://api.openai.com/v1",
"api_key_ref": "keychain:openai",
"models": {"default": "gpt-5.5"},
},
},
}
},
f,
)
# L1 2=Codex → L2 (1=openai 2=+Add): select openai (1) → L3 1=Make default
# → back to L2 q=back → L1 q=exit.
stdin = "\n".join(["2", "1", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = load_config()
# openai is now the Codex (openai-family) default.
assert get_default_provider(cfg, "openai").name == "openai"
# anthropic (Claude family) default is UNTOUCHED — different family, so
# set-default must not have cleared it.
assert get_default_provider(cfg, "anthropic").name == "anthropic"
def test_configure_models_set_default_replaces_same_family_default(isolated_config) -> None:
"""A new same-family default clears the previous one (≤1 per family).
Two openai-family keys, one default. Setting the other as default must
clear the first's flag so exactly one openai default remains —
otherwise :func:`get_default_provider` would fail loud on the clash.
"""
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{
"providers": {
"openai": {
"kind": "key",
"default": True,
"openai": {
"base_url": "https://api.openai.com/v1",
"api_key_ref": "keychain:openai",
"models": {"default": "gpt-5.5"},
},
},
"openrouter": {
"kind": "gateway",
"openai": {
"base_url": "https://openrouter.ai/api/v1",
"api_key_ref": "keychain:openrouter",
},
},
}
},
f,
)
# Both serve openai. L1 2=Codex → L2 (1=openai 2=openrouter 3=+Add):
# select openrouter (2) → L3 1=Make default → it replaces openai as the
# Codex default → back to L2 q=back → L1 q=exit.
stdin = "\n".join(["2", "2", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = load_config()
# openrouter is the sole openai default — proves the old openai flag
# was cleared. If both kept default:true, get_default_provider raises.
assert get_default_provider(cfg, "openai").name == "openrouter"
def test_configure_models_remove_drops_entry(isolated_config) -> None:
"""Removing a provider drops exactly that entry from the block.
Seed two providers, remove the first, assert only the second remains.
"""
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{
"providers": {
"anthropic": {
"kind": "key",
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "keychain:anthropic",
},
},
"openai": {
"kind": "key",
"openai": {
"base_url": "https://api.openai.com/v1",
"api_key_ref": "keychain:openai",
},
},
}
},
f,
)
# L1 1=Claude → L2 (1=anthropic 2=+Add): select anthropic (1) → L3
# (1=Make default 2=Remove): 2=Remove → back to L2 q=back → L1 q=exit.
stdin = "\n".join(["1", "1", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert "anthropic" not in cfg["providers"]
assert "openai" in cfg["providers"]
def test_add_menu_options_are_friendly_and_credential_aware() -> None:
"""The add menu shows intuitive provider+credential labels, not raw ids.
Proves the user-facing fix: choices read like "OpenAI — API key" and
"Claude — subscription", and each label resolves to the right
(kind, provider/cli). A failure means a menu entry would show a raw id
(e.g. "openai") or map to the wrong credential path.
"""
options = add_menu_options()
# Every label is emoji-prefixed (first char is the kind glyph, not
# ASCII) and uses the friendly " — <credential>" form — never a raw id.
assert all(not o.label[0].isascii() for o in options)
assert all(" — " in o.label for o in options)
# The friendly, credential-aware labels are present (the user's
# examples) — matched as suffixes so the test isn't coupled to the
# exact emoji glyph.
assert any(o.label.endswith("OpenAI — API key") for o in options)
assert any(o.label.endswith("ChatGPT — subscription") for o in options)
assert any(o.label.endswith("Claude — subscription (Pro/Max)") for o in options)
# Each resolves to the correct kind + preset provider/cli.
by_provider = {o.provider: o for o in options if o.provider}
by_cli = {o.cli: o for o in options if o.cli}
assert by_provider["openai"].kind == "key"
assert by_provider["openai"].label.endswith("OpenAI — API key")
assert by_cli["codex"].kind == "subscription"
assert by_cli["claude"].kind == "subscription"
# The catch-all has no preset provider (the user picks one next).
other = next(o for o in options if o.other)
assert other.kind == "key" and other.provider is None
def test_add_menu_options_ordering() -> None:
"""The add menu orders: API key → subscription → extras → Databricks → Other.
Proves the user-requested ordering, in both the full menu and each
family-scoped subset (the menu actually shown after drilling into a
harness): the first-party API key(s) and subscription(s) lead, the
cross-vendor extras follow alphabetically (Gateway before OpenRouter),
and Databricks sits just above the catch-all "Other". A regression to
the old interleaved order (or Other above Databricks) fails here.
"""
# Full menu: first-party keys (OpenAI, Anthropic, Gemini), then
# subscriptions, then Gateway, OpenRouter, Databricks, Other.
full = [o.label.split(None, 1)[1] for o in add_menu_options()]
assert full == [
"OpenAI — API key",
"Anthropic — API key",
"Gemini — API key",
"ChatGPT — subscription",
"Claude — subscription (Pro/Max)",
"Gateway — custom base URL + key (e.g. OpenRouter)",
"OpenRouter — API key",
"Databricks — workspace",
"Other provider — API key",
# Bedrock is appended last so it never shifts the established order.
"AWS Bedrock — API key",
]
# Codex (openai) scoped: API key, subscription, Gateway, OpenRouter,
# Databricks, Other — Databricks immediately above Other.
codex = [o.label.split(None, 1)[1] for o in add_menu_options_for_family(OPENAI_FAMILY)]
assert codex == [
"OpenAI — API key",
"ChatGPT — subscription",
"Gateway — custom base URL + key (e.g. OpenRouter)",
"OpenRouter — API key",
"Databricks — workspace",
"Other provider — API key",
]
assert codex.index("Databricks — workspace") < codex.index("Other provider — API key")
# Claude (anthropic) scoped: API key, subscription, Gateway, Databricks
# (no OpenRouter / Other — those are openai-family).
claude = [o.label.split(None, 1)[1] for o in add_menu_options_for_family(ANTHROPIC_FAMILY)]
assert claude == [
"Anthropic — API key",
"Claude — subscription (Pro/Max)",
"Gateway — custom base URL + key (e.g. OpenRouter)",
"Databricks — workspace",
"AWS Bedrock — API key",
]
# Gemini (antigravity) scoped: API key only — Gemini is key-only (no
# subscription/gateway/Databricks), and it must NOT appear in the
# openai-family "Other provider" catch-all (asserted via `codex` above).
gemini = [o.label.split(None, 1)[1] for o in add_menu_options_for_family(GEMINI_FAMILY)]
assert gemini == ["Gemini — API key"]
def test_add_menu_databricks_option_gated_on_extra(monkeypatch) -> None:
"""The Databricks option stays visible without the SDK, with the hint.
The `databricks` extra (databricks-sdk) is no longer a default
dependency, so the add menu gates the Databricks flow on it: the option
is never hidden (discoverability), but its description switches from
the routing explanation to the install hint when the SDK is absent.
A failure means a bare-OSS user either loses the option entirely or
sees the routing description for a flow that would abort on selection.
"""
# Patch the symbol configure_models bound at import — the menu builder
# calls this exact name, so the patch deterministically simulates a
# bare install without touching the process-wide importlib machinery.
monkeypatch.setattr(
"omnigent.onboarding.configure_models.databricks_sdk_installed",
lambda: False,
)
options = add_menu_options()
databricks = next(o for o in options if o.label.endswith("Databricks — workspace"))
# The label (and thus menu presence/ordering) is unchanged; only the
# description carries the gate.
assert databricks.kind == "databricks"
assert databricks.description == (
"Requires the Databricks extra — select for the install command."
)
# With the SDK present (the dev/CI env — no patch), the description
# explains the routing instead of demanding an install.
monkeypatch.undo()
options = add_menu_options()
databricks = next(o for o in options if o.label.endswith("Databricks — workspace"))
assert "Unity AI Gateway" in databricks.description
def test_configure_models_add_databricks_aborts_without_extra(
isolated_config, monkeypatch
) -> None:
"""Selecting Databricks without the SDK aborts before any side effect.
Drives the real add flow (Claude → +Add → Databricks) with the SDK
absent. The gate must return to the menu without prompting for a
workspace URL, running `databricks auth login`, `ucode configure`, or
writing a provider entry. A failure here (non-zero exit via the raising
login stub, or a written provider) means the gate ran after a side
effect — exactly the bug it exists to prevent: signing the user into a
workspace that routing then can't use.
"""
# cli.py's databricks branch resolves databricks_sdk_installed from the
# source module at call time, so patching the module attribute is seen.
monkeypatch.setattr(
"omnigent.onboarding.databricks_config.databricks_sdk_installed",
lambda: False,
)
def _login_must_not_run(*args: object, **kwargs: object) -> str:
"""Stub that fails the test if the Databricks login is reached."""
raise AssertionError(
"login_databricks_workspace ran despite the missing databricks "
"extra — the gate must abort before the browser login."
)
monkeypatch.setattr(
"omnigent.onboarding.setup.login_databricks_workspace",
_login_must_not_run,
)
# L1: 1=Claude → L2 (empty): 1=+Add → Claude-scoped menu: 4=Databricks
# (key, subscription, gateway, then Databricks) → gate aborts back to
# L2: q=back → L1: q=exit. If the gate were broken, the next stdin line
# ("q") would be consumed as the workspace URL and the login stub would
# raise, failing the invoke with a non-zero exit code.
stdin = "\n".join(["1", "1", "4", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
# No provider entry was persisted — the add aborted cleanly.
cfg = _config_yaml(isolated_config)
assert cfg.get("providers", {}) == {}
@pytest.mark.parametrize(
"kind,name,profile,expected",
[
("subscription", "claude-subscription", None, "Subscription"),
("subscription", "codex-subscription", None, "Subscription"),
("key", "anthropic", None, "Anthropic API Key"),
("key", "openai", None, "OpenAI API Key"),
("databricks", "databricks", "oss", "Databricks (oss)"),
("databricks", "databricks", None, "Databricks"),
("gateway", "my-proxy", None, "My-Proxy"), # display-name fallback
],
)
def test_credential_label_by_kind(
kind: str, name: str, profile: str | None, expected: str
) -> None:
"""The shared credential label names each kind consistently.
Proves the single source of truth used by BOTH ``configure harnesses``
and the ``/model`` readout: a subscription is always "Subscription"
(never the brand/cli name), a vendor key is "<Vendor> API Key", and
Databricks names its profile. A drift here would make the two surfaces
disagree — the exact inconsistency the shared helper was added to fix.
"""
assert credential_label(kind, name, profile=profile) == expected
@pytest.mark.parametrize("kind", ["key", "subscription", "gateway", "databricks", "local"])
def test_kind_glyph_uniform_display_width(kind: str) -> None:
"""Every kind glyph renders at a uniform 2-cell width on modern terminals.
Proves the "ticket looks cramped" fix comes from the subscription
glyph's VARIATION SELECTOR-16 (which makes ADMISSION TICKETS a 2-cell
emoji like 🔑 / 🌐 / 🧱), not ad-hoc padding. Width is measured via the
banner box's own ``_display_width`` (rich >= 14 ``cell_len``, which counts
a VS16-forced wide emoji as the two cells terminals render). A regression
that dropped the VS16 (or a glyph) yields width != 2.
"""
from omnigent.inner.banner import _display_width
g = kind_glyph(kind)
width = _display_width(g)
assert width == 2, f"glyph for {kind!r} should be 2 display cells; got {width} ({g!r})."
@pytest.mark.parametrize(
"provider,expected",
[
("openai", "OpenAI"),
("openrouter", "OpenRouter"),
("xai", "xAI"),
("together_ai", "Together AI"),
("some-custom-proxy", "Some-Custom-Proxy"), # fallback: title-cased
],
)
def test_provider_display_name_friendly(provider: str, expected: str) -> None:
"""Provider ids render as human names; unknown ids fall back title-cased.
A failure means a menu/prompt would show the raw id (e.g. "xai" instead
of "xAI").
"""
assert provider_display_name(provider) == expected
def test_configure_models_add_subscription_via_flat_menu(isolated_config) -> None:
"""Picking "Claude — subscription" adds a subscription provider (cli=claude).
Exercises the flat menu's subscription path end-to-end (the user's
"OpenAI Subscription"-style option): no kind/cli sub-pick, just the
one intuitive choice. A failure means the subscription option didn't
preset the CLI or wrote the wrong entry.
"""
# L1 1=Claude → L2 1=+Add → scoped anthropic menu 2="Claude —
# subscription (Pro/Max)" → harness login (stubbed True by the autouse
# fixture) → name derived "<cli>-subscription", auto-default → L2 q → L1 q.
stdin = "\n".join(["1", "1", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
entry = cfg["providers"]["claude-subscription"]
# A subscription entry routes through the claude CLI login (no families).
assert entry["kind"] == "subscription"
assert entry["cli"] == "claude"
# claude serves the anthropic surface, so it became the Claude default.
assert get_default_provider(cfg, "anthropic").name == "claude-subscription"
def test_add_subscription_invokes_harness_login(isolated_config, monkeypatch) -> None:
"""Adding a subscription drives the harness's own login before recording.
Proves "configure is the single place to sign in": picking "Claude —
subscription" calls ``harness_login("anthropic")``. A failure means the menu
recorded a subscription without ever logging the user in (the original bug).
"""
calls: list[str] = []
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_login",
lambda family: calls.append(family) or True,
)
stdin = "\n".join(["1", "1", "2", "q", "q"]) + "\n" # Claude → +Add → subscription
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
# Logged into Claude (anthropic) exactly once, before recording the entry.
assert calls == ["anthropic"]
cfg = _config_yaml(isolated_config)
assert cfg["providers"]["claude-subscription"]["kind"] == "subscription"
def test_add_subscription_aborts_when_login_fails(isolated_config, monkeypatch) -> None:
"""A login that doesn't complete records NO subscription (no phantom).
If ``harness_login`` returns False (user cancelled / OAuth failed), the add
must not persist a subscription entry — otherwise routing would later strand
the user at the harness's own login screen, exactly what we're fixing.
"""
monkeypatch.setattr("omnigent.onboarding.harness_install.harness_login", lambda family: False)
stdin = "\n".join(["1", "1", "2", "q", "q"]) + "\n" # Claude → +Add → subscription
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
# Nothing persisted — the failed login aborted the add.
assert "claude-subscription" not in cfg.get("providers", {})
def test_remove_subscription_signs_out_and_removes(isolated_config, monkeypatch) -> None:
"""Removing a subscription runs the harness logout AND drops the entry.
A subscription's credential lives in the harness CLI's own auth file, so a
bare entry-delete wouldn't sign the user out (and would be re-detected). A
failure means logout wasn't invoked or the entry wasn't removed.
"""
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{"providers": {"claude-subscription": {"kind": "subscription", "cli": "claude"}}}, f
)
calls: list[str] = []
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_logout",
lambda family: calls.append(family) or True,
)
# L1 1=Claude → L2 1=select the subscription → L3 2=Remove → confirm 1=Yes
# (sign out + remove) → L2 q → L1 q.
stdin = "\n".join(["1", "1", "2", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
# Signed out of Claude (anthropic) …
assert calls == ["anthropic"]
cfg = _config_yaml(isolated_config)
# … and the entry is gone.
assert "claude-subscription" not in cfg.get("providers", {})
def test_remove_subscription_declined_keeps_it_and_login(isolated_config, monkeypatch) -> None:
"""Declining the remove-confirm leaves the entry AND never logs out.
The confirm defaults to "No"; choosing it must be a true no-op — no logout
(which would sign the user out of the standalone CLI) and no entry change.
"""
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{"providers": {"claude-subscription": {"kind": "subscription", "cli": "claude"}}}, f
)
def _no_logout(family: str) -> bool:
raise AssertionError("harness_logout called despite the user declining removal")
monkeypatch.setattr("omnigent.onboarding.harness_install.harness_logout", _no_logout)
# L1 1=Claude → L2 1=select → L3 2=Remove → confirm 2=No → L2 q → L1 q.
stdin = "\n".join(["1", "1", "2", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
# The subscription survived the declined removal.
assert "claude-subscription" in cfg["providers"]
def _write_databricks_provider(config_home) -> None:
"""Persist a ``kind: databricks`` provider entry to the isolated config.
:param config_home: The tmp config-home directory (``isolated_config``).
"""
config_path = os.path.join(config_home, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump({"providers": {"databricks": {"kind": "databricks", "profile": "myws"}}}, f)
def test_remove_databricks_cleans_ucode_wiring_without_asking(isolated_config) -> None:
"""Remove on a databricks provider strips ucode's wiring as part of removal.
A databricks provider was wired by `ucode configure`, which (for codex
< 0.134.0) edits the user's real ~/.codex/config.toml — so a bare
entry-delete would leave codex routing through the workspace gateway.
Cleanup is the removal's expected behavior, so there is NO extra confirm:
the stdin below carries no confirm digit, and an unexpected prompt would
consume the trailing ``q``s and leave the entry in place (failing the
config assertion). Exercises the real cleanup against files under the
isolated tmp HOME — no stubs — so it also proves the default-path
resolution (``~/.codex/...``) and the user-key preservation.
"""
_write_databricks_provider(isolated_config)
codex_dir = isolated_config / ".codex"
codex_dir.mkdir()
# The exact shape ucode's legacy layout leaves behind, merged into a
# user-owned key that must survive.
(codex_dir / "config.toml").write_text(
'model = "gpt-5.4"\n'
'profile = "ucode"\n'
"\n"
"[profiles.ucode]\n"
'model_provider = "ucode-databricks"\n'
"\n"
"[model_providers.ucode-databricks]\n"
'base_url = "https://example.databricks.com/ai-gateway/codex/v1"\n',
encoding="utf-8",
)
(codex_dir / "ucode.config.toml").write_text(
'model_provider = "ucode-databricks"\n', encoding="utf-8"
)
# L1 1=Claude → L2 1=select databricks → L3 2=Remove (acts immediately)
# → L2 q → L1 q.
stdin = "\n".join(["1", "1", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
# The provider entry is gone from config.yaml.
assert "databricks" not in cfg.get("providers", {})
doc = tomllib.loads((codex_dir / "config.toml").read_text(encoding="utf-8"))
# The invasive selector was stripped — bare codex no longer routes
# through the workspace. If present, Remove never invoked the cleanup.
assert "profile" not in doc
assert "profiles" not in doc
assert "model_providers" not in doc
# The user's own key survived the strip with its exact value.
assert doc["model"] == "gpt-5.4"
# ucode's sidecar is deleted too.
assert not (codex_dir / "ucode.config.toml").exists()
def test_remove_databricks_without_ucode_wiring_still_removes(isolated_config) -> None:
"""Remove works when no ucode wiring exists on the machine.
The cleanup steps must all no-op gracefully (missing ~/.codex,
~/.claude.json, sidecars) rather than erroring and blocking the
entry removal.
"""
_write_databricks_provider(isolated_config)
# L1 1=Claude → L2 1=select databricks → L3 2=Remove → L2 q → L1 q.
stdin = "\n".join(["1", "1", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
# The provider entry is gone despite there being nothing to clean.
assert "databricks" not in cfg.get("providers", {})
def test_render_listing_excludes_configured_subscription_clis(
capsys: pytest.CaptureFixture[str],
) -> None:
"""A detected CLI login isn't shown as "not configured" once its subscription is added.
Regression: a ``subscription`` provider is named e.g. ``claude-subscription``,
so the listing's "Detected (not configured)" filter — which compared the
detected CLI name (``"claude"``) against provider *names* — missed it,
and the login kept showing as not-configured even after the user added
it. The CLI must be excluded once a subscription wraps it, while an
unrelated detection still shows.
"""
from omnigent.onboarding.ambient import DetectedProvider
from omnigent.onboarding.configure_models import render_provider_listing
from omnigent.onboarding.provider_config import load_providers
config: dict[str, object] = {
"providers": {"claude-subscription": {"kind": "subscription", "cli": "claude"}}
}
providers = load_providers(config)
detected = [
DetectedProvider(
name="claude", kind="subscription", family="anthropic", source="claude CLI login"
),
DetectedProvider(name="gemini", kind="key", family="openai", source="$GEMINI_API_KEY"),
]
render_provider_listing(config, providers, detected)
out = capsys.readouterr().out
# The configured subscription is listed…
assert "claude-subscription" in out
# …and its wrapped CLI is NOT offered under "Detected (not configured)"…
assert "claude CLI login" not in out
# …while an unrelated ambient detection still surfaces as a hint.
assert "Detected (not configured)" in out
assert "gemini" in out
def _seed_config(config_home, providers: dict[str, object]) -> None:
"""Write a ``providers:`` block to the isolated config.yaml.
:param config_home: The tmp config-home directory (from ``isolated_config``).
:param providers: The raw ``providers`` mapping to persist, e.g.
``{"claude": {"kind": "subscription", "cli": "claude"}}``.
"""
with open(os.path.join(config_home, "config.yaml"), "w") as f:
yaml.safe_dump({"providers": providers}, f)
def test_add_subscription_replaces_existing_for_same_cli(isolated_config) -> None:
"""Adding a subscription when one already exists replaces it (one per harness).
Seeds a Claude subscription under the ambient-adopted name ``"claude"``
(the shape that produced the ``claude`` + ``claude-subscription``
duplicate), then adds "Claude — subscription" and chooses "Replace it".
The old entry is dropped and only the canonical ``claude-subscription``
remains, still the anthropic default — so a harness never accumulates two
subscriptions for one CLI login.
"""
_seed_config(
isolated_config,
{"claude": {"kind": "subscription", "default": True, "cli": "claude"}},
)
# L1 1=Claude → L2 2=+Add → anthropic menu 2=Claude subscription → replace
# prompt 1=Replace it → L2 q=back → L1 q=exit.
stdin = "\n".join(["1", "2", "2", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
providers = cfg["providers"]
# The duplicate ``claude`` entry is gone; only the canonical name remains.
assert "claude" not in providers
assert providers["claude-subscription"]["kind"] == "subscription"
assert providers["claude-subscription"]["cli"] == "claude"
# The replacement re-claimed the Claude default the old entry held.
assert get_default_provider(cfg, "anthropic").name == "claude-subscription"
def test_add_subscription_keep_current_aborts(isolated_config) -> None:
"""Declining the replace prompt leaves the existing subscription untouched.
Choosing "Keep the current one" aborts the add — no second subscription is
written and the original entry (name and default) is preserved.
"""
_seed_config(
isolated_config,
{"claude": {"kind": "subscription", "default": True, "cli": "claude"}},
)
# L1 1=Claude → L2 2=+Add → anthropic menu 2=Claude subscription → replace
# prompt 2=Keep the current one (abort) → L2 q=back → L1 q=exit.
stdin = "\n".join(["1", "2", "2", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
providers = cfg["providers"]
# No canonical entry was added; the original is still the only subscription.
assert "claude-subscription" not in providers
assert providers["claude"]["kind"] == "subscription"
assert get_default_provider(cfg, "anthropic").name == "claude"
def test_add_second_key_different_source_keeps_both(isolated_config) -> None:
"""A second API key from a new source coexists with the first.
Seeds an anthropic ``key`` backed by ``env:ANTHROPIC_API_KEY``, then adds
another Anthropic key by pasting one (a different source: a ``keychain:``
ref). The paste must NOT overwrite the env-backed entry — it gets a fresh
name (``anthropic-2``) so both keys are kept ("allow multiple API keys").
"""
_seed_config(
isolated_config,
{
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "env:ANTHROPIC_API_KEY",
},
}
},
)
# ANTHROPIC_API_KEY is cleared by isolated_config, so detection offers no
# env reuse — the add takes the paste path. L1 1=Claude → L2 2=+Add →
# anthropic menu 1=Anthropic key → paste key → blank model → q, q.
stdin = "\n".join(["1", "2", "1", "sk-ant-second", "", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
providers = cfg["providers"]
# Both keys survive — the env-backed original and the pasted second.
assert providers["anthropic"]["anthropic"]["api_key_ref"] == "env:ANTHROPIC_API_KEY"
assert providers["anthropic-2"]["kind"] == "key"
assert providers["anthropic-2"]["anthropic"]["api_key_ref"] == "keychain:anthropic-2"
assert secrets.load_secret("anthropic-2") == "sk-ant-second"
def test_readd_same_source_key_updates_in_place(isolated_config, monkeypatch) -> None:
"""Re-adding a key from the same source updates it, never duplicates it.
Seeds an anthropic ``key`` backed by ``env:ANTHROPIC_API_KEY`` and makes
the variable present, so the add flow offers to reuse it. Accepting the
detected env var yields the same source as the existing entry, so it is
updated in place — no ``anthropic-2`` is created (re-adding the key you
already have is idempotent).
"""
_seed_config(
isolated_config,
{
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "env:ANTHROPIC_API_KEY",
},
}
},
)
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env") # so detection offers reuse
# L1 1=Claude → L2 2=+Add → anthropic menu 1=Anthropic key → "y" reuse the
# detected env var → blank model → q, q.
stdin = "\n".join(["1", "2", "1", "y", "", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
providers = cfg["providers"]
# Same source → in-place update, so there is still exactly one anthropic key.
assert "anthropic-2" not in providers
assert providers["anthropic"]["anthropic"]["api_key_ref"] == "env:ANTHROPIC_API_KEY"
def test_multiple_keys_show_source_bracket(isolated_config) -> None:
"""When >1 API key serves a harness, each row is qualified with its source.
Two anthropic keys would otherwise both read as "Anthropic API Key". The
manager appends the source hint — ``$ENV_VAR`` for an env ref, the stored
name for a keychain ref — so the rows are distinguishable. A lone key has
no qualifier (covered implicitly by the other add tests).
"""
_seed_config(
isolated_config,
{
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "env:ANTHROPIC_API_KEY",
},
},
"anthropic-prod": {
"kind": "key",
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "keychain:anthropic-prod",
},
},
},
)
# L1 1=Claude renders the level-2 credential rows; q=back, q=exit.
stdin = "\n".join(["1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
# Both source hints render, so the two keys are told apart.
assert "$ANTHROPIC_API_KEY" in result.output
assert "anthropic-prod" in result.output
def test_configure_models_add_other_provider_prompts_for_name(
isolated_config, monkeypatch
) -> None:
"""The "Other provider" path is the one key case that still prompts for a name.
Per the UX rule (only gateway / "other" prompt for a name; presets,
subscriptions, and databricks derive theirs), adding via "Other provider
— API key" lets the user name the entry — useful for a custom name or
two configs for the same vendor. The pasted key is stored under that
chosen name (``keychain:<name>``), not the catalog id, so custom names
don't collide. A failure means the "other" path stopped prompting or
keyed the secret by the wrong identifier.
"""
# The first "other" provider is xai (an openai-family vendor); clear its
# env var so detection doesn't add a "use the detected key?" prompt.
monkeypatch.delenv("XAI_API_KEY", raising=False)
# "Other" is openai-family, so it lives in the Codex add menu. L1 2=Codex
# → L2 1=+Add → openai menu 6="Other provider — API key" (order: OpenAI
# key, ChatGPT sub, Gateway, OpenRouter, Databricks, Other) → which
# provider → xAI(1) → NAME "my-xai" → key → default model blank → L2
# q=back → L1 q=exit.
stdin = "\n".join(["2", "1", "6", "1", "my-xai", "sk-xai-test", "", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
providers = cfg["providers"]
# Entry is keyed by the user-chosen NAME (not the catalog id "xai")…
assert "my-xai" in providers
assert "xai" not in providers
entry = providers["my-xai"]
assert entry["kind"] == "key"
# …and the secret ref + store are keyed by that same custom name.
assert entry["openai"]["api_key_ref"] == "keychain:my-xai"
assert secrets.load_secret("my-xai") == "sk-xai-test"
def test_configure_models_add_key_free_form_model(isolated_config) -> None:
"""The default-model prompt is free-form: a model NOT in the catalog persists.
Regression for the "default models are outdated" feedback — the bundled
catalog lags new releases, so the prompt must accept any typed model id
(not just a fixed picker). Typing a brand-new id that the catalog does
not know must be written verbatim as `models.default`.
"""
novel = "claude-sonnet-9-9-21001231" # deliberately not in the catalog
# L1 1=Claude → L2 1=+Add → anthropic menu 1=Anthropic key → key →
# default model = <novel> (typed, not from catalog) → L2 q=back → L1 q=exit.
stdin = "\n".join(["1", "1", "1", "sk-ant-test-key", novel, "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert cfg["providers"]["anthropic"]["anthropic"]["models"]["default"] == novel
def test_configure_models_add_gateway_serves_both_harnesses(isolated_config) -> None:
"""A gateway can serve BOTH harness surfaces (Codex + Claude) in one add.
Per the feedback that a gateway / custom endpoint (LiteLLM-style) should
be usable from both the claude-sdk and codex/openai-agents harnesses, the
add flow asks for each surface (defaulting to both). Accepting both writes
an `openai` AND an `anthropic` family block pointing at the same base_url.
"""
# Enter via Claude. L1 1=Claude → L2 1=+Add → anthropic menu 3=Gateway →
# name; base_url; key; surfaces 1="Both Claude and Codex"; wire
# 1=Responses; default model for the OpenAI surface ("gpt-ll") then the
# Claude surface ("claude-ll") → L2 q=back → L1 q=exit.
stdin = (
"\n".join(
[
"1",
"1",
"3",
"litellm",
"https://litellm.example/v1",
"sk-ll",
"1",
"1",
"gpt-ll",
"claude-ll",
"q",
"q",
]
)
+ "\n"
)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
entry = _config_yaml(isolated_config)["providers"]["litellm"]
assert entry["kind"] == "gateway"
# Both surfaces present, same base_url — usable from claude-sdk AND codex.
assert entry["openai"]["base_url"] == "https://litellm.example/v1"
assert entry["anthropic"]["base_url"] == "https://litellm.example/v1"
# Responses wire was chosen for the openai surface.
assert entry["openai"].get("wire_api") == "responses"
# Per-surface default models are pinned (a gateway has no catalog default).
assert entry["openai"]["models"]["default"] == "gpt-ll"
assert entry["anthropic"]["models"]["default"] == "claude-ll"
def test_configure_models_add_openrouter_key_uses_vendor_endpoint_and_chat_wire(
isolated_config, monkeypatch
) -> None:
"""Adding the OpenRouter *key* preset points at openrouter.ai + Chat wire.
Root cause of "OpenRouter doesn't work (LiteLLM does)": a third-party
OpenAI-compatible key got the openai-*family* default base_url
(api.openai.com) and no wire override, so requests went to OpenAI with an
OpenRouter key on the Responses API. The key add now uses the vendor's own
endpoint (openrouter.ai) and `wire_api: chat`.
"""
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
# OpenRouter key is openai-family → Codex add menu. L1 2=Codex → L2
# 1=+Add → openai menu 4="OpenRouter — API key" (order: OpenAI key,
# ChatGPT sub, Gateway, OpenRouter, Databricks, Other) → key → default
# model blank → L2 q=back → L1 q=exit.
stdin = "\n".join(["2", "1", "4", "sk-or-test", "", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
entry = _config_yaml(isolated_config)["providers"]["openrouter"]["openai"]
assert entry["base_url"] == "https://openrouter.ai/api/v1" # NOT api.openai.com
assert entry["wire_api"] == "chat" # OpenRouter is Chat-Completions-only
def test_promote_global_auth_backfills_databricks_for_existing_configs(isolated_config) -> None:
"""An old ``auth:``-only config self-heals into a databricks provider.
Before this PR, ``setup`` wrote only the global ``auth: {type: databricks}``
block, invisible to ``configure harnesses``. Existing users would still see no
databricks even after upgrading — unless they re-ran ``setup``.
``_promote_global_auth_to_provider`` backfills that block into a first-class
``kind: databricks`` providers entry on the next ``configure harnesses`` open,
defaulting both families (the config only ever had the auth: block, so
routing already used databricks for both).
"""
from omnigent.cli import _promote_global_auth_to_provider, _save_global_config
_save_global_config({"auth": {"type": "databricks", "profile": "oss"}})
assert _promote_global_auth_to_provider() == "databricks"
cfg = load_config()
providers = load_providers(cfg)
assert "databricks" in providers
assert providers["databricks"].profile == "oss"
assert get_default_provider(cfg, "anthropic").name == "databricks"
assert get_default_provider(cfg, "openai").name == "databricks"
# Idempotent: a databricks provider already exists, so re-opening configure
# does not double-promote (would otherwise churn the config every open).
assert _promote_global_auth_to_provider() is None
def test_promote_global_auth_respects_explicit_default(isolated_config) -> None:
"""Promotion mirrors routing precedence: an explicit provider default wins.
Routing puts an explicit ``providers:`` default ahead of the ``auth:`` block,
so when a family already has a provider default, the backfilled databricks
must NOT steal it — it only claims families with no existing default. Here
an explicit anthropic key default is kept while databricks takes openai.
"""
from omnigent.cli import _promote_global_auth_to_provider, _save_global_config
_save_global_config(
{
"auth": {"type": "databricks", "profile": "oss"},
"providers": {
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "keychain:anthropic",
"models": {"default": "claude-opus-4-8"},
},
},
},
}
)
assert _promote_global_auth_to_provider() == "databricks"
cfg = load_config()
# Explicit anthropic default untouched; databricks only took the open
# (openai) family — exactly what routing would resolve.
assert get_default_provider(cfg, "anthropic").name == "anthropic"
assert get_default_provider(cfg, "openai").name == "databricks"
def test_promote_global_auth_noop_without_databricks_auth(isolated_config) -> None:
"""No databricks ``auth:`` block → nothing to backfill (returns None)."""
from omnigent.cli import _promote_global_auth_to_provider, _save_global_config
# An api_key auth block (not databricks) must not synthesize a databricks
# provider, and a config with no auth: block at all is a clean no-op.
_save_global_config({"auth": {"type": "api_key", "api_key": "sk-x"}})
assert _promote_global_auth_to_provider() is None
assert "databricks" not in load_providers(load_config())
def _databricks_add_menu_index() -> int:
"""Return the 1-based numbered-fallback position of the Databricks option.
Computed from the live Claude-scoped add menu rather than hardcoded, so a
future reordering of :func:`add_menu_options` doesn't silently point this
test's piped stdin at the wrong row.
:returns: The 1-based index of the ``databricks``-kind option within the
Claude (anthropic) add menu, e.g. ``4``.
"""
from omnigent.onboarding.configure_models import add_menu_options_for_family
from omnigent.onboarding.provider_config import ANTHROPIC_FAMILY, DATABRICKS_KIND
opts = add_menu_options_for_family(ANTHROPIC_FAMILY)
return next(i for i, o in enumerate(opts) if o.kind == DATABRICKS_KIND) + 1
def test_configure_harnesses_add_databricks_normalizes_url_and_persists(
isolated_config, monkeypatch
) -> None:
"""The Databricks add branch normalizes the workspace URL, logs in + runs
ucode against that same URL, and persists a default ``databricks`` provider.
Drives the real ``_configure_harness_add`` databricks branch through the CLI
(numbered fallback), stubbing only the two boundary helpers that shell out
(``login_databricks_workspace`` → returns a profile; ``configure_ucode_for_workspace``)
and ``ucode_workspace_exists`` (→ True). Asserts the user-entered
``"example.cloud.databricks.com/"`` (no scheme, trailing slash) is normalized to
``"https://example.cloud.databricks.com"`` for BOTH the login and the ucode call,
and that ``providers.databricks`` is written as the default. Added under the
Claude harness, so ucode is scoped to ``--agents claude`` (NOT codex/pi) and
the provider defaults only the Claude (anthropic) family. A regression in URL
handling, the per-harness scoping, or persistence surfaces here.
"""
login_calls: list[str] = []
ucode_calls: list[tuple[str, list[str] | None]] = []
exists_calls: list[str] = []
def _fake_login(url: str, *, console: object | None = None) -> str:
login_calls.append(url)
return "my-ws"
def _fake_configure_ucode(url: str, *, agents: list[str] | None = None) -> None:
ucode_calls.append((url, agents))
def _fake_exists(url: str) -> bool:
exists_calls.append(url)
return True
# Patch at the source modules — the databricks branch imports these at call
# time, so the attribute lookup resolves to these stubs.
monkeypatch.setattr("omnigent.onboarding.setup.login_databricks_workspace", _fake_login)
monkeypatch.setattr(
"omnigent.onboarding.ucode_setup.configure_ucode_for_workspace", _fake_configure_ucode
)
monkeypatch.setattr("omnigent.onboarding.ucode_setup.ucode_workspace_exists", _fake_exists)
db = _databricks_add_menu_index()
# L1 1=Claude → L2 1=+Add → add menu <db>=Databricks → workspace URL (no
# scheme + trailing slash, to exercise normalization) → L2 q=back → L1 q=exit.
stdin = "\n".join(["1", "1", str(db), "example.cloud.databricks.com/", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
normalized = "https://example.cloud.databricks.com"
# Login + ucode each ran exactly once, against the normalized URL — if the
# branch dropped the scheme-prefixing or trailing-slash strip, these fail.
assert login_calls == [normalized]
assert exists_calls == [normalized]
# ucode is scoped to the drilled-in harness only: added under Claude →
# `--agents claude`, NOT the legacy claude,codex,pi. A regression to the
# hardcoded agent set (configuring/installing harnesses the user didn't
# pick) changes this and fails here.
assert ucode_calls == [(normalized, ["claude"])]
cfg = _config_yaml(isolated_config)
# Persisted as a kind=databricks provider keyed on the returned profile, and
# made the default for ONLY the Claude (anthropic) family it was added under
# — `default: "anthropic"`, not `True` (which would claim both families).
assert cfg["providers"]["databricks"] == {
"kind": "databricks",
"profile": "my-ws",
"default": "anthropic",
}
assert get_default_provider(cfg, "anthropic").name == "databricks"
# Codex was NOT configured in ucode, so it must NOT be defaulted to
# Databricks (that would route Codex through a workspace ucode never set up
# for it). It stays unset here — add Databricks under Codex to wire it.
assert get_default_provider(cfg, "openai") is None
def test_configure_harnesses_add_databricks_fails_loud_when_ucode_records_no_state(
isolated_config, monkeypatch
) -> None:
"""If ``ucode configure`` records no state for the workspace, the add aborts
loudly and persists NO databricks provider.
Guards against a half-configured provider: routing would otherwise silently
fall back. With ``ucode_workspace_exists`` → False the branch must raise, so
the command exits non-zero and ``providers`` stays empty.
"""
monkeypatch.setattr(
"omnigent.onboarding.setup.login_databricks_workspace",
lambda url, *, console=None: "my-ws",
)
monkeypatch.setattr(
"omnigent.onboarding.ucode_setup.configure_ucode_for_workspace",
lambda url, *, agents=None: None,
)
# ucode "succeeded" but left no state for this workspace.
monkeypatch.setattr(
"omnigent.onboarding.ucode_setup.ucode_workspace_exists", lambda url: False
)
db = _databricks_add_menu_index()
stdin = "\n".join(["1", "1", str(db), "https://example.cloud.databricks.com", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
# The branch raised ClickException → non-zero exit with an explanatory message.
assert result.exit_code != 0
assert "recorded no state" in result.output
# Nothing was persisted — no half-configured databricks provider.
cfg = _config_yaml(isolated_config)
assert "databricks" not in cfg.get("providers", {})
def test_configure_harnesses_add_databricks_under_codex_scopes_to_codex(
isolated_config, monkeypatch
) -> None:
"""Adding Databricks under the Codex harness scopes ucode to ``--agents codex``
and defaults only the Codex (openai) family.
The mirror of the Claude-path test: the per-harness scoping must follow
whichever harness the user drilled into, so the Claude family is left
untouched here.
"""
from omnigent.onboarding.configure_models import add_menu_options_for_family
from omnigent.onboarding.provider_config import DATABRICKS_KIND, OPENAI_FAMILY
ucode_calls: list[tuple[str, list[str] | None]] = []
monkeypatch.setattr(
"omnigent.onboarding.setup.login_databricks_workspace",
lambda url, *, console=None: "my-ws",
)
monkeypatch.setattr(
"omnigent.onboarding.ucode_setup.configure_ucode_for_workspace",
lambda url, *, agents=None: ucode_calls.append((url, agents)),
)
monkeypatch.setattr("omnigent.onboarding.ucode_setup.ucode_workspace_exists", lambda url: True)
# Databricks position within the Codex (openai) add menu, computed live.
codex_opts = add_menu_options_for_family(OPENAI_FAMILY)
db = next(i for i, o in enumerate(codex_opts) if o.kind == DATABRICKS_KIND) + 1
# L1 2=Codex → L2 1=+Add → add menu <db>=Databricks → URL → q → q.
stdin = "\n".join(["2", "1", str(db), "https://example.cloud.databricks.com", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
assert ucode_calls == [("https://example.cloud.databricks.com", ["codex"])]
cfg = _config_yaml(isolated_config)
assert get_default_provider(cfg, "openai").name == "databricks"
assert get_default_provider(cfg, "anthropic") is None
def test_uninstalled_harness_shows_x_and_not_installed(isolated_config, monkeypatch) -> None:
"""A harness whose CLI isn't installed renders a red ✗ "Not installed" status.
Overrides the installed-by-default fixture. The level-1 overview folds the
readiness into the row's aligned status column: an absent CLI reads
``✗ Not installed`` inline (the exact install command is the selection-only
description, surfaced when the row is highlighted, not in the always-visible
row).
"""
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: False
)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input="q\n")
assert result.exit_code == 0, result.output
out = result.output
assert "✗" in out
assert "Not installed" in out
def test_overview_marks_unconfigured_with_x_and_configured_without_checkmark(
isolated_config,
) -> None:
"""Level 1: a configured harness carries no name-level ✓; an unconfigured one gets ✗.
Seeds only an Anthropic (Claude) default and leaves Codex with no
credential. The overview must (1) drop the old green ✓ next to the
configured Claude name — the green ✓ now lives only on the status column —
and (2) mark the installed-but-unconfigured Codex with a ✗ "Not configured"
status. A regression that restores the name-level ✓ or fails to flag the
empty harness surfaces here.
"""
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{
"providers": {
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "keychain:anthropic",
"models": {"default": "claude-sonnet-4-6"},
},
}
}
},
f,
)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input="q\n")
assert result.exit_code == 0, result.output
out = result.output
# Configured Claude: the green ✓ rides the aligned status column, not the
# name — so "✓ Claude" never appears, but the credential's "✓ …" does.
assert "✓ Claude" not in out
assert "✓ Anthropic API Key" in out
# Installed-but-unconfigured Codex: the row's status is a ✗ "Not configured".
assert "Codex" in out
assert "✗ Not configured" in out
def _capture_setup_overview(
monkeypatch,
) -> tuple[list[str], list[bool], list[str], bool, int | None]:
"""Render the level-1 setup overview once and capture the menu it builds.
Monkeypatches the shared ``select`` so the picker records the rows it would
draw, then returns ``-1`` (Esc) so setup exits after one frame. Returns the
captured ``(options, selectable, descriptions, compact, max_visible)`` —
enough to assert the row set, ordering, single-line compactness, no
hidden/windowed rows, and the selection-only install hints without driving
a real TTY.
"""
captured: dict[str, object] = {}
def _capture_select(
title: str,
options: list[str],
*,
selectable: list[bool] | None = None,
descriptions: list[str] | None = None,
compact: bool = False,
max_visible: int | None = None,
clear_on_exit: bool = False,
**_kwargs: object,
) -> int:
assert title == "Configure harnesses"
assert selectable is not None
assert descriptions is not None
captured.update(
options=options,
selectable=selectable,
descriptions=descriptions,
compact=compact,
max_visible=max_visible,
)
return -1
monkeypatch.setattr("omnigent.onboarding.interactive.select", _capture_select)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"])
assert result.exit_code == 0, result.output
return (
captured["options"], # type: ignore[return-value]
captured["selectable"], # type: ignore[return-value]
captured["descriptions"], # type: ignore[return-value]
captured["compact"], # type: ignore[return-value]
captured["max_visible"], # type: ignore[return-value]
)
def _overview_row_names(options: list[str], selectable: list[bool]) -> list[str]:
"""Extract the harness / Quit names from a captured overview frame.
Each row label is ``"<name><padding>[<color>]<glyph> <status>[/]"``; the
name is the text before the status gutter, recovered after Rich
markup is stripped.
"""
import re
from rich.text import Text
names: list[str] = []
for option, is_selectable in zip(options, selectable, strict=True):
if not is_selectable:
continue
plain = Text.from_markup(option).plain
names.append(re.split(r"\s{2,}", plain, maxsplit=1)[0].strip())
return names
def test_overview_lists_all_harnesses_in_priority_order(isolated_config, monkeypatch) -> None:
"""The overview shows every harness on one compact row, in 0.3 priority order.
No "More" folding: all thirteen harnesses are visible at once, followed by
Quit. A regression that hides a harness, reorders the core six, or
reintroduces a collapse row fails here. The menu also opts into the compact
top-level rendering.
"""
from omnigent.onboarding import interactive
options, selectable, descriptions, compact, max_visible = _capture_setup_overview(monkeypatch)
expected = [
"Claude",
"Codex",
"Cursor",
"OpenCode",
"Hermes",
"Pi",
"Antigravity",
"Qwen Code",
"Goose",
"Copilot",
"Kiro",
"Kimi Code",
"Custom ACP agent",
"Quit",
]
assert _overview_row_names(options, selectable) == expected
assert compact is True
assert max_visible is None
rendered = interactive._render_menu(
"Configure harnesses",
options,
0,
descriptions=descriptions,
width=80,
selectable=selectable,
compact=compact,
max_visible=max_visible,
)
for row in expected:
assert row in rendered
assert "more" not in rendered.lower()
def test_overview_lists_configured_acp_agents_as_rows(isolated_config, monkeypatch) -> None:
"""Each configured ACP agent gets its own top-level overview row.
Promotes the generic-ACP agents out of the drill-in so they sit alongside
the built-in harnesses (matching the web picker, which lists each
``acp:<slug>``), followed by an "Add custom ACP agent" row. A regression that
re-buries them under a single opaque "Custom ACP agent" row fails here.
"""
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{
"acp": {
"agents": [
{"name": "Gemini CLI", "command": "gemini --experimental-acp"},
{"name": "My Goose", "command": "goose acp"},
]
}
},
f,
)
options, selectable, _descriptions, _compact, _max_visible = _capture_setup_overview(
monkeypatch
)
names = _overview_row_names(options, selectable)
assert "Gemini CLI" in names
assert "My Goose" in names
assert "Add custom ACP agent" in names
# Once agents exist, the single opaque "Custom ACP agent" row is gone.
assert "Custom ACP agent" not in names
def test_overview_rows_are_single_line(isolated_config, monkeypatch) -> None:
"""Every overview row is a single selectable line — no skipped sub-lines.
The compact layout folds each harness's status into its row (aligned column)
instead of a dim sub-line beneath it, so the cursor lands on every rendered
row and each row carries a (possibly empty) description. A regression that
brings back non-selectable sub-lines fails here.
"""
options, selectable, descriptions, _compact, _max_visible = _capture_setup_overview(
monkeypatch
)
assert all(selectable)
assert len(descriptions) == len(options)
def test_overview_lists_kiro_row(isolated_config, monkeypatch) -> None:
"""Kiro is a first-class harness row with its own status + selection hint.
Kiro (``kiro-native``) is a native CLI harness with its own auth
(``kiro-cli login``). Absent the CLI the row reads ``✗ Not installed`` and
its selection-only description names the curl installer; installed, it still
reads ``✗ Not configured`` because there is no reliable auth probe. A
regression that drops the Kiro row or overstates readiness fails here.
"""
from rich.text import Text
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: False
)
options, selectable, descriptions, _, _max_visible = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
kiro = names.index("Kiro")
assert "Not installed" in Text.from_markup(options[kiro]).plain
assert "cli.kiro.dev/install" in Text.from_markup(descriptions[kiro]).plain
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: True
)
options, selectable, descriptions, _, _max_visible = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
kiro = names.index("Kiro")
assert "Not configured" in Text.from_markup(options[kiro]).plain
assert "kiro-cli login" in Text.from_markup(descriptions[kiro]).plain
def test_overview_hermes_row_reflects_configured_model(isolated_config, monkeypatch) -> None:
"""Hermes reads ready (with its picked model) once ``hermes model`` has run.
Regression for the overview hardcoding an installed Hermes to
``✗ Not configured`` regardless of ``~/.hermes/config.yaml``. With the
``hermes`` binary present:
* a fresh scaffold (``model.provider: auto`` — nothing picked yet) still
reads a yellow ``✗ Not configured`` and points at ``hermes model``;
* a finished ``hermes model`` run (a concrete ``provider`` + ``default``
model) reads a green ✓ with ``"<provider> / <model>"``.
HOME is the isolated tmp dir (``isolated_config``), so the probe reads the
config written here, not the developer's real ``~/.hermes``. The probe
binds ``harness_cli_installed`` at import, so patch the ``hermes_auth``
symbol it actually calls rather than relying on the install fixture.
"""
from rich.text import Text
monkeypatch.setattr("omnigent.onboarding.hermes_auth.hermes_cli_installed", lambda: True)
hermes_dir = os.path.join(isolated_config, ".hermes")
os.makedirs(hermes_dir, exist_ok=True)
hermes_config = os.path.join(hermes_dir, "config.yaml")
# Fresh scaffold: provider "auto" (auto-detect), nothing picked → unconfigured.
with open(hermes_config, "w") as f:
yaml.safe_dump({"model": {"default": "anthropic/claude-opus-4.6", "provider": "auto"}}, f)
options, selectable, descriptions, _, _max_visible = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
hermes = names.index("Hermes")
assert "[yellow]✗ Not configured[/]" in options[hermes]
assert "hermes model" in Text.from_markup(descriptions[hermes]).plain
# Configured: a concrete provider + model picked → green ✓ with the model.
with open(hermes_config, "w") as f:
yaml.safe_dump({"model": {"default": "z-ai/glm-5.2", "provider": "openrouter"}}, f)
options, selectable, _descriptions, _, _max_visible = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
hermes = names.index("Hermes")
assert "[green]✓" in options[hermes]
plain = Text.from_markup(options[hermes]).plain
assert "openrouter" in plain
assert "z-ai/glm-5.2" in plain
def test_overview_truncates_long_status_for_narrow_terminal(isolated_config, monkeypatch) -> None:
"""Verbose ready statuses are capped from the terminal width before rendering.
The compact overview promises one row per harness. A fixed status cap still
lets rows wrap at the selector's 40-column minimum, so the cap must derive
from the actual terminal width. This forces a long OpenCode summary and a
40-column terminal, then renders the captured rows to prove the OpenCode row
remains within the terminal-cell width and carries an ellipsis. The summary
includes wide CJK / emoji text so this catches regressions back to Python
``len()`` slicing.
"""
import os
import re
from rich.cells import cell_len
from omnigent.onboarding import interactive
from omnigent.onboarding.opencode_auth import OpenCodeAuthSummary
monkeypatch.setattr(
"omnigent.cli.shutil.get_terminal_size", lambda fallback: os.terminal_size((40, 24))
)
monkeypatch.setattr(
"omnigent.onboarding.opencode_auth.opencode_auth_summary",
lambda: OpenCodeAuthSummary(
installed=True,
stored_providers=("anthropic", "データブリックス", "🚀provider"),
env_providers=("OpenAI", "长模型供应商", "OpenRouter"),
),
)
options, selectable, descriptions, compact, max_visible = _capture_setup_overview(monkeypatch)
rendered = interactive._render_menu(
"Configure harnesses",
options,
_overview_row_names(options, selectable).index("OpenCode"),
descriptions=descriptions,
width=40,
selectable=selectable,
compact=compact,
max_visible=max_visible,
)
plain = re.sub(r"\x1b\[[0-9;]*m", "", rendered)
opencode_line = next(line for line in plain.splitlines() if "OpenCode" in line)
assert "…" in opencode_line
assert cell_len(opencode_line) <= 40
@pytest.mark.parametrize(
"choice,manager_attr",
[
("4", "_manage_opencode_harness"),
("5", "_manage_hermes_harness"),
("8", "_manage_qwen_harness"),
("9", "_manage_goose_harness"),
("10", "_manage_copilot_harness"),
("11", "_manage_kiro_harness"),
("12", "_manage_kimi_harness"),
("13", "_add_acp_agent"),
],
)
def test_overview_dispatches_to_correct_manager(
isolated_config, monkeypatch, choice: str, manager_attr: str
) -> None:
"""Selecting a harness routes to its drill-in, pinning position→sentinel→manager.
The ordering test asserts row *names* only, so a copy-paste slip that paired
the wrong sentinel with a name (e.g. ``(_QWEN, "Goose", …)``) would route
"Goose" to ``_manage_qwen_harness`` yet still pass the name check. This drives
the real numbered-fallback dispatch end-to-end for the seven harnesses whose
positions no other scripted-stdin test exercises (Claude/Codex/Cursor/Pi/
Antigravity are covered by the add/remove/key tests), so a misrouted row is
caught here.
"""
called: list[str] = []
monkeypatch.setattr(
f"omnigent.cli.{manager_attr}", lambda *a, **k: called.append(manager_attr)
)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=f"{choice}\nq\n")
assert result.exit_code == 0, result.output
assert called == [manager_attr]
def test_overview_status_color_distinguishes_missing_from_unconfigured(
isolated_config, monkeypatch
) -> None:
"""The ✗ status color encodes *absent* (red) vs *installed-but-unconfigured* (yellow).
The kind taxonomy (``missing`` → red, ``warn`` → yellow, ``ready`` → green)
is the whole point of the status column, but the other overview tests assert
only the glyph + text. Here we capture the raw row markup (pre-render) and
pin the color so a regression that, say, paints an absent CLI yellow (telling
a user a missing tool is merely "unconfigured") fails.
"""
# Installed but unconfigured → yellow ✗ (a usable harness awaiting setup).
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: True
)
options, selectable, _descriptions, _compact, _max_visible = _capture_setup_overview(
monkeypatch
)
codex = options[_overview_row_names(options, selectable).index("Codex")]
assert "[yellow]✗ Not configured[/]" in codex
# CLI absent → red ✗ (nothing to use yet).
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: False
)
options, selectable, _descriptions, _compact, _max_visible = _capture_setup_overview(
monkeypatch
)
codex = options[_overview_row_names(options, selectable).index("Codex")]
assert "[red]✗ Not installed[/]" in codex
@pytest.mark.parametrize("name", ["Kiro", "Kimi Code"])
def test_installed_native_cli_auth_unknown_rows_are_not_configured(
isolated_config, monkeypatch, name: str
) -> None:
"""Installed native CLIs with opaque auth/config state must not render ready.
Kiro and Kimi expose installation separately from their own provider/login
configuration. Since setup has no reliable local auth probe for them yet, an
installed binary should be yellow ``Not configured`` with a next-step hint —
not a green ``Installed`` row that implies the harness is ready to use.
(Hermes, like Goose, *does* have a config probe now — its ``model`` is read
from ``~/.hermes/config.yaml`` — so its ready/unconfigured split is covered
by ``test_overview_hermes_row_reflects_configured_model`` instead.)
"""
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: True
)
options, selectable, descriptions, _compact, _max_visible = _capture_setup_overview(
monkeypatch
)
row_index = _overview_row_names(options, selectable).index(name)
assert "[yellow]✗ Not configured[/]" in options[row_index]
assert "[green]✓ Installed[/]" not in options[row_index]
assert descriptions[row_index]
def test_overview_descriptions_map_to_their_rows(isolated_config, monkeypatch) -> None:
"""Each compact row keeps the next-step hint attached to the same harness.
The visible overview is intentionally compact, so the row description is
where the user learns the specific next action. This pins representative
descriptions across provider-backed rows, SDK-extra rows, and native CLI
rows so a future row/description ordering slip can't put (for example)
Goose's hint under Qwen.
"""
from rich.text import Text
from omnigent.onboarding.goose_auth import GooseConfigSummary
from omnigent.onboarding.hermes_auth import HermesConfigSummary
from omnigent.onboarding.opencode_auth import OpenCodeAuthSummary
monkeypatch.setattr("omnigent.onboarding.cursor_auth.cursor_sdk_installed", lambda: True)
monkeypatch.setattr(
"omnigent.onboarding.antigravity_auth.antigravity_sdk_installed", lambda: True
)
monkeypatch.setattr("omnigent.onboarding.copilot_auth.copilot_sdk_installed", lambda: True)
monkeypatch.setattr(
"omnigent.onboarding.opencode_auth.opencode_auth_summary",
lambda: OpenCodeAuthSummary(installed=True, stored_providers=(), env_providers=()),
)
monkeypatch.setattr(
"omnigent.onboarding.goose_auth.goose_config_summary",
lambda: GooseConfigSummary(installed=True, provider=None, model=None),
)
# Installed but no provider picked → the "Open to configure" warn hint.
monkeypatch.setattr(
"omnigent.onboarding.hermes_auth.hermes_config_summary",
lambda: HermesConfigSummary(installed=True, provider=None, model=None),
)
options, selectable, descriptions, _compact, _max_visible = _capture_setup_overview(
monkeypatch
)
desc_by_name = {
name: Text.from_markup(desc).plain
for name, desc in zip(_overview_row_names(options, selectable), descriptions, strict=True)
}
assert desc_by_name["Claude"] == "Open to add a credential."
assert desc_by_name["Codex"] == "Open to add a credential."
assert desc_by_name["Cursor"] == "Open to add the Cursor API key."
assert desc_by_name["OpenCode"] == "Open to sign in (opencode auth login)."
assert desc_by_name["Hermes"] == "Open to configure with `hermes model`."
assert desc_by_name["Pi"] == "Open to add a credential."
assert desc_by_name["Antigravity"] == "Open to add the Gemini API key."
assert desc_by_name["Qwen Code"] == "Open to set up auth (/auth or env vars)."
assert desc_by_name["Goose"] == "Open to run `goose configure`."
assert desc_by_name["Copilot"] == "Open to add the GitHub token."
assert desc_by_name["Kiro"] == "Sign in with `kiro-cli login`."
assert desc_by_name["Kimi Code"] == "Sign in with `kimi login`."
assert desc_by_name["Quit"] == ""
def test_drill_into_uninstalled_installs_then_proceeds(isolated_config, monkeypatch) -> None:
"""Selecting an uninstalled harness → 'Yes, install' runs the install and
proceeds to credential setup.
The install boundary (``install_harness_cli``) is stubbed to succeed; the
test asserts it was invoked for the right family. A regression that skipped
the install or called it for the wrong harness fails here.
"""
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: False
)
installed: list[str] = []
monkeypatch.setattr(
"omnigent.onboarding.harness_install.install_harness_cli",
lambda family: installed.append(family) or True,
)
# L1 1=Claude → install prompt 1=Yes (install) → L2 credential menu q=back
# → L1 q=exit.
stdin = "\n".join(["1", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
assert installed == ["anthropic"] # installed exactly the drilled-in harness
def test_decline_install_returns_without_installing(isolated_config, monkeypatch) -> None:
"""Choosing 'No' at the install prompt returns to the picker, no install."""
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: False
)
def _must_not_install(family: str) -> bool:
raise AssertionError("install_harness_cli called despite declining")
monkeypatch.setattr(
"omnigent.onboarding.harness_install.install_harness_cli", _must_not_install
)
# L1 1=Claude → install prompt 2=No → L1 q=exit.
stdin = "\n".join(["1", "2", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
# ── the Pi harness page ───────────────────────────────────────────────
def test_pi_add_menu_offers_keys_gateway_databricks_but_no_subscription() -> None:
"""The Pi-scoped add menu offers every credential pi can use — and only those.
pi consumes both model families, so both vendors' API keys, gateways,
OpenRouter, "Other provider", and Databricks all appear; the claude /
codex subscriptions must NOT (a CLI login is unusable outside its own
CLI — offering it would configure a credential pi silently can't use).
"""
from omnigent.onboarding.provider_config import PI_SURFACE
options = add_menu_options_for_family(PI_SURFACE)
kinds = {o.kind for o in options}
# No subscription row — the one credential kind pi can't consume.
assert "subscription" not in kinds
# Both vendors' keys are offered (pi spans both families), plus the
# cross-vendor extras and Databricks.
assert any(o.label.endswith("Anthropic — API key") for o in options)
assert any(o.label.endswith("OpenAI — API key") for o in options)
assert "gateway" in kinds
assert "databricks" in kinds
def test_configure_harnesses_pi_page_sets_explicit_pi_default(isolated_config) -> None:
"""Making a credential the Pi default writes the explicit pi scope.
Seed two keys (anthropic the default for its family — so pi initially
rides the anthropic-preferred fallback) and use the Pi page to make the
openai key pi's default. The persisted openai entry must carry the pi
scope, pi resolution must follow it, and BOTH family defaults must be
untouched — the per-surface coexistence invariant extended to pi.
"""
from omnigent.onboarding.provider_config import default_provider_for_harness
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{
"providers": {
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "keychain:anthropic",
"models": {"default": "claude-sonnet-4-6"},
},
},
"openai": {
"kind": "key",
"default": True,
"openai": {
"base_url": "https://api.openai.com/v1",
"api_key_ref": "keychain:openai",
"models": {"default": "gpt-5.5"},
},
},
}
},
f,
)
# L1 6=Pi → L2 (1=anthropic 2=openai 3=+Add): select openai (2) → L3
# 1=Make default for Pi → back to L2 q=back → L1 q=exit.
stdin = "\n".join(["6", "2", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = load_config()
# pi now explicitly routes through the openai key — beating the
# anthropic-preferred fallback it rode before the action.
assert default_provider_for_harness(cfg, "pi").name == "openai"
# The family defaults are untouched: setting the pi scope must not
# disturb either harness's own slot.
assert get_default_provider(cfg, "anthropic").name == "anthropic"
assert get_default_provider(cfg, "openai").name == "openai"
# The persisted form carries the explicit scope (not collapsed to true,
# which re-parses without pi — the round-trip bug).
raw_default = _config_yaml(isolated_config)["providers"]["openai"]["default"]
assert sorted(raw_default) == ["openai", "pi"]
def test_configure_harnesses_pi_page_excludes_subscription_rows(isolated_config) -> None:
"""The Pi page lists only credentials pi can use; subscriptions are absent.
Seed a claude subscription (the anthropic default) plus an openai key.
Pi's level 2 must show the key — marked ✓ default via the fallback,
which skips the subscription — and no ``Subscription`` row at all. A
regression that lists subscriptions under Pi lets the user "select" a
credential the pi harness silently can't consume.
"""
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{
"providers": {
"claude-subscription": {
"kind": "subscription",
"cli": "claude",
"default": True,
},
"openai": {
"kind": "key",
"default": True,
"openai": {
"base_url": "https://api.openai.com/v1",
"api_key_ref": "keychain:openai",
},
},
}
},
f,
)
# L1 6=Pi → L2 renders its rows → q=back → L1 q=exit. The L2 frame is
# cleared on exit under a TTY but the numbered fallback echoes options.
stdin = "\n".join(["6", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
# Isolate the Pi level-2 frame (after the "Pi — select or add" title).
pi_frame = result.output.split("Pi — select or add a credential", 1)[1]
pi_frame = pi_frame.split("Configure harnesses", 1)[0]
# The key row appears and carries the effective-default marker (the
# fallback skipped the subscription); no Subscription row is offered.
assert "OpenAI API Key" in pi_frame
assert "✓ default" in pi_frame
assert "Subscription" not in pi_frame
def test_configure_harnesses_add_databricks_under_pi_scopes_to_pi(
isolated_config, monkeypatch
) -> None:
"""Adding Databricks under the Pi harness scopes ucode to ``--agents pi``
and defaults only the pi surface.
The pi mirror of the Claude/Codex-path tests: ucode must configure only
the pi tool, and the provider must claim only the explicit pi scope —
routing Claude/Codex through a workspace ucode never configured for
them would be the regression.
"""
from omnigent.onboarding.configure_models import add_menu_options_for_family
from omnigent.onboarding.provider_config import (
DATABRICKS_KIND,
PI_SURFACE,
default_provider_for_harness,
)
ucode_calls: list[tuple[str, list[str] | None]] = []
monkeypatch.setattr(
"omnigent.onboarding.setup.login_databricks_workspace",
lambda url, *, console=None: "my-ws",
)
monkeypatch.setattr(
"omnigent.onboarding.ucode_setup.configure_ucode_for_workspace",
lambda url, *, agents=None: ucode_calls.append((url, agents)),
)
monkeypatch.setattr("omnigent.onboarding.ucode_setup.ucode_workspace_exists", lambda url: True)
# Databricks position within the Pi add menu, computed live.
pi_opts = add_menu_options_for_family(PI_SURFACE)
db = next(i for i, o in enumerate(pi_opts) if o.kind == DATABRICKS_KIND) + 1
# L1 6=Pi → L2 1=+Add → add menu <db>=Databricks → URL → q → q.
stdin = "\n".join(["6", "1", str(db), "https://example.cloud.databricks.com", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
# ucode ran once, for the pi agent only — not the legacy claude,codex,pi.
assert ucode_calls == [("https://example.cloud.databricks.com", ["pi"])]
cfg = _config_yaml(isolated_config)
# The provider claims only the explicit pi scope it was added under.
assert cfg["providers"]["databricks"] == {
"kind": "databricks",
"profile": "my-ws",
"default": "pi",
}
assert default_provider_for_harness(load_config(), "pi").name == "databricks"
# Claude/Codex stay unset — ucode never configured them for this workspace.
assert get_default_provider(cfg, "anthropic") is None
assert get_default_provider(cfg, "openai") is None
def test_add_key_does_not_steal_pi_from_fallback_default(isolated_config) -> None:
"""A newly added key never claims the pi scope while a fallback serves pi.
Seed an anthropic key as the anthropic default — pi rides the
anthropic-preferred fallback onto it. Adding an openai key (under the
Codex page) auto-claims the free openai family but must NOT claim the
pi scope: pi's effective default already resolves, and stealing it
would silently re-route pi to the brand-new key.
"""
from omnigent.onboarding.provider_config import default_provider_for_harness
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump(
{
"providers": {
"anthropic": {
"kind": "key",
"default": True,
"anthropic": {
"base_url": "https://api.anthropic.com",
"api_key_ref": "keychain:anthropic",
},
},
}
},
f,
)
# L1 2=Codex → L2 1=+Add → 1=OpenAI key → decline detected env (none set)
# → paste key → accept catalog default model (blank) → L2 q → L1 q.
stdin = "\n".join(["2", "1", "1", "sk-test-openai", "", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = load_config()
# The new key took the free openai family default…
assert get_default_provider(cfg, "openai").name == "openai"
# …but pi still rides the fallback onto the anthropic key: the openai
# entry must not have claimed the explicit pi scope.
assert default_provider_for_harness(cfg, "pi").name == "anthropic"
raw_default = _config_yaml(isolated_config)["providers"]["openai"]["default"]
assert raw_default in (True, "openai", ["openai"])
# ── cli-config labels + entry builder ───────────────────────────────────────
def test_credential_label_cli_config_uses_display_name() -> None:
"""A cli-config credential labels as the provider's own display name.
Failure means configure-harnesses shows the raw entry id instead of
the friendly name isaac wrote into the provider table.
"""
from omnigent.onboarding.configure_models import credential_label
label = credential_label(
"cli-config", "codex-databricks", display_name="Databricks AI Gateway"
)
assert label == "Databricks AI Gateway"
def test_credential_label_cli_config_falls_back_to_entry_name() -> None:
"""Without a display name, the entry name is the label.
Failure (empty/None label) would render a blank credential row.
"""
from omnigent.onboarding.configure_models import credential_label
assert credential_label("cli-config", "codex-myproxy") == "codex-myproxy"
def test_build_cli_config_provider_entry_shapes() -> None:
"""The builder emits the exact config shape the parser requires.
Full-equality assertions: a drifted key would make adoption write
entries that fail to load on the next configure open.
"""
from omnigent.onboarding.configure_models import build_cli_config_provider_entry
assert build_cli_config_provider_entry("codex", "Databricks", "Databricks AI Gateway") == {
"kind": "cli-config",
"cli": "codex",
"model_provider": "Databricks",
"display_name": "Databricks AI Gateway",
}
# No display name → key omitted entirely (labels fall back to the
# entry name), not written as None/empty.
assert build_cli_config_provider_entry("codex", "MyProxy", None) == {
"kind": "cli-config",
"cli": "codex",
"model_provider": "MyProxy",
}
# ── cli-config removal dismissal + re-add ────────────────────────────────────
# The exact state `isaac configure codex` leaves behind: a custom provider
# with self-contained auth in config.toml, and NO auth.json.
_CODEX_CONFIG_TOML = """
model_provider = "Databricks"
[model_providers.Databricks]
name = "Databricks AI Gateway"
base_url = "https://example.ai-gateway.cloud.databricks.com/codex/v1"
[model_providers.Databricks.auth]
command = "jq"
"""
def _write_codex_config_toml(home) -> None:
"""Write an isaac-style ``~/.codex/config.toml`` under the test HOME.
:param home: The tmp HOME directory (the ``isolated_config`` fixture
redirects ``$HOME`` there).
"""
codex_dir = home / ".codex"
codex_dir.mkdir(exist_ok=True)
(codex_dir / "config.toml").write_text(_CODEX_CONFIG_TOML)
def test_remove_cli_config_credential_dismisses_detection(isolated_config) -> None:
"""Removing an adopted codex config.toml credential sticks across reopens.
The reported bug: Remove dropped the entry, but the next ``setup``
re-detected the unchanged ~/.codex/config.toml and silently re-adopted
it — making Remove a no-op. Removal must record a dismissal that the
next open honors.
"""
_write_codex_config_toml(isolated_config)
# Open 1: plain open auto-adopts the detected config provider.
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input="q\n")
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
# Adopted as a real entry — the feature's golden path. Absence means
# detection itself broke, not the removal under test.
assert "codex-databricks" in cfg["providers"]
# Open 2: L1 2=Codex → L2 1=the credential → L3 1=Remove (it is the
# codex default, so no "Make default" row precedes Remove) → q → q.
stdin = "\n".join(["2", "1", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert "codex-databricks" not in cfg["providers"]
# The dismissal is what makes Remove stick — without it open 3 re-adopts.
assert cfg["dismissed_detections"] == ["codex-databricks"]
# Open 3: a plain reopen must NOT re-adopt the dismissed detection.
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input="q\n")
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert "codex-databricks" not in cfg.get("providers", {})
def test_add_menu_readds_dismissed_cli_config_credential(isolated_config) -> None:
"""The add menu offers a dismissed config.toml provider; picking it re-adds.
Removal dismisses the detection (test above), so the add menu's
detected-config row is the only way back. Re-adding must persist the
entry, restore it as the codex default, and clear the dismissal.
"""
from omnigent.onboarding.configure_models import add_menu_options_for_family
from omnigent.onboarding.provider_config import OPENAI_FAMILY
_write_codex_config_toml(isolated_config)
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump({"dismissed_detections": ["codex-databricks"]}, f)
# The detected-config row is appended after the base codex-scoped
# options; select() input is its 1-based index.
detected_row = str(len(add_menu_options_for_family(OPENAI_FAMILY)) + 1)
# L1 2=Codex → L2 1=+Add (no credentials yet) → add menu: the appended
# detected-config row → back to L2 q → L1 q.
stdin = "\n".join(["2", "1", detected_row, "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
entry = cfg["providers"]["codex-databricks"]
# The persisted entry pins the config.toml provider by name and keeps
# the friendly display name for labels.
assert entry["kind"] == "cli-config"
assert entry["model_provider"] == "Databricks"
assert entry["display_name"] == "Databricks AI Gateway"
# Re-claims the codex (openai) default — there is no other credential.
assert entry["default"] is True or entry.get("default") == "true"
# The dismissal is cleared, so the credential behaves like an ordinary
# detection again instead of staying half-dismissed.
assert cfg["dismissed_detections"] == []
# ── Cursor API-key flow ─────────────────────────────────────────────────────
# Cursor runs via the ``cursor-sdk`` package and authenticates with a
# ``CURSOR_API_KEY``; it has no provider/gateway family. Its drill-in (L1 row 4)
# stores the key in the secret store + a dedicated ``cursor:`` config block,
# mirroring the other harnesses' api-key persistence. The menu is API-key-only
# (Set/Replace/Remove), so it touches neither the ``cursor-agent`` binary nor a
# login probe. ``isolated_config`` clears any ambient ``CURSOR_API_KEY``.
@pytest.fixture()
def _cursor_sdk_present(monkeypatch):
"""Force ``cursor-sdk`` detection to report installed.
The key-management tests below script the Cursor drill-in assuming no
install-offer. ``cursor-sdk`` is an opt-in extra (absent in CI), so without
this the drill-in's install-offer fires — consuming a scripted menu token
(desyncing the input) and even running a real ``uv pip install``. Patching the
source-module attribute is seen at every call site (it's resolved at call
time). Mirror of :func:`_cursor_sdk_absent`.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.setattr(
"omnigent.onboarding.cursor_auth.cursor_sdk_installed",
lambda: True,
)
def test_cursor_set_api_key_paste_writes_block_and_secret(
isolated_config, _cursor_sdk_present
) -> None:
"""Pasting a ``crsr_`` key stores the secret + writes the ``cursor:`` block.
Proves the api-key path: the secret lands in the store (never plaintext in
config) and the config references it via ``keychain:cursor``.
"""
# L1 3=Cursor → cursor menu 1=Set API key → paste key (crsr_ → no warn) →
# cursor menu q=back → L1 q=quit.
stdin = "\n".join(["3", "1", "crsr_test_key_123", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert cfg["cursor"] == {"api_key_ref": "keychain:cursor"}
# The pasted secret reached the store under the ``cursor`` name; config
# holds only the reference.
assert secrets.load_secret("cursor") == "crsr_test_key_123"
def test_cursor_adopt_env_api_key_writes_env_ref(
isolated_config, monkeypatch, _cursor_sdk_present
) -> None:
"""Adopting an existing ``$CURSOR_API_KEY`` records an ``env:`` ref only.
The env path must NOT copy the secret into the store — it points the config
at the live environment variable so the key never leaves the user's shell.
"""
monkeypatch.setenv("CURSOR_API_KEY", "crsr_env_key_456")
# L1 3=Cursor → 1=Set API key → "y" adopt detected $CURSOR_API_KEY →
# q back → q quit.
stdin = "\n".join(["3", "1", "y", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert cfg["cursor"] == {"api_key_ref": "env:CURSOR_API_KEY"}
assert secrets.load_secret("cursor") is None
def test_cursor_remove_api_key_drops_block_and_secret(
isolated_config, _cursor_sdk_present
) -> None:
"""Removing a Cursor key deletes the stored secret AND drops the config block."""
# Seed a stored key: the keychain secret + the ``cursor:`` block referencing it.
secrets.store_secret("cursor", "crsr_seeded")
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump({"cursor": {"api_key_ref": "keychain:cursor"}}, f)
# L1 3=Cursor → cursor menu (key set: 1=Replace 2=Remove 3=Back) → 2=Remove
# → q back → q quit.
stdin = "\n".join(["3", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert "cursor" not in cfg
assert secrets.load_secret("cursor") is None
def test_cursor_set_api_key_non_crsr_declined_is_not_stored(
isolated_config, _cursor_sdk_present
) -> None:
"""A non-``crsr_`` paste that the user declines to force is NOT persisted.
The soft prefix check warns and asks to store anyway; declining must leave
both the secret store and the config untouched.
"""
# L1 3=Cursor → 1=Set API key → paste non-crsr_ key → "n" decline warning →
# q back → q quit.
stdin = "\n".join(["3", "1", "sk-not-a-cursor-key", "n", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert "cursor" not in cfg
assert secrets.load_secret("cursor") is None
# ── Cursor SDK-extra install offer (the optional ``cursor`` extra) ───────────
# ``cursor-sdk`` is now an OPTIONAL extra, so a key can be set with no SDK and
# setup must offer to install it (like antigravity post-#322). These tests force
# detection absent (the SDK is actually present in the test venv).
@pytest.fixture()
def _cursor_sdk_absent(monkeypatch):
"""Force ``cursor-sdk`` detection to report missing.
Both the overview row and the drill-in resolve ``cursor_sdk_installed`` from
the source module at call time, so patching the module attribute covers both.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.setattr(
"omnigent.onboarding.cursor_auth.cursor_sdk_installed",
lambda: False,
)
def test_cursor_overview_install_command_is_selection_only(
isolated_config, _cursor_sdk_absent, monkeypatch
) -> None:
"""With the cursor extra absent, the Cursor row's install command is its description.
The install command (dynamically computed) is the selection-only hint —
the selector's per-row description, shown when the row is highlighted —
and is NOT baked into the always-visible row label.
"""
from rich.text import Text
options, selectable, descriptions, _, _max_visible = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
cursor = names.index("Cursor")
assert "omnigent[cursor]" in Text.from_markup(descriptions[cursor]).plain
# The command lives in the description only — never the always-visible row.
assert "omnigent[cursor]" not in Text.from_markup(options[cursor]).plain
def test_cursor_drillin_offers_install_when_sdk_missing(
isolated_config, _cursor_sdk_absent
) -> None:
"""Drilling into Cursor with the SDK absent presents the install offer.
Here the user picks "show the command" (choice 3), which prints it and falls
through to the key menu, then backs out.
"""
# L1 3=Cursor → install offer 3=show command → key menu q=back → L1 q.
stdin = "\n".join(["3", "3", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
out = result.output
assert "isn't installed" in out
assert "omnigent[cursor]" in out
def test_cursor_key_settable_when_sdk_missing(isolated_config, _cursor_sdk_absent) -> None:
"""The Cursor key is still storable when the SDK is absent (no hard block).
The deliberate divergence from pi: the drill-in offers the install but does
NOT gate key management on it. Here the user declines ("set the key anyway" =
choice 2), then sets the key — which must persist as it does with the SDK.
"""
# L1 3=Cursor → install offer 2=set key anyway → key menu 1=Set →
# paste crsr_ key → key menu q=back → L1 q=quit.
stdin = "\n".join(["3", "2", "1", "crsr_key_no_sdk", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert cfg["cursor"] == {"api_key_ref": "keychain:cursor"}
assert secrets.load_secret("cursor") == "crsr_key_no_sdk"
def test_cursor_install_now_invokes_runner_without_index(
isolated_config, _cursor_sdk_absent, monkeypatch
) -> None:
"""Choosing "install it now" shells the install with ``omnigent[cursor]``.
Mocks the subprocess and asserts the argv targets the extra and carries NO
hardcoded index URL / proxy. Forces the ``uv``-absent path for determinism.
"""
import subprocess
calls: list[list[str]] = []
def _run(argv: list[str], *, check: bool = False, timeout: float | None = None):
calls.append(argv)
return subprocess.CompletedProcess(args=argv, returncode=0)
monkeypatch.setattr("omnigent.onboarding.extra_install._is_uv_tool_install", lambda: False)
monkeypatch.setattr("omnigent.onboarding.extra_install.shutil.which", lambda name: None)
monkeypatch.setattr("omnigent.onboarding.cursor_auth.subprocess.run", _run)
# L1 3=Cursor → install offer 1=install now → key menu q=back → L1 q.
stdin = "\n".join(["3", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
assert len(calls) == 1, f"expected exactly one install invocation, got {calls}"
argv = calls[0]
assert "omnigent[cursor]" in argv
assert "install" in argv
# No index URL / proxy is baked into committed code.
assert not any("index" in part or "://" in part for part in argv)
# ── Antigravity Gemini API-key flow ─────────────────────────────────────────
# Antigravity (Gemini-native, no provider family) is row 7 on the overview (it
# follows Pi) and stores its key in the secret store + the ``antigravity:``
# config block. API-key-only menu (Set/Replace/Remove); ``isolated_config``
# clears ambient GEMINI_API_KEY / ANTIGRAVITY_API_KEY.
@pytest.fixture()
def _antigravity_sdk_present(monkeypatch):
"""Force ``google-antigravity`` detection to report installed.
The key-management tests below script the Antigravity drill-in assuming no
install-offer. The optional ``antigravity`` extra is absent in CI, so without
this the drill-in's install-offer fires — consuming a scripted menu token
(desyncing the input) and even running a real ``uv pip install``. Patching the
source-module attribute is seen at every call site (it's resolved at call
time). Mirror of :func:`_antigravity_sdk_absent`.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.setattr(
"omnigent.onboarding.antigravity_auth.antigravity_sdk_installed",
lambda: True,
)
def test_antigravity_set_api_key_paste_writes_block_and_secret(
isolated_config, _antigravity_sdk_present
) -> None:
"""Pasting an ``AIza`` key stores the secret + writes the ``antigravity:`` block.
Proves the api-key path: the secret lands in the store (never plaintext in
config) and the config references it via ``keychain:antigravity``.
"""
# L1 7=Antigravity → antigravity menu 1=Set API key →
# paste key (AIza → no warn) → antigravity menu q=back → L1 q=quit.
stdin = "\n".join(["7", "1", "AIza_test_key_123", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert cfg["antigravity"] == {"api_key_ref": "keychain:antigravity"}
# The pasted secret reached the store under the ``antigravity`` name; config
# holds only the reference.
assert secrets.load_secret("antigravity") == "AIza_test_key_123"
def test_antigravity_adopt_env_api_key_writes_env_ref(
isolated_config, monkeypatch, _antigravity_sdk_present
) -> None:
"""Adopting an existing ``$GEMINI_API_KEY`` records an ``env:`` ref only.
The env path must NOT copy the secret into the store — it points the config
at the live environment variable so the key never leaves the user's shell.
"""
monkeypatch.setenv("GEMINI_API_KEY", "AIza_env_key_456")
# L1 7=Antigravity → 1=Set API key →
# "y" adopt detected $GEMINI_API_KEY → q back → q quit.
stdin = "\n".join(["7", "1", "y", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert cfg["antigravity"] == {"api_key_ref": "env:GEMINI_API_KEY"}
assert secrets.load_secret("antigravity") is None
def test_antigravity_remove_api_key_drops_block_and_secret(
isolated_config, _antigravity_sdk_present
) -> None:
"""Removing a Gemini key deletes the stored secret AND drops the config block."""
# Seed a stored key: the keychain secret + the ``antigravity:`` block.
secrets.store_secret("antigravity", "AIza_seeded")
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump({"antigravity": {"api_key_ref": "keychain:antigravity"}}, f)
# L1 7=Antigravity → antigravity menu (key set:
# 1=Replace 2=Remove 3=Back) → 2=Remove → q back → q quit.
stdin = "\n".join(["7", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert "antigravity" not in cfg
assert secrets.load_secret("antigravity") is None
def test_antigravity_remove_does_not_delete_foreign_keychain_secret(
isolated_config, _antigravity_sdk_present
) -> None:
"""Removing antigravity drops the block but spares a shared ``keychain:<other>``.
A hand-edited ``antigravity:`` block may point at a secret we don't own
(here ``keychain:shared-gemini``). Remove must NOT clobber that secret —
only the config block is dropped. Against the old over-broad delete (any
``keychain:`` ref) the shared secret would have been destroyed.
"""
# Seed a foreign shared secret referenced by a hand-edited block.
secrets.store_secret("shared-gemini", "AIza_shared_seeded")
config_path = os.path.join(isolated_config, "config.yaml")
with open(config_path, "w") as f:
yaml.safe_dump({"antigravity": {"api_key_ref": "keychain:shared-gemini"}}, f)
# L1 7=Antigravity → antigravity menu 2=Remove →
# q back → q quit.
stdin = "\n".join(["7", "2", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
# Block dropped, but the secret we don't own is left intact.
assert "antigravity" not in cfg
assert secrets.load_secret("shared-gemini") == "AIza_shared_seeded"
def test_antigravity_set_api_key_non_aiza_declined_is_not_stored(
isolated_config, _antigravity_sdk_present
) -> None:
"""A non-``AIza`` paste that the user declines to force is NOT persisted.
The soft prefix check warns and asks to store anyway; declining must leave
both the secret store and the config untouched.
"""
# L1 7=Antigravity → 1=Set API key →
# paste non-AIza key → "n" decline warning → q back → q quit.
stdin = "\n".join(["7", "1", "sk-not-a-gemini-key", "n", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert "antigravity" not in cfg
assert secrets.load_secret("antigravity") is None
# ── Antigravity SDK-extra install offer (the optional ``antigravity`` extra) ──
# The antigravity SDK ships in an OPTIONAL extra, so a user can paste a key and still
# have no SDK; setup must detect that and offer to install. These tests force detection
# absent (the SDK is actually present in the test venv).
@pytest.fixture()
def _antigravity_sdk_absent(monkeypatch):
"""Force ``google-antigravity`` detection to report missing.
Both call sites (overview row + drill-in) resolve ``antigravity_sdk_installed``
from the source module at call time, so patching the module attribute is seen by
both.
:param monkeypatch: Pytest monkeypatch fixture.
"""
monkeypatch.setattr(
"omnigent.onboarding.antigravity_auth.antigravity_sdk_installed",
lambda: False,
)
def test_antigravity_overview_install_command_is_selection_only(
isolated_config, _antigravity_sdk_absent, monkeypatch
) -> None:
"""With the antigravity extra absent, the Antigravity row's install command is its description.
The install command (dynamically computed) is the selection-only hint —
the selector's per-row description — not baked into the always-visible row.
Without the SDK-detection branch the hint never appears.
"""
from rich.text import Text
options, selectable, descriptions, _, _max_visible = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
antigravity = names.index("Antigravity")
assert "omnigent[antigravity]" in Text.from_markup(descriptions[antigravity]).plain
assert "omnigent[antigravity]" not in Text.from_markup(options[antigravity]).plain
@pytest.fixture()
def _copilot_sdk_absent(monkeypatch):
"""Force the ``github-copilot-sdk`` extra absent and clear ambient Copilot tokens.
Copilot is the third soft-SDK-extra harness (like Cursor / Antigravity): its
readiness is a GitHub token, and a missing SDK is surfaced as an install
hint rather than a hard block. This drives the unconfigured + SDK-absent
state so the overview row reads "Not installed" with the extra's install
command as its selection-only description.
:param monkeypatch: Pytest monkeypatch fixture.
"""
for var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr("omnigent.onboarding.copilot_auth.copilot_sdk_installed", lambda: False)
def test_copilot_overview_install_command_is_selection_only(
isolated_config, _copilot_sdk_absent, monkeypatch
) -> None:
"""With the copilot extra absent, the Copilot row's install command is its description.
Mirrors the Cursor / Antigravity selection-only-hint contract for the third
soft-SDK-extra harness: the ``omnigent[copilot]`` install command is the
per-row description (shown only when highlighted), never baked into the
always-visible row label.
"""
from rich.text import Text
options, selectable, descriptions, _, _max_visible = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
copilot = names.index("Copilot")
assert "omnigent[copilot]" in Text.from_markup(descriptions[copilot]).plain
assert "pip install" not in Text.from_markup(options[copilot]).plain
@pytest.mark.parametrize(
"choice,sdk_probe,unexpected_header",
[
("3", "omnigent.onboarding.cursor_auth.cursor_sdk_installed", "Cursor — no API key yet"),
(
"7",
"omnigent.onboarding.antigravity_auth.antigravity_sdk_installed",
"Antigravity — no Gemini API key yet",
),
(
"10",
"omnigent.onboarding.copilot_auth.copilot_sdk_installed",
"Copilot — no GitHub token yet",
),
],
)
def test_soft_sdk_install_prompt_abort_returns_to_overview(
isolated_config, monkeypatch, choice: str, sdk_probe: str, unexpected_header: str
) -> None:
"""Esc/q on a soft-SDK install prompt backs out instead of entering key setup.
Cursor, Antigravity, and Copilot can store their key/token even when the SDK
extra is absent, but that should happen only when the user explicitly picks
"Set ... anyway". Aborting the install offer should return to the harness
overview, matching the other setup drill-ins' Esc behavior.
"""
monkeypatch.setattr(sdk_probe, lambda: False)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=f"{choice}\nq\nq\n")
assert result.exit_code == 0, result.output
assert unexpected_header not in result.output
def test_antigravity_drillin_offers_install_when_sdk_missing(
isolated_config, _antigravity_sdk_absent
) -> None:
"""Drilling into Antigravity with the SDK absent presents the install offer.
The user picks "show the command" (choice 3), which prints the command and falls
through to the key menu, then backs out.
"""
# L1 7=Antigravity → install offer 3=show command →
# key menu q=back → L1 q.
stdin = "\n".join(["7", "3", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
out = result.output
assert "isn't installed" in out
assert "omnigent[antigravity]" in out
def test_antigravity_key_settable_when_sdk_missing(
isolated_config, _antigravity_sdk_absent
) -> None:
"""The Gemini key is still storable when the SDK is absent (no hard block).
The deliberate divergence from pi: the drill-in offers the install but does NOT
gate key management on it. The user declines ("set the key anyway" = choice 2),
then sets the key, which must persist as it does with the SDK present.
"""
# L1 7=Antigravity → install offer 2=set key anyway →
# key menu 1=Set → paste AIza key → key menu q=back → L1 q=quit.
stdin = "\n".join(["7", "2", "1", "AIza_key_no_sdk", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
assert cfg["antigravity"] == {"api_key_ref": "keychain:antigravity"}
assert secrets.load_secret("antigravity") == "AIza_key_no_sdk"
def test_antigravity_install_now_invokes_runner_without_index(
isolated_config, _antigravity_sdk_absent, monkeypatch
) -> None:
"""Choosing "install it now" shells the install with ``omnigent[antigravity]``.
Mocks the subprocess and asserts the argv targets the extra and carries NO
hardcoded index URL / proxy. Forces the ``uv``-absent path for a deterministic argv.
"""
import subprocess
calls: list[list[str]] = []
def _run(argv: list[str], *, check: bool = False, timeout: float | None = None):
calls.append(argv)
return subprocess.CompletedProcess(args=argv, returncode=0)
monkeypatch.setattr("omnigent.onboarding.extra_install._is_uv_tool_install", lambda: False)
monkeypatch.setattr("omnigent.onboarding.extra_install.shutil.which", lambda name: None)
monkeypatch.setattr("omnigent.onboarding.antigravity_auth.subprocess.run", _run)
# L1 7=Antigravity → install offer 1=install now →
# key menu q=back → L1 q.
stdin = "\n".join(["7", "1", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
assert len(calls) == 1, f"expected exactly one install invocation, got {calls}"
argv = calls[0]
assert "omnigent[antigravity]" in argv
assert "install" in argv
# No index URL / proxy is baked into committed code.
assert not any("index" in part or "://" in part for part in argv)
def _other_key_add_menu_index(family: str) -> int:
"""Return the 1-based numbered-fallback position of "Other provider — API key".
Computed from the live per-family add menu rather than hardcoded, so a
reordering of :func:`add_menu_options` doesn't aim this test's piped stdin
at the wrong row.
:param family: The harness surface whose add menu is inspected.
:returns: The 1-based index of the catch-all ``other``-key option.
"""
from omnigent.onboarding.configure_models import add_menu_options_for_family
from omnigent.onboarding.provider_config import KEY_KIND
opts = add_menu_options_for_family(family)
return next(i for i, o in enumerate(opts) if o.kind == KEY_KIND and o.other) + 1
def test_configure_harnesses_add_other_key_no_remaining_providers_aborts_cleanly(
isolated_config, monkeypatch
) -> None:
"""Picking "Other provider — API key" with no catalog providers left aborts
cleanly instead of crashing.
Regression for #820: when every catch-all key provider is already configured,
``other_key_providers()`` returns ``[]`` and the secondary ``select`` was
handed an empty option list, raising ``ValueError: select() requires at least
one option`` out of ``omnigent setup``. The add branch must detect the empty
list, tell the user, and return — exit code 0, no traceback. Driven under Pi
(the surface from the report), with the harness CLI forced installed so the
drill-in reaches the add menu.
"""
from omnigent.onboarding.provider_config import PI_SURFACE
# Force the harness CLI "installed" so the Pi drill-in shows the add menu
# rather than the install prompt, and pretend the catch-all catalog is
# exhausted (the real-world trigger: all of Groq/DeepSeek/… already added).
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: True
)
monkeypatch.setattr("omnigent.onboarding.configure_models.other_key_providers", list)
other = _other_key_add_menu_index(PI_SURFACE)
# L1 6=Pi → L2 1=+Add → add menu <other>=Other provider — API key → L2 q=back → L1 q=exit.
stdin = "\n".join(["6", "1", str(other), "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
# Pre-fix this exited non-zero with a ValueError; the guard makes it graceful.
assert result.exit_code == 0, result.output
assert result.exception is None, result.exception
assert "No other API-key providers" in result.output
def test_build_bedrock_provider_entry_shape() -> None:
"""`build_bedrock_provider_entry` produces a kind: bedrock / anthropic body."""
entry = build_bedrock_provider_entry(
base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
api_key_ref="env:AWS_BEARER_TOKEN_BEDROCK",
default_model="us.anthropic.claude-opus-4-5-20251101-v1:0",
)
assert entry == {
"kind": "bedrock",
"anthropic": {
"base_url": "https://bedrock-runtime.us-east-1.amazonaws.com",
"api_key_ref": "env:AWS_BEARER_TOKEN_BEDROCK",
"models": {"default": "us.anthropic.claude-opus-4-5-20251101-v1:0"},
},
}
def test_configure_models_add_bedrock_writes_entry_and_secret(
isolated_config, monkeypatch
) -> None:
"""Adding 'AWS Bedrock — API key' from the Claude menu writes a kind: bedrock entry.
Drives the new interactive path: Claude harness → +Add → 'AWS Bedrock —
API key' (last in the Claude-scoped menu) → name, base_url, pasted bearer
token, Bedrock model id. Asserts the persisted ``kind: bedrock`` body, the
keychain secret, and that it auto-becomes the anthropic default. A
regression means the setup menu can't create a bedrock provider — the gap
this closes.
"""
# No exported token → the paste→keychain path (deterministic prompts).
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
# L1 1=Claude → L2 1=+Add → Claude menu 5='AWS Bedrock — API key'
# (1=Anthropic key, 2=Claude sub, 3=Gateway, 4=Databricks, 5=Bedrock) →
# name; base_url; pasted key; default model → L2 q=back → L1 q=exit.
stdin = (
"\n".join(
[
"1",
"1",
"5",
"mybr",
"https://bedrock-runtime.us-east-1.amazonaws.com",
"absk-test",
"us.anthropic.claude-opus-4-5-20251101-v1:0",
"q",
"q",
]
)
+ "\n"
)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
cfg = _config_yaml(isolated_config)
entry = cfg["providers"]["mybr"]
assert entry["kind"] == "bedrock"
assert entry["anthropic"]["base_url"] == "https://bedrock-runtime.us-east-1.amazonaws.com"
assert entry["anthropic"]["api_key_ref"] == "keychain:mybr"
assert entry["anthropic"]["models"]["default"] == "us.anthropic.claude-opus-4-5-20251101-v1:0"
assert secrets.load_secret("mybr") == "absk-test"
# A bedrock provider serves the anthropic surface, so it auto-claims the
# (previously empty) Claude default.
assert get_default_provider(cfg, "anthropic").name == "mybr"
def test_credential_label_bedrock_not_duplicated() -> None:
"""A bedrock credential reads 'AWS Bedrock', never 'Bedrock Bedrock'.
The entry name is user-chosen (the default is 'bedrock'); naming the
credential after the provider id used to render 'Bedrock Bedrock'. The
generic default collapses to 'AWS Bedrock'; a custom name is qualified.
"""
from omnigent.onboarding.configure_models import credential_label
from omnigent.onboarding.provider_config import BEDROCK_KIND
assert credential_label(BEDROCK_KIND, "bedrock") == "AWS Bedrock"
assert credential_label(BEDROCK_KIND, "nexus") == "AWS Bedrock (nexus)"