diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0e8722ce..81b72c7d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -107,6 +107,56 @@ jobs:
if: always()
run: planoai down || true
+ # ── Zero-config path: `planoai up` with no args, no plano.yaml in cwd.
+ # Exercises the synthesize_default_config branch in cli/planoai/main.py
+ # which is otherwise never hit by the smoke test above.
+ #
+ # Pre-seed ~/.plano/ from the freshly-built artifacts so the CLI's
+ # cached-download path hits in step (2) of ensure_wasm_plugins /
+ # ensure_brightstaff_binary. Without this, running from outside the
+ # repo means find_repo_root() returns None, the local-build short-
+ # circuit is skipped, and the CLI tries to download from a GitHub
+ # release that does not yet exist for the in-flight version on
+ # release-bump PRs (e.g. 0.4.27 before publish-binaries has run).
+ - name: Seed ~/.plano cache for zero-config test
+ run: |
+ VERSION=$(sed -nE 's/^__version__ = "(.*)"$/\1/p' cli/planoai/__init__.py)
+ mkdir -p ~/.plano/plugins ~/.plano/bin
+ cp crates/target/wasm32-wasip1/release/prompt_gateway.wasm ~/.plano/plugins/
+ cp crates/target/wasm32-wasip1/release/llm_gateway.wasm ~/.plano/plugins/
+ cp crates/target/release/brightstaff ~/.plano/bin/
+ chmod +x ~/.plano/bin/brightstaff
+ echo "$VERSION" > ~/.plano/plugins/wasm.version
+ echo "$VERSION" > ~/.plano/bin/brightstaff.version
+
+ - name: Zero-config smoke test
+ env:
+ OPENAI_API_KEY: test-key-not-used
+ run: |
+ empty_dir="$(mktemp -d)"
+ cd "$empty_dir"
+ test ! -f plano.yaml
+ planoai up
+ test -f "$HOME/.plano/default_config.yaml"
+
+ - name: Zero-config health check
+ run: |
+ for i in $(seq 1 30); do
+ if curl -sf http://localhost:12000/healthz > /dev/null 2>&1; then
+ echo "Zero-config health check passed"
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "Zero-config health check failed after 30s"
+ cat ~/.plano/run/logs/envoy.log || true
+ cat ~/.plano/run/logs/brightstaff.log || true
+ exit 1
+
+ - name: Stop plano (zero-config)
+ if: always()
+ run: planoai down || true
+
# ──────────────────────────────────────────────
# Single Docker build — shared by all downstream jobs
# ──────────────────────────────────────────────
@@ -133,13 +183,13 @@ jobs:
load: true
tags: |
${{ env.PLANO_DOCKER_IMAGE }}
- ${{ env.DOCKER_IMAGE }}:0.4.22
+ ${{ env.DOCKER_IMAGE }}:0.4.27
${{ env.DOCKER_IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Save image as artifact
- run: docker save ${{ env.PLANO_DOCKER_IMAGE }} ${{ env.DOCKER_IMAGE }}:0.4.22 ${{ env.DOCKER_IMAGE }}:latest -o /tmp/plano-image.tar
+ run: docker save ${{ env.PLANO_DOCKER_IMAGE }} ${{ env.DOCKER_IMAGE }}:0.4.27 ${{ env.DOCKER_IMAGE }}:latest -o /tmp/plano-image.tar
- name: Upload image artifact
uses: actions/upload-artifact@v6
diff --git a/.github/workflows/update-providers.yml b/.github/workflows/update-providers.yml
new file mode 100644
index 00000000..affc089b
--- /dev/null
+++ b/.github/workflows/update-providers.yml
@@ -0,0 +1,125 @@
+name: Update provider_models.yaml
+
+on:
+ repository_dispatch:
+ types: [update-providers]
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ update-providers:
+ runs-on: ubuntu-latest
+ env:
+ RESPONSE_URL: ${{ github.event.client_payload.response_url }}
+ SLACK_USER_ID: ${{ github.event.client_payload.user_id }}
+ SLACK_USER_NAME: ${{ github.event.client_payload.user_name }}
+ steps:
+ - name: Checkout main
+ uses: actions/checkout@v6
+ with:
+ ref: main
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: ${{ secrets.AWS_REGION }}
+
+ - name: Cache cargo build
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ crates/target
+ key: cargo-fetch-models-${{ hashFiles('crates/**/Cargo.lock', 'crates/**/Cargo.toml') }}
+ restore-keys: cargo-fetch-models-
+
+ - name: Run fetch_models
+ working-directory: crates/hermesllm
+ env:
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
+ DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
+ GROK_API_KEY: ${{ secrets.GROK_API_KEY }}
+ DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
+ MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
+ ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
+ MIMO_API_KEY: ${{ secrets.MIMO_API_KEY }}
+ GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
+ AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }}
+ META_MODELS_API_KEY: ${{ secrets.META_MODELS_API_KEY }}
+ run: cargo run --bin fetch_models --features model-fetch
+
+ - name: Create pull request
+ id: cpr
+ uses: peter-evans/create-pull-request@v7
+ with:
+ branch: bot/update-providers-${{ github.run_id }}
+ base: main
+ commit-message: "chore: refresh provider_models.yaml"
+ title: "chore: refresh provider_models.yaml"
+ body: |
+ Automated refresh of `crates/hermesllm/src/bin/provider_models.yaml`
+ via `fetch_models`.
+
+ Requested by ${{ env.SLACK_USER_NAME && format('@{0}', env.SLACK_USER_NAME) || 'workflow_dispatch' }}${{ env.SLACK_USER_ID && format(' (Slack `{0}`)', env.SLACK_USER_ID) || '' }}.
+
+ Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ labels: automated, provider-models
+ add-paths: crates/hermesllm/src/bin/provider_models.yaml
+
+ - name: Notify Slack (success)
+ if: success() && env.RESPONSE_URL != ''
+ env:
+ PR_URL: ${{ steps.cpr.outputs.pull-request-url }}
+ PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
+ PR_OP: ${{ steps.cpr.outputs.pull-request-operation }}
+ run: |
+ if [ -z "$PR_URL" ]; then
+ TEXT=":information_source: No provider model changes detected \u2014 nothing to PR."
+ BLOCKS=$(jq -nc --arg text "$TEXT" '{response_type:"ephemeral", replace_original:true, text:$text, blocks:[{type:"section", text:{type:"mrkdwn", text:$text}}]}')
+ else
+ TEXT=":white_check_mark: provider_models.yaml PR ready: $PR_URL"
+ BLOCKS=$(jq -nc \
+ --arg pr "$PR_URL" \
+ --arg num "$PR_NUMBER" \
+ --arg op "$PR_OP" \
+ '{
+ response_type:"ephemeral",
+ replace_original:true,
+ text:(":white_check_mark: provider_models.yaml PR #" + $num + " " + $op + ": " + $pr),
+ blocks:[
+ {type:"section", text:{type:"mrkdwn", text:(":white_check_mark: *provider_models.yaml* PR <" + $pr + "|#" + $num + "> " + $op + ".")}},
+ {type:"actions", elements:[{type:"button", text:{type:"plain_text", text:"Open PR"}, url:$pr}]}
+ ]
+ }')
+ fi
+ curl -sS -X POST -H 'Content-Type: application/json' -d "$BLOCKS" "$RESPONSE_URL"
+
+ - name: Notify Slack (failure)
+ if: failure() && env.RESPONSE_URL != ''
+ run: |
+ RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ TEXT=":x: provider_models.yaml update failed. Logs: $RUN_URL"
+ jq -nc \
+ --arg text "$TEXT" \
+ --arg run "$RUN_URL" \
+ '{
+ response_type:"ephemeral",
+ replace_original:true,
+ text:$text,
+ blocks:[
+ {type:"section", text:{type:"mrkdwn", text:(":x: *provider_models.yaml update failed.*")}},
+ {type:"actions", elements:[{type:"button", text:{type:"plain_text", text:"View logs"}, url:$run}]}
+ ]
+ }' | curl -sS -X POST -H 'Content-Type: application/json' -d @- "$RESPONSE_URL"
diff --git a/CLAUDE.md b/CLAUDE.md
index 58b2191f..975b9ea0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,7 +49,7 @@ Client → Envoy (prompt_gateway.wasm → llm_gateway.wasm) → Agents/LLM Provi
### Python CLI (cli/planoai/)
-Entry point: `main.py`. Built with `rich-click`. Commands: `up`, `down`, `build`, `logs`, `trace`, `init`, `cli_agent`, `generate_prompt_targets`.
+Entry point: `main.py`. Built with `rich-click`. Commands: `up`, `down`, `build`, `logs`, `trace`, `init`, `cli_agent`.
### Config (config/)
diff --git a/Dockerfile b/Dockerfile
index ad0ca707..8275fb96 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -50,7 +50,7 @@ FROM python:3.14-slim AS arch
RUN set -eux; \
apt-get update; \
apt-get upgrade -y; \
- apt-get install -y --no-install-recommends gettext-base curl procps; \
+ apt-get install -y --no-install-recommends gettext-base procps; \
apt-get clean; rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir supervisor
diff --git a/README.md b/README.md
index b7ff7efc..177bf8e3 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Plano solves this by moving core delivery concerns into a unified, out-of-proces
Plano pulls rote plumbing out of your framework so you can stay focused on what matters most: the core product logic of your agentic applications. Plano is backed by [industry-leading LLM research](https://planoai.dev/research) and built on [Envoy](https://envoyproxy.io) by its core contributors, who built critical infrastructure at scale for modern worklaods.
**High-Level Network Sequence Diagram**:
-
+
**Jump to our [docs](https://docs.planoai.dev)** to learn how you can use Plano to improve the speed, safety and obervability of your agentic applications.
@@ -156,7 +156,7 @@ curl http://localhost:8001/v1/chat/completions \
Every request is traced end-to-end with OpenTelemetry - no instrumentation code needed.
-
+
### What You Didn't Have to Build
@@ -183,7 +183,6 @@ Ready to try Plano? Check out our comprehensive documentation:
- **[LLM Routing](https://docs.planoai.dev/guides/llm_router.html)** - Route by model name, alias, or intelligent preferences
- **[Agent Orchestration](https://docs.planoai.dev/guides/orchestration.html)** - Build multi-agent workflows
- **[Filter Chains](https://docs.planoai.dev/concepts/filter_chain.html)** - Add guardrails, moderation, and memory hooks
-- **[Prompt Targets](https://docs.planoai.dev/concepts/prompt_target.html)** - Turn prompts into deterministic API calls
- **[Observability](https://docs.planoai.dev/guides/observability/observability.html)** - Traces, metrics, and logs
## Contribution
diff --git a/apps/www/src/components/Hero.tsx b/apps/www/src/components/Hero.tsx
index b45c6873..3e05236a 100644
--- a/apps/www/src/components/Hero.tsx
+++ b/apps/www/src/components/Hero.tsx
@@ -24,7 +24,7 @@ export function Hero() {
>
- v0.4.22
+ v0.4.27
—
diff --git a/build_filter_image.sh b/build_filter_image.sh
index 64708056..8840d9e2 100644
--- a/build_filter_image.sh
+++ b/build_filter_image.sh
@@ -1 +1 @@
-docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.22
+docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.27
diff --git a/cli/planoai/__init__.py b/cli/planoai/__init__.py
index ec2c63da..8ce10400 100644
--- a/cli/planoai/__init__.py
+++ b/cli/planoai/__init__.py
@@ -1,3 +1,3 @@
"""Plano CLI - Intelligent Prompt Gateway."""
-__version__ = "0.4.22"
+__version__ = "0.4.27"
diff --git a/cli/planoai/config_generator.py b/cli/planoai/config_generator.py
index cb07767e..f754a183 100644
--- a/cli/planoai/config_generator.py
+++ b/cli/planoai/config_generator.py
@@ -39,6 +39,42 @@ CHATGPT_API_BASE = "https://chatgpt.com/backend-api/codex"
CHATGPT_DEFAULT_ORIGINATOR = "codex_cli_rs"
CHATGPT_DEFAULT_USER_AGENT = "codex_cli_rs/0.0.0 (Unknown 0; unknown) unknown"
+KIMI_CODE_API_HOST = "api.kimi.com"
+KIMI_CODE_DEFAULT_USER_AGENT = "KimiCLI/1.3"
+
+
+def normalize_kimi_code_base_url(base_url: str) -> str:
+ """Ensure Kimi Code API base URLs include the /v1 suffix."""
+ parsed = urlparse(base_url)
+ if parsed.hostname != KIMI_CODE_API_HOST:
+ return base_url
+ path = parsed.path.rstrip("/")
+ if path.endswith("/coding"):
+ return f"{parsed.scheme}://{parsed.netloc}{path}/v1"
+ return base_url
+
+
+def apply_kimi_code_provider_defaults(model_provider: dict) -> None:
+ """Inject Kimi Code API defaults (User-Agent, normalized base URL)."""
+ base_url = model_provider.get("base_url")
+ if not base_url:
+ return
+ parsed = urlparse(base_url)
+ model_id = model_provider.get("model", "")
+ is_kimi_code = (
+ parsed.hostname == KIMI_CODE_API_HOST or model_id == "kimi-for-coding"
+ )
+ if not is_kimi_code:
+ return
+
+ normalized = normalize_kimi_code_base_url(base_url)
+ if normalized != base_url:
+ model_provider["base_url"] = normalized
+
+ headers = model_provider.setdefault("headers", {})
+ headers.setdefault("User-Agent", KIMI_CODE_DEFAULT_USER_AGENT)
+
+
SUPPORTED_PROVIDERS = (
SUPPORTED_PROVIDERS_WITHOUT_BASE_URL + SUPPORTED_PROVIDERS_WITH_BASE_URL
)
@@ -463,6 +499,8 @@ def validate_and_render_schema():
headers.setdefault("session_id", str(uuid.uuid4()))
model_provider["headers"] = headers
+ apply_kimi_code_provider_defaults(model_provider)
+
updated_model_providers.append(model_provider)
if model_provider.get("base_url", None):
@@ -562,15 +600,15 @@ def validate_and_render_schema():
"Please provide model_providers either under listeners or at root level, not both. Currently we don't support multiple listeners with model_providers"
)
- # Validate input_filters IDs on listeners reference valid agent/filter IDs
+ # Validate listener-level filter IDs reference valid agent/filter IDs.
for listener in listeners:
- listener_input_filters = listener.get("input_filters", [])
- for fc_id in listener_input_filters:
- if fc_id not in agent_id_keys:
- raise Exception(
- f"Listener '{listener.get('name', 'unknown')}' references input_filters id '{fc_id}' "
- f"which is not defined in agents or filters. Available ids: {', '.join(sorted(agent_id_keys))}"
- )
+ for filter_field in ("input_filters", "output_filters"):
+ for fc_id in listener.get(filter_field, []):
+ if fc_id not in agent_id_keys:
+ raise Exception(
+ f"Listener '{listener.get('name', 'unknown')}' references {filter_field} id '{fc_id}' "
+ f"which is not defined in agents or filters. Available ids: {', '.join(sorted(agent_id_keys))}"
+ )
# Validate model aliases if present
if "model_aliases" in config_yaml:
diff --git a/cli/planoai/consts.py b/cli/planoai/consts.py
index 5cafb817..33d8d631 100644
--- a/cli/planoai/consts.py
+++ b/cli/planoai/consts.py
@@ -5,7 +5,7 @@ PLANO_COLOR = "#969FF4"
SERVICE_NAME_ARCHGW = "plano"
PLANO_DOCKER_NAME = "plano"
-PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.22")
+PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.27")
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT = "http://localhost:4317"
# Native mode constants
diff --git a/cli/planoai/main.py b/cli/planoai/main.py
index ea43a1a8..491e2912 100644
--- a/cli/planoai/main.py
+++ b/cli/planoai/main.py
@@ -7,7 +7,6 @@ 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,
@@ -622,28 +621,6 @@ def down(docker, verbose):
)
-@click.command()
-@click.option(
- "--f",
- "--file",
- type=click.Path(exists=True),
- required=True,
- help="Path to the Python file",
-)
-def generate_prompt_targets(file):
- """Generats prompt_targets from python methods.
- Note: This works for simple data types like ['int', 'float', 'bool', 'str', 'list', 'tuple', 'set', 'dict']:
- If you have a complex pydantic data type, you will have to flatten those manually until we add support for it.
- """
-
- print(f"Processing file: {file}")
- if not file.endswith(".py"):
- print("Error: Input file must be a .py file")
- sys.exit(1)
-
- targets.generate_prompt_targets(file)
-
-
@click.command()
@click.option(
"--debug",
@@ -741,7 +718,6 @@ main.add_command(down)
main.add_command(build)
main.add_command(logs)
main.add_command(cli_agent)
-main.add_command(generate_prompt_targets)
main.add_command(init_cmd, name="init")
main.add_command(trace_cmd, name="trace")
main.add_command(chatgpt_cmd, name="chatgpt")
diff --git a/cli/planoai/obs/pricing.py b/cli/planoai/obs/pricing.py
index 6f2ce5b4..0a8b7321 100644
--- a/cli/planoai/obs/pricing.py
+++ b/cli/planoai/obs/pricing.py
@@ -1,7 +1,8 @@
-"""DigitalOcean Gradient pricing catalog for the obs console.
+"""Model pricing catalog for the obs console.
-Ported loosely from ``crates/brightstaff/src/router/model_metrics.rs::fetch_do_pricing``.
-Single-source: one fetch at startup, cached for the life of the process.
+Mirrors ``crates/brightstaff/src/router/model_metrics.rs``. The source is
+configurable: ``digitalocean`` (DO GenAI catalog) or ``models.dev``. A single
+fetch at startup is cached for the life of the process.
"""
from __future__ import annotations
@@ -14,7 +15,18 @@ from typing import Any
import requests
-DEFAULT_PRICING_URL = "https://api.digitalocean.com/v2/gen-ai/models/catalog"
+DO_PRICING_URL = "https://api.digitalocean.com/v2/gen-ai/models/catalog"
+MODELS_DEV_URL = "https://models.dev/api.json"
+
+# Backwards-compatible default (DigitalOcean) used when no provider is given.
+DEFAULT_PRICING_URL = DO_PRICING_URL
+DEFAULT_PRICING_PROVIDER = "digitalocean"
+
+_DEFAULT_URLS = {
+ "digitalocean": DO_PRICING_URL,
+ "models.dev": MODELS_DEV_URL,
+}
+
FETCH_TIMEOUT_SECS = 5.0
@@ -51,36 +63,52 @@ class PricingCatalog:
return list(self._prices.keys())[:n]
@classmethod
- def fetch(cls, url: str = DEFAULT_PRICING_URL) -> "PricingCatalog":
- """Fetch pricing from DO's catalog endpoint. On failure, returns an
+ def fetch(
+ cls,
+ provider: str = DEFAULT_PRICING_PROVIDER,
+ url: str | None = None,
+ ) -> "PricingCatalog":
+ """Fetch pricing from the configured catalog. On failure, returns an
empty catalog (cost column will be blank).
- The catalog endpoint is public — no auth required, no signup — so
- ``planoai obs`` gets cost data on first run out of the box.
+ ``provider`` selects the parser/default URL: ``digitalocean`` or
+ ``models.dev``. Both catalog endpoints are public — no auth required —
+ so ``planoai obs`` gets cost data on first run out of the box.
"""
+ provider = (provider or DEFAULT_PRICING_PROVIDER).strip().lower()
+ resolved_url = url or _DEFAULT_URLS.get(provider, DO_PRICING_URL)
try:
- resp = requests.get(url, timeout=FETCH_TIMEOUT_SECS)
+ resp = requests.get(resolved_url, timeout=FETCH_TIMEOUT_SECS)
resp.raise_for_status()
data = resp.json()
except Exception as exc: # noqa: BLE001 — best-effort; never fatal
logger.warning(
- "DO pricing fetch failed: %s; cost column will be blank.",
+ "%s pricing fetch failed: %s; cost column will be blank.",
+ provider,
exc,
)
return cls()
- prices = _parse_do_pricing(data)
+ if provider == "models.dev":
+ prices = _parse_models_dev_pricing(data)
+ else:
+ prices = _parse_do_pricing(data)
+
if not prices:
- # Dump the first entry's raw shape so we can see which fields DO
- # actually returned — helps when the catalog adds new fields or
- # the response doesn't match our parser.
+ # Dump a sample of the raw shape so we can see which fields the
+ # catalog returned — helps when it adds new fields or the response
+ # doesn't match our parser.
import json as _json
- sample_items = _coerce_items(data)
- sample = sample_items[0] if sample_items else data
+ if provider == "models.dev" and isinstance(data, dict):
+ sample = next(iter(data.values()), data)
+ else:
+ sample_items = _coerce_items(data)
+ sample = sample_items[0] if sample_items else data
logger.warning(
- "DO pricing response had no parseable entries; cost column "
+ "%s pricing response had no parseable entries; cost column "
"will be blank. Sample entry: %s",
+ provider,
_json.dumps(sample, default=str)[:400],
)
return cls(prices)
@@ -278,6 +306,75 @@ def _parse_do_pricing(data: Any) -> dict[str, ModelPrice]:
return prices
+def _parse_models_dev_pricing(data: Any) -> dict[str, ModelPrice]:
+ """Parse a models.dev ``api.json`` response into a ModelPrice map.
+
+ models.dev shape (top-level object keyed by provider id)::
+
+ {
+ "anthropic": {
+ "models": {
+ "claude-opus-4-5": {
+ "cost": {"input": 5, "output": 25, "cache_read": 0.5}
+ }
+ }
+ },
+ ...
+ }
+
+ ``cost.*`` values are USD per *million* tokens, so we divide by 1e6 to get a
+ per-token rate. First-party providers use bare model keys, so we register
+ both ``provider/model`` (matching Plano's routing names) and the bare model
+ id as a fallback.
+ """
+ prices: dict[str, ModelPrice] = {}
+ if not isinstance(data, dict):
+ return prices
+
+ for provider_id, provider in data.items():
+ if not isinstance(provider, dict):
+ continue
+ models = provider.get("models")
+ if not isinstance(models, dict):
+ continue
+ for model_key, model in models.items():
+ if not isinstance(model, dict):
+ continue
+ cost = model.get("cost")
+ if not isinstance(cost, dict):
+ continue
+ input_pm = _as_float(cost.get("input"))
+ output_pm = _as_float(cost.get("output"))
+ if input_pm is None or output_pm is None:
+ continue
+ # Skip 0-rate entries so cost falls back to `—` rather than $0.0000.
+ if input_pm == 0 and output_pm == 0:
+ continue
+ cached_pm = _as_float(cost.get("cache_read"))
+ price = ModelPrice(
+ input_per_token_usd=input_pm / 1_000_000,
+ output_per_token_usd=output_pm / 1_000_000,
+ cached_input_per_token_usd=(
+ cached_pm / 1_000_000 if cached_pm is not None else None
+ ),
+ )
+ composite = f"{provider_id}/{model_key}"
+ prices[composite] = price
+ prices.setdefault(composite.lower(), price)
+ prices.setdefault(str(model_key), price)
+ prices.setdefault(str(model_key).lower(), price)
+ return prices
+
+
+def _as_float(value: Any) -> float | None:
+ if value is None:
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
def _coerce_items(data: Any) -> list[dict]:
if isinstance(data, list):
return [x for x in data if isinstance(x, dict)]
diff --git a/cli/planoai/obs_cmd.py b/cli/planoai/obs_cmd.py
index 6249df30..a0867b6e 100644
--- a/cli/planoai/obs_cmd.py
+++ b/cli/planoai/obs_cmd.py
@@ -2,9 +2,12 @@
from __future__ import annotations
+import logging
+import os
import time
import rich_click as click
+import yaml
from rich.console import Console
from rich.live import Live
@@ -15,8 +18,50 @@ from planoai.obs.collector import (
LLMCallStore,
ObsCollector,
)
-from planoai.obs.pricing import PricingCatalog
+from planoai.obs.pricing import DEFAULT_PRICING_PROVIDER, PricingCatalog
from planoai.obs.render import render
+from planoai.utils import find_config_file
+
+logger = logging.getLogger(__name__)
+
+
+def _resolve_pricing_source(
+ config_file: str | None,
+ provider_override: str | None,
+ url_override: str | None,
+) -> tuple[str, str | None]:
+ """Pick the cost pricing source.
+
+ Precedence: explicit CLI overrides > the first ``type: cost`` entry in
+ ``model_metrics_sources`` from the Plano config > the DigitalOcean default.
+ """
+ provider = DEFAULT_PRICING_PROVIDER
+ url: str | None = None
+
+ config_path = find_config_file(file=config_file)
+ if config_path and os.path.exists(config_path):
+ try:
+ with open(config_path, "r") as f:
+ config = yaml.safe_load(f) or {}
+ sources = config.get("model_metrics_sources") or []
+ for source in sources:
+ if isinstance(source, dict) and source.get("type") == "cost":
+ if source.get("provider"):
+ provider = str(source["provider"])
+ if source.get("url"):
+ url = str(source["url"])
+ break
+ except Exception as exc: # noqa: BLE001 — config is optional for obs
+ logger.warning(
+ "could not read pricing source from %s: %s", config_path, exc
+ )
+
+ if provider_override:
+ provider = provider_override
+ if url_override:
+ url = url_override
+
+ return provider, url
@click.command(name="obs", help="Live observability console for Plano LLM traffic.")
@@ -48,13 +93,42 @@ from planoai.obs.render import render
show_default=True,
help="TUI refresh interval.",
)
-def obs(port: int, host: str, capacity: int, refresh_ms: int) -> None:
+@click.option(
+ "--config",
+ "config_file",
+ type=str,
+ default=None,
+ help="Path to the Plano config to read the pricing source from "
+ "(defaults to ./config.yaml or ./plano_config.yaml).",
+)
+@click.option(
+ "--pricing-provider",
+ type=click.Choice(["digitalocean", "models.dev"]),
+ default=None,
+ help="Override the cost pricing provider (otherwise read from config).",
+)
+@click.option(
+ "--pricing-url",
+ type=str,
+ default=None,
+ help="Override the pricing catalog URL (otherwise read from config / provider default).",
+)
+def obs(
+ port: int,
+ host: str,
+ capacity: int,
+ refresh_ms: int,
+ config_file: str | None,
+ pricing_provider: str | None,
+ pricing_url: str | None,
+) -> None:
console = Console()
+ provider, url = _resolve_pricing_source(config_file, pricing_provider, pricing_url)
console.print(
- f"[bold {PLANO_COLOR}]planoai obs[/] — loading DO pricing catalog...",
+ f"[bold {PLANO_COLOR}]planoai obs[/] — loading {provider} pricing catalog...",
end="",
)
- pricing = PricingCatalog.fetch()
+ pricing = PricingCatalog.fetch(provider=provider, url=url)
if len(pricing):
sample = ", ".join(pricing.sample_models(3))
console.print(
@@ -63,7 +137,7 @@ def obs(port: int, host: str, capacity: int, refresh_ms: int) -> None:
else:
console.print(
" [yellow]no pricing loaded[/] — "
- "[dim]cost column will be blank (DO catalog unreachable)[/]"
+ f"[dim]cost column will be blank ({provider} catalog unreachable)[/]"
)
store = LLMCallStore(capacity=capacity)
diff --git a/cli/planoai/rich_click_config.py b/cli/planoai/rich_click_config.py
index fe90dcf1..0ae83844 100644
--- a/cli/planoai/rich_click_config.py
+++ b/cli/planoai/rich_click_config.py
@@ -63,9 +63,5 @@ def configure_rich_click(plano_color: str) -> None:
"name": "Observability",
"commands": ["trace", "obs"],
},
- {
- "name": "Utilities",
- "commands": ["generate-prompt-targets"],
- },
],
}
diff --git a/cli/planoai/targets.py b/cli/planoai/targets.py
deleted file mode 100644
index 7c56f2b7..00000000
--- a/cli/planoai/targets.py
+++ /dev/null
@@ -1,365 +0,0 @@
-import ast
-import sys
-import yaml
-from typing import Any
-
-FLASK_ROUTE_DECORATORS = ["route", "get", "post", "put", "delete", "patch"]
-FASTAPI_ROUTE_DECORATORS = ["get", "post", "put", "delete", "patch"]
-
-
-def detect_framework(tree: Any) -> str:
- """Detect whether the file is using Flask or FastAPI based on imports."""
- for node in ast.walk(tree):
- if isinstance(node, ast.ImportFrom):
- if node.module == "flask":
- return "flask"
- elif node.module == "fastapi":
- return "fastapi"
- return "unknown"
-
-
-def get_route_decorators(node: Any, framework: str) -> list:
- """Extract route decorators based on the framework."""
- decorators = []
- for decorator in node.decorator_list:
- if isinstance(decorator, ast.Call) and isinstance(
- decorator.func, ast.Attribute
- ):
- if framework == "flask" and decorator.func.attr in FLASK_ROUTE_DECORATORS:
- decorators.append(decorator.func.attr)
- elif (
- framework == "fastapi"
- and decorator.func.attr in FASTAPI_ROUTE_DECORATORS
- ):
- decorators.append(decorator.func.attr)
- return decorators
-
-
-def get_route_path(node: Any, framework: str) -> str:
- """Extract route path based on the framework."""
- for decorator in node.decorator_list:
- if isinstance(decorator, ast.Call) and decorator.args:
- return decorator.args[0].s # Assuming it's a string literal
-
-
-def is_pydantic_model(annotation: ast.expr, tree: ast.AST) -> bool:
- """Check if a given type annotation is a Pydantic model."""
- # We walk through the AST to find class definitions and check if they inherit from Pydantic's BaseModel
- if isinstance(annotation, ast.Name):
- for node in ast.walk(tree):
- if isinstance(node, ast.ClassDef) and node.name == annotation.id:
- for base in node.bases:
- if isinstance(base, ast.Name) and base.id == "BaseModel":
- return True
- return False
-
-
-def get_pydantic_model_fields(model_name: str, tree: ast.AST) -> list:
- """Extract fields from a Pydantic model, handling list, tuple, set, dict types, and direct default values."""
- fields = []
-
- for node in ast.walk(tree):
- if isinstance(node, ast.ClassDef) and node.name == model_name:
- for stmt in node.body:
- if isinstance(stmt, ast.AnnAssign):
- # Initialize the default field description
- field_type = "Unknown: Please Fix This!"
- description = "Field, description not present. Please fix."
- default_value = None
- required = True # Assume the field is required initially
-
- # Check if the field uses Field() with required status and description
- if (
- stmt.value
- and isinstance(stmt.value, ast.Call)
- and isinstance(stmt.value.func, ast.Name)
- and stmt.value.func.id == "Field"
- ):
- # Extract the description argument inside the Field call
- for keyword in stmt.value.keywords:
- if keyword.arg == "description" and isinstance(
- keyword.value, ast.Str
- ):
- description = keyword.value.s
- if keyword.arg == "default":
- default_value = keyword.value
- # If Ellipsis (...) is used, it means the field is required
- if (
- stmt.value.args
- and isinstance(stmt.value.args[0], ast.Constant)
- and stmt.value.args[0].value is Ellipsis
- ):
- required = True
- else:
- required = False
-
- # Handle direct default values (e.g., name: str = "John Doe")
- elif stmt.value is not None:
- if isinstance(stmt.value, ast.Constant):
- # Set the default value from the assignment (e.g., name: str = "John Doe")
- default_value = stmt.value.value
- required = (
- False # Not required since it has a default value
- )
-
- # Always extract the field type, even if there's a default value
- if isinstance(stmt.annotation, ast.Subscript):
- # Get the base type (list, tuple, set, dict)
- base_type = (
- stmt.annotation.value.id
- if isinstance(stmt.annotation.value, ast.Name)
- else "Unknown"
- )
-
- # Handle only list, tuple, set, dict and ignore the inner types
- if base_type.lower() in ["list", "tuple", "set", "dict"]:
- field_type = base_type.lower()
-
- # Handle the ellipsis '...' for required fields if no Field() call
- elif (
- isinstance(stmt.value, ast.Constant)
- and stmt.value.value is Ellipsis
- ):
- required = True
-
- # Handle simple types like str, int, etc.
- if isinstance(stmt.annotation, ast.Name):
- field_type = stmt.annotation.id
-
- field_info = {
- "name": stmt.target.id,
- "type": field_type, # Always set the field type
- "description": description,
- "default": default_value, # Handle direct default values
- "required": required,
- }
- fields.append(field_info)
-
- return fields
-
-
-def get_function_parameters(node: ast.FunctionDef, tree: ast.AST) -> list:
- """Extract the parameters and their types from the function definition."""
- parameters = []
-
- # Extract docstring to find descriptions
- docstring = ast.get_docstring(node)
- arg_descriptions = extract_arg_descriptions_from_docstring(docstring)
-
- # Extract default values
- defaults = [None] * (
- len(node.args.args) - len(node.args.defaults)
- ) + node.args.defaults # Align defaults with args
- for arg, default in zip(node.args.args, defaults):
- if arg.arg != "self": # Skip 'self' or 'cls' in class methods
- param_info = {
- "name": arg.arg,
- "description": arg_descriptions.get(arg.arg, "[ADD DESCRIPTION]"),
- }
-
- # Handle Pydantic model types
- if hasattr(arg, "annotation") and is_pydantic_model(arg.annotation, tree):
- # Extract and flatten Pydantic model fields
- pydantic_fields = get_pydantic_model_fields(arg.annotation.id, tree)
- parameters.extend(
- pydantic_fields
- ) # Flatten the model fields into the parameters list
- continue # Skip adding the current param_info for the model since we expand the fields
-
- # Handle standard Python types (int, float, str, etc.)
- elif hasattr(arg, "annotation") and isinstance(arg.annotation, ast.Name):
- if arg.annotation.id in [
- "int",
- "float",
- "bool",
- "str",
- "list",
- "tuple",
- "set",
- "dict",
- ]:
- param_info["type"] = arg.annotation.id
- else:
- param_info["type"] = "[UNKNOWN - PLEASE FIX]"
-
- # Handle generic subscript types (e.g., Optional, List[Type], etc.)
- elif hasattr(arg, "annotation") and isinstance(
- arg.annotation, ast.Subscript
- ):
- if isinstance(
- arg.annotation.value, ast.Name
- ) and arg.annotation.value.id in ["list", "tuple", "set", "dict"]:
- param_info["type"] = (
- f"{arg.annotation.value.id}" # e.g., "List", "Tuple", etc.
- )
- else:
- param_info["type"] = "[UNKNOWN - PLEASE FIX]"
-
- # Default for unknown types
- else:
- param_info["type"] = (
- "[UNKNOWN - PLEASE FIX]" # If unable to detect type
- )
-
- # Handle default values
- if default is not None:
- if isinstance(default, ast.Constant) or isinstance(
- default, ast.NameConstant
- ):
- param_info["default"] = (
- default.value
- ) # Use the default value directly
- else:
- param_info["default"] = "[UNKNOWN DEFAULT]" # Unknown default type
- param_info["required"] = False # Optional since it has a default value
- else:
- param_info["default"] = None
- param_info["required"] = True # Required if no default value
-
- parameters.append(param_info)
-
- return parameters
-
-
-def get_function_docstring(node: Any) -> str:
- """Extract the function's docstring description if present."""
- # Check if the first node is a docstring
- if isinstance(node.body[0], ast.Expr) and isinstance(node.body[0].value, ast.Str):
- # Get the entire docstring
- full_docstring = node.body[0].value.s.strip()
-
- # Split the docstring by double newlines (to separate description from fields like Args)
- description = full_docstring.split("\n\n")[0].strip()
-
- return description
-
- return "No description provided."
-
-
-def extract_arg_descriptions_from_docstring(docstring: str) -> dict:
- """Extract descriptions for function parameters from the 'Args' section of the docstring."""
- descriptions = {}
- if not docstring:
- return descriptions
-
- in_args_section = False
- current_param = None
- for line in docstring.splitlines():
- line = line.strip()
-
- # Detect the start of the 'Args' section
- if line.startswith("Args:"):
- in_args_section = True
- continue # Proceed to the next line after 'Args:'
-
- # End of 'Args' section if no indentation and no colon
- if in_args_section and not line.startswith(" ") and ":" not in line:
- break # Stop processing if we reach a new section
-
- # Process lines in the 'Args' section
- if in_args_section:
- if ":" in line:
- # Extract parameter name and description
- param_name, description = line.split(":", 1)
- descriptions[param_name.strip()] = description.strip()
- current_param = param_name.strip()
- elif current_param and line.startswith(" "):
- # Handle multiline descriptions (indented lines)
- descriptions[current_param] += f" {line.strip()}"
-
- return descriptions
-
-
-def generate_prompt_targets(input_file_path: str) -> None:
- """Introspect routes and generate YAML for either Flask or FastAPI."""
- with open(input_file_path, "r") as source:
- tree = ast.parse(source.read())
-
- # Detect the framework (Flask or FastAPI)
- framework = detect_framework(tree)
- if framework == "unknown":
- print("Could not detect Flask or FastAPI in the file.")
- return
-
- # Extract routes
- routes = []
- for node in ast.walk(tree):
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
- route_decorators = get_route_decorators(node, framework)
- if route_decorators:
- route_path = get_route_path(node, framework)
- function_params = get_function_parameters(
- node, tree
- ) # Get parameters for the route
- function_docstring = get_function_docstring(node) # Extract docstring
- routes.append(
- {
- "name": node.name,
- "path": route_path,
- "methods": route_decorators,
- "parameters": function_params, # Add parameters to the route
- "description": function_docstring, # Add the docstring as the description
- }
- )
-
- # Generate YAML structure
- output_structure = {"prompt_targets": []}
-
- for route in routes:
- target = {
- "name": route["name"],
- "endpoint": [
- {
- "name": "app_server",
- "path": route["path"],
- }
- ],
- "description": route["description"], # Use extracted docstring
- "parameters": [
- {
- "name": param["name"],
- "type": param["type"],
- "description": f"{param['description']}",
- **(
- {"default": param["default"]}
- if "default" in param and param["default"] is not None
- else {}
- ), # Only add default if it's set
- "required": param["required"],
- }
- for param in route["parameters"]
- ],
- }
-
- if route["name"] == "default":
- # Special case for `information_extraction` based on your YAML format
- target["type"] = "default"
- target["auto-llm-dispatch-on-response"] = True
-
- output_structure["prompt_targets"].append(target)
-
- # Output as YAML
- print(
- yaml.dump(output_structure, sort_keys=False, default_flow_style=False, indent=3)
- )
-
-
-if __name__ == "__main__":
- if len(sys.argv) != 2:
- print("Usage: python targets.py ")
- sys.exit(1)
-
- input_file = sys.argv[1]
-
- # Automatically generate the output file name
- if input_file.endswith(".py"):
- output_file = input_file.replace(".py", "_prompt_targets.yml")
- else:
- print("Error: Input file must be a .py file")
- sys.exit(1)
-
- # Call the function with the input and generated output file names
- generate_prompt_targets(input_file, output_file)
-
-# Example usage:
-# python targets.py api.yaml
diff --git a/cli/planoai/templates/conversational_state_v1_responses.yaml b/cli/planoai/templates/conversational_state_v1_responses.yaml
index 403278a9..11fb7477 100644
--- a/cli/planoai/templates/conversational_state_v1_responses.yaml
+++ b/cli/planoai/templates/conversational_state_v1_responses.yaml
@@ -11,7 +11,7 @@ model_providers:
default: true
# Anthropic Models
- - model: anthropic/claude-sonnet-4-20250514
+ - model: anthropic/claude-sonnet-4-6
access_key: $ANTHROPIC_API_KEY
listeners:
diff --git a/cli/planoai/templates/preference_aware_routing.yaml b/cli/planoai/templates/preference_aware_routing.yaml
index e38b3881..1fcb6bf4 100644
--- a/cli/planoai/templates/preference_aware_routing.yaml
+++ b/cli/planoai/templates/preference_aware_routing.yaml
@@ -12,7 +12,7 @@ model_providers:
- name: code understanding
description: understand and explain existing code snippets, functions, or libraries
- - model: anthropic/claude-sonnet-4-20250514
+ - model: anthropic/claude-sonnet-4-6
access_key: $ANTHROPIC_API_KEY
routing_preferences:
- name: code generation
diff --git a/cli/pyproject.toml b/cli/pyproject.toml
index f7ac640e..1bc3730c 100644
--- a/cli/pyproject.toml
+++ b/cli/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "planoai"
-version = "0.4.22"
+version = "0.4.27"
description = "Python-based CLI tool to manage Plano."
authors = [{name = "Katanemo Labs, Inc."}]
readme = "README.md"
diff --git a/cli/test/test_config_generator.py b/cli/test/test_config_generator.py
index 77b5b480..2f9834ca 100644
--- a/cli/test/test_config_generator.py
+++ b/cli/test/test_config_generator.py
@@ -3,8 +3,10 @@ import pytest
import yaml
from unittest import mock
from planoai.config_generator import (
- validate_and_render_schema,
+ apply_kimi_code_provider_defaults,
migrate_inline_routing_preferences,
+ normalize_kimi_code_base_url,
+ validate_and_render_schema,
)
@@ -327,6 +329,90 @@ routing_preferences:
tracing:
random_sampling: 100
+""",
+ },
+ {
+ "id": "unknown_listener_output_filter",
+ "expected_error": "references output_filters id 'missing_output_guard'",
+ "plano_config": """
+version: v0.4.0
+
+filters:
+ - id: input_guard
+ url: http://localhost:10500
+ type: http
+
+listeners:
+ - name: llm
+ type: model
+ port: 12000
+ input_filters:
+ - input_guard
+ output_filters:
+ - missing_output_guard
+
+model_providers:
+ - model: openai/gpt-4o-mini
+ access_key: $OPENAI_API_KEY
+ default: true
+
+""",
+ },
+ {
+ "id": "valid_listener_output_filter",
+ "expected_error": None,
+ "plano_config": """
+version: v0.4.0
+
+filters:
+ - id: input_guard
+ url: http://localhost:10500
+ type: http
+ - id: output_guard
+ url: http://localhost:10501
+ type: http
+
+listeners:
+ - name: llm
+ type: model
+ port: 12000
+ input_filters:
+ - input_guard
+ output_filters:
+ - output_guard
+
+model_providers:
+ - model: openai/gpt-4o-mini
+ access_key: $OPENAI_API_KEY
+ default: true
+
+""",
+ },
+ {
+ "id": "valid_tracing_posthog_exporter",
+ "expected_error": None,
+ "plano_config": """
+version: v0.4.0
+
+listeners:
+ - name: llm
+ type: model
+ port: 12000
+
+model_providers:
+ - model: openai/gpt-4o-mini
+ access_key: $OPENAI_API_KEY
+ default: true
+
+tracing:
+ random_sampling: 100
+ exporters:
+ - type: posthog
+ url: https://us.i.posthog.com
+ api_key: $POSTHOG_API_KEY
+ distinct_id_header: x-user-id
+ capture_messages: false
+
""",
},
]
@@ -525,7 +611,7 @@ model_providers:
- name: code understanding
description: understand and explain existing code snippets, functions, or libraries
- - model: anthropic/claude-sonnet-4-20250514
+ - model: anthropic/claude-sonnet-4-6
access_key: $ANTHROPIC_API_KEY
routing_preferences:
- name: code generation
@@ -542,9 +628,7 @@ model_providers:
by_name = {entry["name"]: entry for entry in top_level}
assert set(by_name) == {"code understanding", "code generation"}
assert by_name["code understanding"]["models"] == ["openai/gpt-4o"]
- assert by_name["code generation"]["models"] == [
- "anthropic/claude-sonnet-4-20250514"
- ]
+ assert by_name["code generation"]["models"] == ["anthropic/claude-sonnet-4-6"]
assert (
by_name["code understanding"]["description"]
== "understand and explain existing code snippets, functions, or libraries"
@@ -567,7 +651,7 @@ model_providers:
- name: code generation
description: generating new code snippets, functions, or boilerplate based on user prompts or requirements
- - model: anthropic/claude-sonnet-4-20250514
+ - model: anthropic/claude-sonnet-4-6
access_key: $ANTHROPIC_API_KEY
routing_preferences:
- name: code generation
@@ -582,7 +666,7 @@ model_providers:
assert entry["name"] == "code generation"
assert entry["models"] == [
"openai/gpt-4o",
- "anthropic/claude-sonnet-4-20250514",
+ "anthropic/claude-sonnet-4-6",
]
assert config_yaml["version"] == "v0.4.0"
@@ -599,7 +683,7 @@ listeners:
model_providers:
- model: openai/gpt-4o
access_key: $OPENAI_API_KEY
- - model: anthropic/claude-sonnet-4-20250514
+ - model: anthropic/claude-sonnet-4-6
access_key: $ANTHROPIC_API_KEY
routing_preferences:
@@ -607,7 +691,7 @@ routing_preferences:
description: generating new code snippets or boilerplate
models:
- openai/gpt-4o
- - anthropic/claude-sonnet-4-20250514
+ - anthropic/claude-sonnet-4-6
"""
config_yaml = yaml.safe_load(plano_config)
before = yaml.safe_dump(config_yaml, sort_keys=True)
@@ -738,3 +822,29 @@ model_providers:
migrate_inline_routing_preferences(config_yaml)
assert config_yaml["version"] == "v0.5.0"
+
+
+def test_normalize_kimi_code_base_url_appends_v1_suffix():
+ assert (
+ normalize_kimi_code_base_url("https://api.kimi.com/coding")
+ == "https://api.kimi.com/coding/v1"
+ )
+ assert (
+ normalize_kimi_code_base_url("https://api.kimi.com/coding/")
+ == "https://api.kimi.com/coding/v1"
+ )
+ assert (
+ normalize_kimi_code_base_url("https://api.kimi.com/coding/v1")
+ == "https://api.kimi.com/coding/v1"
+ )
+
+
+def test_apply_kimi_code_provider_defaults_injects_user_agent():
+ provider = {
+ "model": "kimi-for-coding",
+ "base_url": "https://api.kimi.com/coding",
+ "access_key": "$MOONSHOTAI_API_KEY",
+ }
+ apply_kimi_code_provider_defaults(provider)
+ assert provider["base_url"] == "https://api.kimi.com/coding/v1"
+ assert provider["headers"]["User-Agent"] == "KimiCLI/1.3"
diff --git a/cli/test/test_obs_pricing.py b/cli/test/test_obs_pricing.py
index 02247d3d..322607a9 100644
--- a/cli/test/test_obs_pricing.py
+++ b/cli/test/test_obs_pricing.py
@@ -144,3 +144,68 @@ def test_parse_do_catalog_divides_large_values_as_per_million():
prices = _parse_do_pricing(sample)
assert prices["mystery-model"].input_per_token_usd == 5.0 / 1_000_000
assert prices["mystery-model"].output_per_token_usd == 15.0 / 1_000_000
+
+
+_MODELS_DEV_SAMPLE = {
+ "anthropic": {
+ "id": "anthropic",
+ "models": {
+ "claude-opus-4-5": {
+ "id": "claude-opus-4-5",
+ "cost": {"input": 5, "output": 25, "cache_read": 0.5},
+ }
+ },
+ },
+ "groq": {
+ "id": "groq",
+ "models": {
+ "llama-3.3-70b-versatile": {
+ "id": "llama-3.3-70b-versatile",
+ "cost": {"input": 0.59, "output": 0.79},
+ },
+ # No cost block → skipped.
+ "whisper-large-v3-turbo": {"id": "whisper-large-v3-turbo"},
+ },
+ },
+}
+
+
+def test_parse_models_dev_composes_provider_keys_and_per_token_rates():
+ from planoai.obs.pricing import _parse_models_dev_pricing
+
+ prices = _parse_models_dev_pricing(_MODELS_DEV_SAMPLE)
+
+ # models.dev cost values are per-million → divided by 1e6.
+ opus = prices["anthropic/claude-opus-4-5"]
+ assert opus.input_per_token_usd == 5 / 1_000_000
+ assert opus.output_per_token_usd == 25 / 1_000_000
+ assert opus.cached_input_per_token_usd == 0.5 / 1_000_000
+
+ # Composite provider/model keys match Plano's routing names.
+ assert "groq/llama-3.3-70b-versatile" in prices
+ # Bare model id registered as a fallback.
+ assert "llama-3.3-70b-versatile" in prices
+ # Models without a cost block are skipped.
+ assert "groq/whisper-large-v3-turbo" not in prices
+
+
+def test_models_dev_catalog_cost_computation():
+ from planoai.obs.pricing import PricingCatalog, _parse_models_dev_pricing
+
+ catalog = PricingCatalog(_parse_models_dev_pricing(_MODELS_DEV_SAMPLE))
+ # 1000 input @ 5e-6 = 0.005; 500 output @ 25e-6 = 0.0125
+ cost = catalog.cost_for_call(_call("anthropic/claude-opus-4-5", 1000, 500))
+ assert cost == round(0.005 + 0.0125, 6)
+
+
+def test_models_dev_skips_zero_rate_entries():
+ from planoai.obs.pricing import _parse_models_dev_pricing
+
+ sample = {
+ "free": {
+ "models": {
+ "promo-model": {"cost": {"input": 0, "output": 0}},
+ }
+ }
+ }
+ assert _parse_models_dev_pricing(sample) == {}
diff --git a/cli/uv.lock b/cli/uv.lock
index d63fab73..8d79c4fc 100644
--- a/cli/uv.lock
+++ b/cli/uv.lock
@@ -337,7 +337,7 @@ wheels = [
[[package]]
name = "planoai"
-version = "0.4.22"
+version = "0.4.27"
source = { editable = "." }
dependencies = [
{ name = "click" },
diff --git a/config/plano_config_schema.yaml b/config/plano_config_schema.yaml
index 9560b437..f356b573 100644
--- a/config/plano_config_schema.yaml
+++ b/config/plano_config_schema.yaml
@@ -194,6 +194,7 @@ properties:
- digitalocean
- vercel
- openrouter
+ - moonshotai
headers:
type: object
additionalProperties:
@@ -252,6 +253,7 @@ properties:
- digitalocean
- vercel
- openrouter
+ - moonshotai
headers:
type: object
additionalProperties:
@@ -445,6 +447,28 @@ properties:
additionalProperties:
type: string
additionalProperties: false
+ exporters:
+ type: array
+ items:
+ oneOf:
+ - type: object
+ properties:
+ type:
+ type: string
+ const: posthog
+ url:
+ type: string
+ api_key:
+ type: string
+ distinct_id_header:
+ type: string
+ capture_messages:
+ type: boolean
+ additionalProperties: false
+ required:
+ - type
+ - url
+ - api_key
additionalProperties: false
mode:
type: string
@@ -580,13 +604,17 @@ properties:
type: string
enum:
- digitalocean
+ - models.dev
+ url:
+ type: string
+ description: "Optional override for the pricing catalog endpoint. Defaults per provider (digitalocean: DO GenAI catalog; models.dev: https://models.dev/api.json)."
refresh_interval:
type: integer
minimum: 1
description: "Refresh interval in seconds"
model_aliases:
type: object
- description: "Map DO catalog keys (lowercase(creator)/model_id) to Plano model names used in routing_preferences. Example: 'openai/openai-gpt-oss-120b: openai/gpt-4o'"
+ description: "Map catalog keys to Plano model names used in routing_preferences. DigitalOcean keys are 'lowercase(creator)/model_id'; models.dev keys are 'creator/model_id'. Example: 'openai/openai-gpt-oss-120b: openai/gpt-4o'"
additionalProperties:
type: string
required:
diff --git a/crates/Cargo.lock b/crates/Cargo.lock
index 39261d67..fd13d4c5 100644
--- a/crates/Cargo.lock
+++ b/crates/Cargo.lock
@@ -2552,9 +2552,9 @@ dependencies = [
[[package]]
name = "proxy-wasm"
-version = "0.2.4"
+version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8d35d9e2bc5104e2e954b149aa1d5f9fa3bb27f73b45b2706020fed101db685"
+checksum = "de8f6564bd52c2f4ff79fa5d1bd3bc10d8f822162af8d527e121e46703496aa0"
dependencies = [
"hashbrown 0.16.1",
"log",
@@ -2752,12 +2752,18 @@ dependencies = [
"num-bigint",
"percent-encoding",
"pin-project-lite",
+ "rustls 0.23.38",
+ "rustls-native-certs 0.7.3",
+ "rustls-pemfile 2.2.0",
+ "rustls-pki-types",
"ryu",
"sha1_smol",
"socket2 0.5.10",
"tokio",
+ "tokio-rustls 0.26.4",
"tokio-util",
"url",
+ "webpki-roots 0.26.11",
]
[[package]]
@@ -2965,7 +2971,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
dependencies = [
"openssl-probe 0.1.6",
- "rustls-pemfile",
+ "rustls-pemfile 1.0.4",
+ "schannel",
+ "security-framework 2.11.1",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
+dependencies = [
+ "openssl-probe 0.1.6",
+ "rustls-pemfile 2.2.0",
+ "rustls-pki-types",
"schannel",
"security-framework 2.11.1",
]
@@ -2991,6 +3010,15 @@ dependencies = [
"base64 0.21.7",
]
+[[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -4024,7 +4052,7 @@ dependencies = [
"serde_json",
"ureq-proto",
"utf8-zero",
- "webpki-roots",
+ "webpki-roots 1.0.6",
]
[[package]]
@@ -4278,6 +4306,15 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.6",
+]
+
[[package]]
name = "webpki-roots"
version = "1.0.6"
diff --git a/crates/brightstaff/Cargo.toml b/crates/brightstaff/Cargo.toml
index d2635963..0b62c313 100644
--- a/crates/brightstaff/Cargo.toml
+++ b/crates/brightstaff/Cargo.toml
@@ -43,7 +43,7 @@ lru = "0.12"
metrics = "0.23"
metrics-exporter-prometheus = { version = "0.15", default-features = false, features = ["http-listener"] }
metrics-process = "2.1"
-redis = { version = "0.27", features = ["tokio-comp"] }
+redis = { version = "0.27", features = ["tokio-comp", "tokio-rustls-comp", "tls-rustls-webpki-roots"] }
reqwest = { version = "0.12.15", features = ["stream"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
diff --git a/crates/brightstaff/src/app_state.rs b/crates/brightstaff/src/app_state.rs
index 1d534e89..de4393f3 100644
--- a/crates/brightstaff/src/app_state.rs
+++ b/crates/brightstaff/src/app_state.rs
@@ -21,6 +21,10 @@ pub struct AppState {
pub state_storage: Option>,
pub llm_provider_url: String,
pub span_attributes: Option,
+ /// Request header whose value populates the observability `distinct_id`
+ /// (e.g. PostHog). Sourced from `tracing.exporters[].distinct_id_header`.
+ /// `None` means LLM events are captured anonymously.
+ pub distinct_id_header: Option,
/// Shared HTTP client for upstream LLM requests (connection pooling / keep-alive).
pub http_client: reqwest::Client,
pub filter_pipeline: Arc,
diff --git a/crates/brightstaff/src/handlers/llm/mod.rs b/crates/brightstaff/src/handlers/llm/mod.rs
index 3336209f..a1d3ce97 100644
--- a/crates/brightstaff/src/handlers/llm/mod.rs
+++ b/crates/brightstaff/src/handlers/llm/mod.rs
@@ -93,6 +93,25 @@ async fn llm_chat_inner(
}
});
+ // Stamp the caller identity for downstream exporters (e.g. PostHog
+ // `distinct_id`). Sourced from the configured `distinct_id_header`; when the
+ // header is absent the event is exported anonymously.
+ if let Some(header_name) = state.distinct_id_header.as_deref() {
+ if let Some(distinct_id) = request_headers
+ .get(header_name)
+ .and_then(|v| v.to_str().ok())
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ {
+ get_active_span(|span| {
+ span.set_attribute(opentelemetry::KeyValue::new(
+ tracing_plano::DISTINCT_ID,
+ distinct_id.to_string(),
+ ));
+ });
+ }
+ }
+
// Session pinning: extract session ID and check cache before routing
let session_id: Option = request_headers
.get(MODEL_AFFINITY_HEADER)
@@ -366,6 +385,19 @@ async fn llm_chat_inner(
};
tracing::Span::current().record(tracing_llm::MODEL_NAME, resolved_model.as_str());
+ // Record the provider (derived from the `provider/model` prefix) so
+ // observability exporters can populate provider fields (e.g. PostHog
+ // `$ai_provider`).
+ let (resolved_provider, _) = bs_metrics::split_provider_model(&resolved_model);
+ if resolved_provider != "unknown" {
+ get_active_span(|span| {
+ span.set_attribute(opentelemetry::KeyValue::new(
+ tracing_llm::PROVIDER,
+ resolved_provider.to_string(),
+ ));
+ });
+ }
+
// --- Phase 4: Forward to upstream and stream back ---
send_upstream(
&state.http_client,
diff --git a/crates/brightstaff/src/main.rs b/crates/brightstaff/src/main.rs
index b1e17e42..c9e8b9bc 100644
--- a/crates/brightstaff/src/main.rs
+++ b/crates/brightstaff/src/main.rs
@@ -142,25 +142,19 @@ async fn init_app_state(
.listeners
.iter()
.find(|l| l.listener_type == ListenerType::Model);
- let resolve_chain = |filter_ids: Option>| -> Option {
- filter_ids.map(|ids| {
- let agents = ids
- .iter()
- .filter_map(|id| {
- global_agent_map
- .get(id)
- .map(|a: &Agent| (id.clone(), a.clone()))
- })
- .collect();
- ResolvedFilterChain {
- filter_ids: ids,
- agents,
- }
- })
- };
let filter_pipeline = Arc::new(FilterPipeline {
- input: resolve_chain(model_listener.and_then(|l| l.input_filters.clone())),
- output: resolve_chain(model_listener.and_then(|l| l.output_filters.clone())),
+ input: resolve_filter_chain(
+ "input_filters",
+ model_listener.and_then(|l| l.input_filters.clone()),
+ &global_agent_map,
+ )
+ .map_err(|e| format!("failed to resolve model listener input filters: {e}"))?,
+ output: resolve_filter_chain(
+ "output_filters",
+ model_listener.and_then(|l| l.output_filters.clone()),
+ &global_agent_map,
+ )
+ .map_err(|e| format!("failed to resolve model listener output filters: {e}"))?,
});
let overrides = config.overrides.clone().unwrap_or_default();
@@ -333,6 +327,20 @@ async fn init_app_state(
.as_ref()
.and_then(|tracing| tracing.span_attributes.clone());
+ // Resolve the distinct_id header from the first PostHog exporter that
+ // declares one, so the LLM handler can stamp `plano.distinct_id` on spans.
+ let distinct_id_header = config
+ .tracing
+ .as_ref()
+ .and_then(|tracing| tracing.exporters.as_ref())
+ .and_then(|exporters| {
+ exporters.iter().find_map(|exporter| match exporter {
+ common::configuration::Exporter::Posthog(posthog) => {
+ posthog.distinct_id_header.clone()
+ }
+ })
+ });
+
let signals_enabled = !overrides.disable_signals.unwrap_or(false);
Ok(AppState {
@@ -344,12 +352,36 @@ async fn init_app_state(
state_storage,
llm_provider_url,
span_attributes,
+ distinct_id_header,
http_client: reqwest::Client::new(),
filter_pipeline,
signals_enabled,
})
}
+fn resolve_filter_chain(
+ field_name: &str,
+ filter_ids: Option>,
+ global_agent_map: &HashMap,
+) -> Result