diff --git a/cli/planoai/defaults.py b/cli/planoai/defaults.py index 7d5915e3..06eb6cda 100644 --- a/cli/planoai/defaults.py +++ b/cli/planoai/defaults.py @@ -27,6 +27,10 @@ class ProviderDefault: env_var: str base_url: str model_pattern: str + # Concrete, small, cheap model to promote to `default: true` in the + # synthesized config when this provider has an env key. Lets clients send + # bare (unprefixed) model names that Plano otherwise rejects. + default_model: str | None = None # Only set for providers whose prefix in the model pattern is NOT one of the # built-in SUPPORTED_PROVIDERS in cli/planoai/config_generator.py. For # built-ins, the validator infers the interface from the model prefix and @@ -41,18 +45,21 @@ PROVIDER_DEFAULTS: list[ProviderDefault] = [ env_var="OPENAI_API_KEY", base_url="https://api.openai.com/v1", model_pattern="openai/*", + default_model="gpt-4o-mini", ), ProviderDefault( name="anthropic", env_var="ANTHROPIC_API_KEY", base_url="https://api.anthropic.com/v1", model_pattern="anthropic/*", + default_model="claude-haiku-4-5", ), ProviderDefault( name="gemini", env_var="GEMINI_API_KEY", base_url="https://generativelanguage.googleapis.com/v1beta", model_pattern="gemini/*", + default_model="gemini-2.5-flash", ), ProviderDefault( name="groq", @@ -79,6 +86,7 @@ PROVIDER_DEFAULTS: list[ProviderDefault] = [ env_var="DO_API_KEY", base_url="https://inference.do-ai.run/v1", model_pattern="digitalocean/*", + default_model="openai-gpt-5.4-mini", ), ] @@ -137,11 +145,27 @@ def synthesize_default_config( for p in detection.passthrough: model_providers.append(_entry(p, {"passthrough_auth": True})) - # We intentionally don't mark any provider as `default: true` here: - # the validator rejects wildcard models (e.g. `openai/*`) as defaults, and - # all our entries are wildcards. Clients should prefix model names (e.g. - # `openai/gpt-4o-mini`, `do/router:software-engineering`); the wildcard - # matcher routes them to the correct provider. + # Promote the first env-keyed provider that ships a `default_model` to a + # concrete `default: true` entry. This lets clients send bare (unprefixed) + # model names and still route somewhere sensible. Wildcards can't be marked + # default (the validator rejects it), so we add a second concrete row. + # + # When no env keys are present, we leave `default` unset: the user is fully + # in pass-through mode and must prefix model names so Plano knows which + # upstream to route to. + default_picked = next( + (p for p in detection.with_keys if p.default_model is not None), None + ) + if default_picked is not None: + model_providers.append( + { + "name": f"{default_picked.name}/{default_picked.default_model}", + "model": f"{default_picked.name}/{default_picked.default_model}", + "base_url": default_picked.base_url, + "access_key": f"${default_picked.env_var}", + "default": True, + } + ) return { "version": "v0.4.0", diff --git a/cli/planoai/main.py b/cli/planoai/main.py index f8340b35..3e094a69 100644 --- a/cli/planoai/main.py +++ b/cli/planoai/main.py @@ -6,7 +6,13 @@ import sys import contextlib import logging import rich_click as click +import yaml from planoai import targets +from planoai.defaults import ( + DEFAULT_LLM_LISTENER_PORT, + detect_providers, + synthesize_default_config, +) # Brand color - Plano purple PLANO_COLOR = "#969FF4" @@ -317,7 +323,23 @@ def build(docker): help="Show detailed startup logs with timestamps.", is_flag=True, ) -def up(file, path, foreground, with_tracing, tracing_port, docker, verbose): +@click.option( + "--listener-port", + default=DEFAULT_LLM_LISTENER_PORT, + type=int, + show_default=True, + help="Override the LLM listener port when running without a config file. Ignored when a config file is present.", +) +def up( + file, + path, + foreground, + with_tracing, + tracing_port, + docker, + verbose, + listener_port, +): """Starts Plano.""" from rich.status import Status @@ -332,15 +354,8 @@ def up(file, path, foreground, with_tracing, tracing_port, docker, verbose): # pass-through config that covers the common LLM providers and # auto-wires OTel export to ``planoai obs``. See cli/planoai/defaults.py. if not os.path.exists(plano_config_file): - import yaml - - from planoai.defaults import ( - detect_providers, - synthesize_default_config, - ) - detection = detect_providers() - cfg_dict = synthesize_default_config() + cfg_dict = synthesize_default_config(listener_port=listener_port) default_dir = os.path.expanduser("~/.plano") os.makedirs(default_dir, exist_ok=True) @@ -350,7 +365,7 @@ def up(file, path, foreground, with_tracing, tracing_port, docker, verbose): plano_config_file = synthesized_path console.print( f"[dim]No plano config found; using defaults ({detection.summary}). " - f"Listening on :12000, tracing -> http://localhost:4317.[/dim]" + f"Listening on :{listener_port}, tracing -> http://localhost:4317.[/dim]" ) if not docker: diff --git a/cli/test/test_defaults.py b/cli/test/test_defaults.py index 3d40e65d..f1acd7a5 100644 --- a/cli/test/test_defaults.py +++ b/cli/test/test_defaults.py @@ -23,6 +23,8 @@ def test_zero_env_vars_produces_pure_passthrough(): for provider in cfg["model_providers"]: assert provider.get("passthrough_auth") is True assert "access_key" not in provider + # No provider should be marked default in pure pass-through mode. + assert provider.get("default") is not True # All known providers should be listed. names = {p["name"] for p in cfg["model_providers"]} assert "digitalocean" in names @@ -42,6 +44,31 @@ def test_env_keys_promote_providers_to_env_keyed(): assert by_name["anthropic"].get("passthrough_auth") is True +def test_first_env_keyed_provider_becomes_default(): + cfg = synthesize_default_config( + env={"OPENAI_API_KEY": "sk-1", "ANTHROPIC_API_KEY": "a-1"} + ) + defaults = [p for p in cfg["model_providers"] if p.get("default") is True] + assert len(defaults) == 1 + # openai appears first in PROVIDER_DEFAULTS so it wins. + assert defaults[0]["model"] == "openai/gpt-4o-mini" + assert defaults[0]["access_key"] == "$OPENAI_API_KEY" + + +def test_default_skips_providers_without_default_model(): + # Groq has no default_model wired up — the next env-keyed provider with one + # should be picked instead. + cfg = synthesize_default_config(env={"GROQ_API_KEY": "g", "ANTHROPIC_API_KEY": "a"}) + defaults = [p for p in cfg["model_providers"] if p.get("default") is True] + assert len(defaults) == 1 + assert defaults[0]["model"].startswith("anthropic/") + + +def test_listener_port_is_configurable(): + cfg = synthesize_default_config(env={}, listener_port=11000) + assert cfg["listeners"][0]["port"] == 11000 + + def test_detection_summary_strings(): det = detect_providers(env={"OPENAI_API_KEY": "sk", "DO_API_KEY": "d"}) summary = det.summary