mirror of
https://github.com/katanemo/plano.git
synced 2026-06-08 14:55:14 +02:00
Zero-config planoai up: pass-through proxy with auto-detected providers (#890)
This commit is contained in:
parent
711e4dd07d
commit
1f701258cb
5 changed files with 291 additions and 4 deletions
163
cli/planoai/defaults.py
Normal file
163
cli/planoai/defaults.py
Normal file
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
86
cli/test/test_defaults.py
Normal file
86
cli/test/test_defaults.py
Normal file
|
|
@ -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/*"
|
||||
Loading…
Add table
Add a link
Reference in a new issue