diff --git a/cli/planoai/defaults.py b/cli/planoai/defaults.py new file mode 100644 index 00000000..110d0f3b --- /dev/null +++ b/cli/planoai/defaults.py @@ -0,0 +1,163 @@ +"""Default config synthesizer for zero-config ``planoai up``. + +When the user runs ``planoai up`` in a directory with no ``config.yaml`` / +``plano_config.yaml``, we synthesize a pass-through config that covers the +common LLM providers and auto-wires OTel export to ``localhost:4317`` so +``planoai obs`` works out of the box. + +Auth handling: +- If the provider's env var is set, bind ``access_key: $ENV_VAR``. +- Otherwise set ``passthrough_auth: true`` so the client's own Authorization + header is forwarded. No env var is required to start the proxy. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +DEFAULT_LLM_LISTENER_PORT = 12000 +# plano_config validation requires an http:// scheme on the OTLP endpoint. +DEFAULT_OTLP_ENDPOINT = "http://localhost:4317" + + +@dataclass(frozen=True) +class ProviderDefault: + name: str + env_var: str + base_url: str + model_pattern: str + # 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 + # rejects configs that set this field explicitly. + provider_interface: str | None = None + + +# Keep ordering stable so synthesized configs diff cleanly across runs. +PROVIDER_DEFAULTS: list[ProviderDefault] = [ + ProviderDefault( + name="openai", + env_var="OPENAI_API_KEY", + base_url="https://api.openai.com/v1", + model_pattern="openai/*", + ), + ProviderDefault( + name="anthropic", + env_var="ANTHROPIC_API_KEY", + base_url="https://api.anthropic.com/v1", + model_pattern="anthropic/*", + ), + ProviderDefault( + name="gemini", + env_var="GEMINI_API_KEY", + base_url="https://generativelanguage.googleapis.com/v1beta", + model_pattern="gemini/*", + ), + ProviderDefault( + name="groq", + env_var="GROQ_API_KEY", + base_url="https://api.groq.com/openai/v1", + model_pattern="groq/*", + ), + ProviderDefault( + name="deepseek", + env_var="DEEPSEEK_API_KEY", + base_url="https://api.deepseek.com/v1", + model_pattern="deepseek/*", + ), + ProviderDefault( + name="mistral", + env_var="MISTRAL_API_KEY", + base_url="https://api.mistral.ai/v1", + model_pattern="mistral/*", + ), + # DigitalOcean Gradient is a first-class provider post-#889 — the + # `digitalocean/` model prefix routes to the built-in Envoy cluster, no + # base_url needed at runtime. + ProviderDefault( + name="digitalocean", + env_var="DO_API_KEY", + base_url="https://inference.do-ai.run/v1", + model_pattern="digitalocean/*", + ), +] + + +@dataclass +class DetectionResult: + with_keys: list[ProviderDefault] + passthrough: list[ProviderDefault] + + @property + def summary(self) -> str: + parts = [] + if self.with_keys: + parts.append("env-keyed: " + ", ".join(p.name for p in self.with_keys)) + if self.passthrough: + parts.append("pass-through: " + ", ".join(p.name for p in self.passthrough)) + return " | ".join(parts) if parts else "no providers" + + +def detect_providers(env: dict[str, str] | None = None) -> DetectionResult: + env = env if env is not None else dict(os.environ) + with_keys: list[ProviderDefault] = [] + passthrough: list[ProviderDefault] = [] + for p in PROVIDER_DEFAULTS: + val = env.get(p.env_var) + if val: + with_keys.append(p) + else: + passthrough.append(p) + return DetectionResult(with_keys=with_keys, passthrough=passthrough) + + +def synthesize_default_config( + env: dict[str, str] | None = None, + *, + listener_port: int = DEFAULT_LLM_LISTENER_PORT, + otel_endpoint: str = DEFAULT_OTLP_ENDPOINT, +) -> dict: + """Build a pass-through config dict suitable for validation + envoy rendering. + + The returned dict can be dumped to YAML and handed to the existing `planoai up` + pipeline unchanged. + """ + detection = detect_providers(env) + + def _entry(p: ProviderDefault, base: dict) -> dict: + row: dict = {"name": p.name, "model": p.model_pattern, "base_url": p.base_url} + if p.provider_interface is not None: + row["provider_interface"] = p.provider_interface + row.update(base) + return row + + model_providers: list[dict] = [] + for p in detection.with_keys: + model_providers.append(_entry(p, {"access_key": f"${p.env_var}"})) + for p in detection.passthrough: + model_providers.append(_entry(p, {"passthrough_auth": True})) + + # No explicit `default: true` entry is synthesized: the plano config + # validator rejects wildcard models as defaults, and brightstaff already + # registers bare model names as lookup keys during wildcard expansion + # (crates/common/src/llm_providers.rs), so `{"model": "gpt-4o-mini"}` + # without a prefix resolves via the openai wildcard without needing + # `default: true`. See discussion on #890. + + return { + "version": "v0.4.0", + "listeners": [ + { + "name": "llm", + "type": "model", + "port": listener_port, + "address": "0.0.0.0", + } + ], + "model_providers": model_providers, + "tracing": { + "random_sampling": 100, + "opentracing_grpc_endpoint": otel_endpoint, + }, + } diff --git a/cli/planoai/main.py b/cli/planoai/main.py index c8659a3c..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 @@ -328,12 +350,23 @@ def up(file, path, foreground, with_tracing, tracing_port, docker, verbose): # Use the utility function to find config file plano_config_file = find_config_file(path, file) - # Check if the file exists + # Zero-config fallback: when no user config is present, synthesize a + # 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): + detection = detect_providers() + cfg_dict = synthesize_default_config(listener_port=listener_port) + + default_dir = os.path.expanduser("~/.plano") + os.makedirs(default_dir, exist_ok=True) + synthesized_path = os.path.join(default_dir, "default_config.yaml") + with open(synthesized_path, "w") as fh: + yaml.safe_dump(cfg_dict, fh, sort_keys=False) + plano_config_file = synthesized_path console.print( - f"[red]✗[/red] Config file not found: [dim]{plano_config_file}[/dim]" + f"[dim]No plano config found; using defaults ({detection.summary}). " + f"Listening on :{listener_port}, tracing -> http://localhost:4317.[/dim]" ) - sys.exit(1) if not docker: from planoai.native_runner import native_validate_config diff --git a/cli/test/test_defaults.py b/cli/test/test_defaults.py new file mode 100644 index 00000000..bb16a573 --- /dev/null +++ b/cli/test/test_defaults.py @@ -0,0 +1,86 @@ +from pathlib import Path + +import jsonschema +import yaml + +from planoai.defaults import ( + PROVIDER_DEFAULTS, + detect_providers, + synthesize_default_config, +) + +_SCHEMA_PATH = Path(__file__).parents[2] / "config" / "plano_config_schema.yaml" + + +def _schema() -> dict: + return yaml.safe_load(_SCHEMA_PATH.read_text()) + + +def test_zero_env_vars_produces_pure_passthrough(): + cfg = synthesize_default_config(env={}) + assert cfg["version"] == "v0.4.0" + assert cfg["listeners"][0]["port"] == 12000 + 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 + assert "openai" in names + assert "anthropic" in names + + +def test_env_keys_promote_providers_to_env_keyed(): + cfg = synthesize_default_config( + env={"OPENAI_API_KEY": "sk-1", "DO_API_KEY": "do-1"} + ) + by_name = {p["name"]: p for p in cfg["model_providers"]} + assert by_name["openai"].get("access_key") == "$OPENAI_API_KEY" + assert by_name["openai"].get("passthrough_auth") is None + assert by_name["digitalocean"].get("access_key") == "$DO_API_KEY" + # Unset env keys remain pass-through. + assert by_name["anthropic"].get("passthrough_auth") is True + + +def test_no_default_is_synthesized(): + # Bare model names resolve via brightstaff's wildcard expansion registering + # bare keys, so the synthesizer intentionally never sets `default: true`. + cfg = synthesize_default_config( + env={"OPENAI_API_KEY": "sk-1", "ANTHROPIC_API_KEY": "a-1"} + ) + assert not any(p.get("default") is True for p in cfg["model_providers"]) + + +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 + assert "env-keyed" in summary and "openai" in summary and "digitalocean" in summary + assert "pass-through" in summary + + +def test_tracing_block_points_at_local_console(): + cfg = synthesize_default_config(env={}) + tracing = cfg["tracing"] + assert tracing["opentracing_grpc_endpoint"] == "http://localhost:4317" + # random_sampling is a percentage in the plano config — 100 = every span. + assert tracing["random_sampling"] == 100 + + +def test_synthesized_config_validates_against_schema(): + cfg = synthesize_default_config(env={"OPENAI_API_KEY": "sk"}) + jsonschema.validate(cfg, _schema()) + + +def test_provider_defaults_digitalocean_is_configured(): + by_name = {p.name: p for p in PROVIDER_DEFAULTS} + assert "digitalocean" in by_name + assert by_name["digitalocean"].env_var == "DO_API_KEY" + assert by_name["digitalocean"].base_url == "https://inference.do-ai.run/v1" + assert by_name["digitalocean"].model_pattern == "digitalocean/*" diff --git a/config/plano_config_schema.yaml b/config/plano_config_schema.yaml index d3d6a643..3439ebee 100644 --- a/config/plano_config_schema.yaml +++ b/config/plano_config_schema.yaml @@ -190,6 +190,7 @@ properties: - openai - xiaomi - gemini + - digitalocean routing_preferences: type: array items: @@ -238,6 +239,7 @@ properties: - openai - xiaomi - gemini + - digitalocean routing_preferences: type: array items: diff --git a/crates/common/src/configuration.rs b/crates/common/src/configuration.rs index 125a986d..028c8046 100644 --- a/crates/common/src/configuration.rs +++ b/crates/common/src/configuration.rs @@ -391,6 +391,8 @@ pub enum LlmProviderType { AmazonBedrock, #[serde(rename = "plano")] Plano, + #[serde(rename = "digitalocean")] + DigitalOcean, } impl Display for LlmProviderType { @@ -412,6 +414,7 @@ impl Display for LlmProviderType { LlmProviderType::Qwen => write!(f, "qwen"), LlmProviderType::AmazonBedrock => write!(f, "amazon_bedrock"), LlmProviderType::Plano => write!(f, "plano"), + LlmProviderType::DigitalOcean => write!(f, "digitalocean"), } } }