diff --git a/cli/planoai/defaults.py b/cli/planoai/defaults.py new file mode 100644 index 00000000..419fa239 --- /dev/null +++ b/cli/planoai/defaults.py @@ -0,0 +1,167 @@ +"""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 OpenAI-compatible and `do` is not in the built-in + # SUPPORTED_PROVIDERS list, so we must provide provider_interface explicitly. + ProviderDefault( + name="do", + env_var="DO_API_KEY", + base_url="https://inference.do-ai.run/v1", + model_pattern="do/*", + provider_interface="openai", + ), +] + + +@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})) + + # 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. + + 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..f8340b35 100644 --- a/cli/planoai/main.py +++ b/cli/planoai/main.py @@ -328,12 +328,30 @@ 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): - console.print( - f"[red]✗[/red] Config file not found: [dim]{plano_config_file}[/dim]" + import yaml + + from planoai.defaults import ( + detect_providers, + synthesize_default_config, + ) + + detection = detect_providers() + cfg_dict = synthesize_default_config() + + 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"[dim]No plano config found; using defaults ({detection.summary}). " + f"Listening on :12000, 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..fce77490 --- /dev/null +++ b/cli/test/test_defaults.py @@ -0,0 +1,71 @@ +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 + # All known providers should be listed. + names = {p["name"] for p in cfg["model_providers"]} + assert "do" 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["do"].get("access_key") == "$DO_API_KEY" + # Unset env keys remain pass-through. + assert by_name["anthropic"].get("passthrough_auth") is True + + +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 "do" 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_do_is_configured(): + by_name = {p.name: p for p in PROVIDER_DEFAULTS} + assert "do" in by_name + assert by_name["do"].env_var == "DO_API_KEY" + assert by_name["do"].base_url == "https://inference.do-ai.run/v1" + assert by_name["do"].model_pattern == "do/*" diff --git a/cli/uv.lock b/cli/uv.lock index 665ebdb8..e8c85648 100644 --- a/cli/uv.lock +++ b/cli/uv.lock @@ -337,7 +337,7 @@ wheels = [ [[package]] name = "planoai" -version = "0.4.18" +version = "0.4.19" source = { editable = "." } dependencies = [ { name = "click" },