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**: -![high-level network plano arcitecture for Plano](docs/source/_static/img/plano_network_diagram_high_level.png) +![high-level network plano architecture for Plano](docs/source/_static/img/plano_network_diagram_high_level.png) **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. -![Atomatic Tracing](docs/source/_static/img/demo_tracing.png) +![Automatic Tracing](docs/source/_static/img/demo_tracing.png) ### 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, String> { + let Some(ids) = filter_ids else { + return Ok(None); + }; + + let mut agents = HashMap::new(); + for id in &ids { + let agent = global_agent_map + .get(id) + .ok_or_else(|| format!("{field_name} id '{id}' is not defined in agents or filters"))?; + agents.insert(id.clone(), agent.clone()); + } + + Ok(Some(ResolvedFilterChain { + filter_ids: ids, + agents, + })) +} + /// Initialize the conversation state storage backend (if configured). async fn init_state_storage( config: &Configuration, @@ -588,3 +620,63 @@ async fn main() -> Result<(), Box> { let state = Arc::new(init_app_state(&config).await?); run_server(state).await } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_agent(id: &str) -> Agent { + Agent { + id: id.to_string(), + transport: None, + tool: None, + url: "http://localhost:10500".to_string(), + agent_type: Some("http".to_string()), + } + } + + #[test] + fn resolve_filter_chain_keeps_valid_filter_references() { + let agent = test_agent("output_guard"); + let global_agent_map = HashMap::from([(agent.id.clone(), agent)]); + + let resolved = resolve_filter_chain( + "output_filters", + Some(vec!["output_guard".to_string()]), + &global_agent_map, + ) + .expect("filter chain should resolve") + .expect("filter chain should be present"); + + assert_eq!(resolved.filter_ids, vec!["output_guard".to_string()]); + assert!(resolved.agents.contains_key("output_guard")); + } + + #[test] + fn resolve_filter_chain_errors_on_missing_output_filter_reference() { + let global_agent_map = HashMap::new(); + + let err = resolve_filter_chain( + "output_filters", + Some(vec!["missing_output_guard".to_string()]), + &global_agent_map, + ) + .expect_err("missing output filter should fail closed"); + + assert!(err.contains("output_filters id 'missing_output_guard'")); + } + + #[test] + fn resolve_filter_chain_errors_on_missing_input_filter_reference() { + let global_agent_map = HashMap::new(); + + let err = resolve_filter_chain( + "input_filters", + Some(vec!["missing_input_guard".to_string()]), + &global_agent_map, + ) + .expect_err("missing input filter should fail closed"); + + assert!(err.contains("input_filters id 'missing_input_guard'")); + } +} diff --git a/crates/brightstaff/src/router/model_metrics.rs b/crates/brightstaff/src/router/model_metrics.rs index 1adb408d..a4b3df48 100644 --- a/crates/brightstaff/src/router/model_metrics.rs +++ b/crates/brightstaff/src/router/model_metrics.rs @@ -9,6 +9,7 @@ use tokio::sync::RwLock; use tracing::{debug, info, warn}; const DO_PRICING_URL: &str = "https://api.digitalocean.com/v2/gen-ai/models/catalog"; +const MODELS_DEV_URL: &str = "https://models.dev/api.json"; pub struct ModelMetricsService { cost: Arc>>, @@ -22,28 +23,35 @@ impl ModelMetricsService { for source in sources { match source { - MetricsSource::Cost(cfg) => match cfg.provider { - CostProvider::Digitalocean => { - let aliases = cfg.model_aliases.clone().unwrap_or_default(); - let data = fetch_do_pricing(&client, &aliases).await; - info!(models = data.len(), "fetched digitalocean pricing"); - *cost_data.write().await = data; + MetricsSource::Cost(cfg) => { + let provider = cfg.provider.clone(); + let url = cfg + .url + .clone() + .unwrap_or_else(|| default_cost_url(&provider).to_string()); + let aliases = cfg.model_aliases.clone().unwrap_or_default(); + let provider_name = cost_provider_name(&provider); - if let Some(interval_secs) = cfg.refresh_interval { - let cost_clone = Arc::clone(&cost_data); - let client_clone = client.clone(); - let interval = Duration::from_secs(interval_secs); - tokio::spawn(async move { - loop { - tokio::time::sleep(interval).await; - let data = fetch_do_pricing(&client_clone, &aliases).await; - info!(models = data.len(), "refreshed digitalocean pricing"); - *cost_clone.write().await = data; - } - }); - } + let data = fetch_cost_pricing(&provider, &url, &client, &aliases).await; + info!(models = data.len(), provider = provider_name, url = %url, "fetched cost pricing"); + *cost_data.write().await = data; + + if let Some(interval_secs) = cfg.refresh_interval { + let cost_clone = Arc::clone(&cost_data); + let client_clone = client.clone(); + let interval = Duration::from_secs(interval_secs); + tokio::spawn(async move { + loop { + tokio::time::sleep(interval).await; + let data = + fetch_cost_pricing(&provider, &url, &client_clone, &aliases) + .await; + info!(models = data.len(), provider = provider_name, url = %url, "refreshed cost pricing"); + *cost_clone.write().await = data; + } + }); } - }, + } MetricsSource::Latency(cfg) => match cfg.provider { LatencyProvider::Prometheus => { let data = fetch_prometheus_metrics(&cfg.url, &cfg.query, &client).await; @@ -165,11 +173,55 @@ struct DoPricing { output_price_per_million: Option, } -async fn fetch_do_pricing( +#[derive(serde::Deserialize)] +struct ModelsDevProvider { + #[serde(default)] + models: HashMap, +} + +#[derive(serde::Deserialize)] +struct ModelsDevModel { + cost: Option, +} + +#[derive(serde::Deserialize)] +struct ModelsDevCost { + input: Option, + output: Option, +} + +fn default_cost_url(provider: &CostProvider) -> &'static str { + match provider { + CostProvider::Digitalocean => DO_PRICING_URL, + CostProvider::ModelsDev => MODELS_DEV_URL, + } +} + +fn cost_provider_name(provider: &CostProvider) -> &'static str { + match provider { + CostProvider::Digitalocean => "digitalocean", + CostProvider::ModelsDev => "models.dev", + } +} + +async fn fetch_cost_pricing( + provider: &CostProvider, + url: &str, client: &reqwest::Client, aliases: &HashMap, ) -> HashMap { - match client.get(DO_PRICING_URL).send().await { + match provider { + CostProvider::Digitalocean => fetch_do_pricing(url, client, aliases).await, + CostProvider::ModelsDev => fetch_models_dev_pricing(url, client, aliases).await, + } +} + +async fn fetch_do_pricing( + url: &str, + client: &reqwest::Client, + aliases: &HashMap, +) -> HashMap { + match client.get(url).send().await { Ok(resp) => match resp.json::().await { Ok(list) => list .data @@ -184,17 +236,66 @@ async fn fetch_do_pricing( }) .collect(), Err(err) => { - warn!(error = %err, url = DO_PRICING_URL, "failed to parse digitalocean pricing response"); + warn!(error = %err, url = %url, "failed to parse digitalocean pricing response"); HashMap::new() } }, Err(err) => { - warn!(error = %err, url = DO_PRICING_URL, "failed to fetch digitalocean pricing"); + warn!(error = %err, url = %url, "failed to fetch digitalocean pricing"); HashMap::new() } } } +/// models.dev publishes a top-level object keyed by provider id; each provider +/// carries a `models` map whose keys are `creator/model` ids and whose `cost` +/// block holds per-million USD rates. We sum input + output (mirroring the DO +/// ranking metric) and key the result by `creator/model_id` so it lines up with +/// Plano's `provider/model` routing names. +async fn fetch_models_dev_pricing( + url: &str, + client: &reqwest::Client, + aliases: &HashMap, +) -> HashMap { + match client.get(url).send().await { + Ok(resp) => match resp.json::>().await { + Ok(providers) => parse_models_dev_pricing(providers, aliases), + Err(err) => { + warn!(error = %err, url = %url, "failed to parse models.dev pricing response"); + HashMap::new() + } + }, + Err(err) => { + warn!(error = %err, url = %url, "failed to fetch models.dev pricing"); + HashMap::new() + } + } +} + +fn parse_models_dev_pricing( + providers: HashMap, + aliases: &HashMap, +) -> HashMap { + let mut out = HashMap::new(); + for (provider_id, provider) in providers { + for (model_key, model) in provider.models { + let Some(cost) = model.cost else { continue }; + let (Some(input), Some(output)) = (cost.input, cost.output) else { + continue; + }; + // First-party providers use bare model keys (`claude-opus-4-5`), + // so compose `provider/model` to line up with Plano routing names. + let raw_key = format!("{provider_id}/{model_key}"); + let total = input + output; + let key = aliases.get(&raw_key).cloned().unwrap_or(raw_key); + out.insert(key, total); + // Also register the bare model id as a fallback lookup. + out.entry(model_key).or_insert(total); + } + } + out +} + #[derive(serde::Deserialize)] struct PrometheusResponse { data: PrometheusData, @@ -368,6 +469,50 @@ mod tests { assert_eq!(result, vec!["gpt-4o", "gpt-4o-mini"]); } + #[test] + fn test_parse_models_dev_pricing_composes_provider_keys() { + let json = r#"{ + "anthropic": { + "models": { + "claude-opus-4-5": {"cost": {"input": 5.0, "output": 25.0}} + } + }, + "groq": { + "models": { + "llama-3.3-70b-versatile": {"cost": {"input": 0.59, "output": 0.79}}, + "whisper-large-v3-turbo": {"cost": null} + } + } + }"#; + let providers: HashMap = serde_json::from_str(json).unwrap(); + let aliases = HashMap::new(); + let prices = parse_models_dev_pricing(providers, &aliases); + + assert_eq!(prices.get("anthropic/claude-opus-4-5"), Some(&30.0)); + assert_eq!(prices.get("groq/llama-3.3-70b-versatile"), Some(&1.38)); + // bare fallback also registered + assert_eq!(prices.get("claude-opus-4-5"), Some(&30.0)); + // models with no cost block are skipped + assert!(!prices.contains_key("groq/whisper-large-v3-turbo")); + } + + #[test] + fn test_parse_models_dev_pricing_applies_aliases() { + let json = r#"{ + "openai": {"models": {"gpt-oss-120b": {"cost": {"input": 1.0, "output": 2.0}}}} + }"#; + let providers: HashMap = serde_json::from_str(json).unwrap(); + let mut aliases = HashMap::new(); + aliases.insert( + "openai/gpt-oss-120b".to_string(), + "openai/gpt-4o".to_string(), + ); + let prices = parse_models_dev_pricing(providers, &aliases); + + assert_eq!(prices.get("openai/gpt-4o"), Some(&3.0)); + assert!(!prices.contains_key("openai/gpt-oss-120b")); + } + #[test] fn test_rank_by_ascending_metric_nan_treated_as_missing() { let models = vec![ diff --git a/crates/brightstaff/src/signals/otel.rs b/crates/brightstaff/src/signals/otel.rs index deb3c1b5..176875cc 100644 --- a/crates/brightstaff/src/signals/otel.rs +++ b/crates/brightstaff/src/signals/otel.rs @@ -1,27 +1,21 @@ //! Helpers for emitting `SignalReport` data to OpenTelemetry spans. //! -//! Two sets of attributes are emitted: -//! -//! - **Legacy** keys under `signals.*` (e.g. `signals.frustration.count`), -//! computed from the new layered counts. Preserved for one release for -//! backward compatibility with existing dashboards. -//! - **New** layered keys (e.g. `signals.interaction.misalignment.count`), -//! one set of `count`/`severity` attributes per category, plus per-instance -//! span events named `signal.`. +//! Layered keys (e.g. `signals.interaction.misalignment.count`) are emitted, +//! one set of `count`/`severity` attributes per category, plus per-instance +//! span events named `signal.`. use opentelemetry::trace::SpanRef; use opentelemetry::KeyValue; -use crate::signals::schemas::{SignalGroup, SignalReport, SignalType}; +use crate::signals::schemas::{SignalGroup, SignalReport}; -/// Emit both legacy and layered OTel attributes/events for a `SignalReport`. +/// Emit layered OTel attributes/events for a `SignalReport`. /// /// Returns `true` if any "concerning" signal was found, mirroring the previous /// behavior used to flag the span operation name. pub fn emit_signals_to_span(span: &SpanRef<'_>, report: &SignalReport) -> bool { emit_overall(span, report); emit_layered_attributes(span, report); - emit_legacy_attributes(span, report); emit_signal_events(span, report); is_concerning(report) @@ -90,69 +84,6 @@ fn emit_layered_attributes(span: &SpanRef<'_>, report: &SignalReport) { ); } -fn count_of(report: &SignalReport, t: SignalType) -> usize { - report.iter_signals().filter(|s| s.signal_type == t).count() -} - -/// Emit the legacy attribute keys consumed by existing dashboards. These are -/// derived from the new `SignalReport` so no detector contract is broken. -fn emit_legacy_attributes(span: &SpanRef<'_>, report: &SignalReport) { - use crate::tracing::signals as legacy; - - // signals.follow_up.repair.{count,ratio} - misalignment proxies repairs. - let repair_count = report.interaction.misalignment.count; - let user_turns = report.turn_metrics.user_turns.max(1) as f32; - if repair_count > 0 { - span.set_attribute(KeyValue::new(legacy::REPAIR_COUNT, repair_count as i64)); - let ratio = repair_count as f32 / user_turns; - span.set_attribute(KeyValue::new(legacy::REPAIR_RATIO, format!("{:.3}", ratio))); - } - - // signals.frustration.{count,severity} - disengagement.negative_stance is - // the closest legacy analog of "frustration". - let frustration_count = count_of(report, SignalType::DisengagementNegativeStance); - if frustration_count > 0 { - span.set_attribute(KeyValue::new( - legacy::FRUSTRATION_COUNT, - frustration_count as i64, - )); - let severity = match frustration_count { - 0 => 0, - 1..=2 => 1, - 3..=4 => 2, - _ => 3, - }; - span.set_attribute(KeyValue::new(legacy::FRUSTRATION_SEVERITY, severity as i64)); - } - - // signals.repetition.count - stagnation (repetition + dragging). - if report.interaction.stagnation.count > 0 { - span.set_attribute(KeyValue::new( - legacy::REPETITION_COUNT, - report.interaction.stagnation.count as i64, - )); - } - - // signals.escalation.requested - any escalation/quit signal. - let escalated = report.interaction.disengagement.signals.iter().any(|s| { - matches!( - s.signal_type, - SignalType::DisengagementEscalation | SignalType::DisengagementQuit - ) - }); - if escalated { - span.set_attribute(KeyValue::new(legacy::ESCALATION_REQUESTED, true)); - } - - // signals.positive_feedback.count - satisfaction signals. - if report.interaction.satisfaction.count > 0 { - span.set_attribute(KeyValue::new( - legacy::POSITIVE_FEEDBACK_COUNT, - report.interaction.satisfaction.count as i64, - )); - } -} - fn emit_signal_events(span: &SpanRef<'_>, report: &SignalReport) { for sig in report.iter_signals() { let event_name = format!("signal.{}", sig.signal_type.as_str()); @@ -231,11 +162,4 @@ mod tests { let r = report_with_escalation(); assert!(is_concerning(&r)); } - - #[test] - fn count_of_returns_per_type_count() { - let r = report_with_escalation(); - assert_eq!(count_of(&r, SignalType::DisengagementEscalation), 1); - assert_eq!(count_of(&r, SignalType::DisengagementNegativeStance), 0); - } } diff --git a/crates/brightstaff/src/streaming.rs b/crates/brightstaff/src/streaming.rs index 26af8672..4c532f30 100644 --- a/crates/brightstaff/src/streaming.rs +++ b/crates/brightstaff/src/streaming.rs @@ -367,9 +367,7 @@ impl StreamProcessor for ObservableStreamProcessor { self.response_buffer.shrink_to_fit(); // Analyze signals if messages are available and record as span - // attributes + per-signal events. We dual-emit legacy aggregate keys - // and the new layered taxonomy so existing dashboards keep working - // while new consumers can opt into the richer hierarchy. + // attributes + per-signal events using the layered signal taxonomy. if let Some(ref messages) = self.messages { let analyzer = SignalAnalyzer::default(); let report = analyzer.analyze_openai(messages); diff --git a/crates/brightstaff/src/tracing/constants.rs b/crates/brightstaff/src/tracing/constants.rs index 79a40401..f88cf99c 100644 --- a/crates/brightstaff/src/tracing/constants.rs +++ b/crates/brightstaff/src/tracing/constants.rs @@ -145,6 +145,11 @@ pub mod plano { /// "software-engineering"). Absent when the client routed directly /// to a concrete model. pub const ROUTE_NAME: &str = "plano.route.name"; + + /// Caller identity used to populate downstream observability `distinct_id` + /// fields (e.g. PostHog). Sourced from the configured + /// `tracing.exporters[].distinct_id_header`. Absent for anonymous calls. + pub const DISTINCT_ID: &str = "plano.distinct_id"; } // ============================================================================= @@ -183,27 +188,6 @@ pub mod signals { /// Efficiency score (0.0-1.0) pub const EFFICIENCY_SCORE: &str = "signals.efficiency_score"; - - /// Number of repair attempts detected - pub const REPAIR_COUNT: &str = "signals.follow_up.repair.count"; - - /// Ratio of repairs to user turns - pub const REPAIR_RATIO: &str = "signals.follow_up.repair.ratio"; - - /// Number of frustration indicators detected - pub const FRUSTRATION_COUNT: &str = "signals.frustration.count"; - - /// Frustration severity level (0-3) - pub const FRUSTRATION_SEVERITY: &str = "signals.frustration.severity"; - - /// Number of repetition instances detected - pub const REPETITION_COUNT: &str = "signals.repetition.count"; - - /// Whether escalation was requested (user asked for human help) - pub const ESCALATION_REQUESTED: &str = "signals.escalation.requested"; - - /// Number of positive feedback indicators detected - pub const POSITIVE_FEEDBACK_COUNT: &str = "signals.positive_feedback.count"; } // ============================================================================= diff --git a/crates/brightstaff/src/tracing/init.rs b/crates/brightstaff/src/tracing/init.rs index ed351148..b9560423 100644 --- a/crates/brightstaff/src/tracing/init.rs +++ b/crates/brightstaff/src/tracing/init.rs @@ -11,8 +11,8 @@ use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::EnvFilter; -use super::ServiceNameOverrideExporter; -use common::configuration::Tracing; +use super::{PostHogExporter, ServiceNameOverrideExporter}; +use common::configuration::{Exporter, PosthogExporter, Tracing}; struct BracketedTime; @@ -90,26 +90,53 @@ pub fn init_tracer(tracing_config: Option<&Tracing>) -> &'static SdkTracerProvid let random_sampling = tracing_config.and_then(|t| t.random_sampling).unwrap_or(0); - let tracing_enabled = random_sampling > 0 && otel_endpoint.is_some(); + // Collect PostHog export destinations from `tracing.exporters`. + let posthog_exporters: Vec = tracing_config + .and_then(|t| t.exporters.as_ref()) + .map(|exporters| { + exporters + .iter() + .map(|Exporter::Posthog(posthog)| posthog.clone()) + .collect() + }) + .unwrap_or_default(); + + // Tracing is enabled when sampling is on and there is at least one + // destination — an OTLP collector and/or a configured exporter. + let has_destination = otel_endpoint.is_some() || !posthog_exporters.is_empty(); + let tracing_enabled = random_sampling > 0 && has_destination; eprintln!( - "initializing tracing: tracing_enabled={}, otel_endpoint={:?}, random_sampling={}", - tracing_enabled, otel_endpoint, random_sampling + "initializing tracing: tracing_enabled={}, otel_endpoint={:?}, random_sampling={}, posthog_exporters={}", + tracing_enabled, otel_endpoint, random_sampling, posthog_exporters.len() ); - // Create OTLP exporter to send spans to collector. - // Use `if let` to destructure the endpoint, avoiding an unwrap. - if let Some(endpoint) = otel_endpoint.as_deref().filter(|_| tracing_enabled) { + if tracing_enabled { if std::env::var("OTEL_SERVICE_NAME").is_err() { std::env::set_var("OTEL_SERVICE_NAME", "plano"); } + + // Compose the tracer provider from all configured destinations. Each + // `with_batch_exporter` registers an independent span processor, so + // every span fans out to the OTLP collector and every exporter. + let mut builder = SdkTracerProvider::builder(); + // Create ServiceNameOverrideExporter to support per-span service names // This allows spans to have different service names (e.g., plano(orchestrator), // plano(filter), plano(llm)) by setting the "service.name.override" attribute - let exporter = ServiceNameOverrideExporter::new(endpoint); + if let Some(endpoint) = otel_endpoint.as_deref() { + builder = builder.with_batch_exporter(ServiceNameOverrideExporter::new(endpoint)); + } - let provider = SdkTracerProvider::builder() - .with_batch_exporter(exporter) - .build(); + // PostHog exporters translate LLM spans into `$ai_generation` events. + for posthog in &posthog_exporters { + builder = builder.with_batch_exporter(PostHogExporter::new( + &posthog.url, + &posthog.api_key, + posthog.capture_messages.unwrap_or(false), + )); + } + + let provider = builder.build(); global::set_tracer_provider(provider.clone()); diff --git a/crates/brightstaff/src/tracing/mod.rs b/crates/brightstaff/src/tracing/mod.rs index 8e09a21c..dac26232 100644 --- a/crates/brightstaff/src/tracing/mod.rs +++ b/crates/brightstaff/src/tracing/mod.rs @@ -1,6 +1,7 @@ mod constants; mod custom_attributes; mod init; +mod posthog_exporter; mod service_name_exporter; pub use constants::{ @@ -8,6 +9,7 @@ pub use constants::{ }; pub use custom_attributes::collect_custom_trace_attributes; pub use init::init_tracer; +pub use posthog_exporter::PostHogExporter; pub use service_name_exporter::{ServiceNameOverrideExporter, SERVICE_NAME_OVERRIDE_KEY}; use opentelemetry::trace::get_active_span; diff --git a/crates/brightstaff/src/tracing/posthog_exporter.rs b/crates/brightstaff/src/tracing/posthog_exporter.rs new file mode 100644 index 00000000..53d3ccef --- /dev/null +++ b/crates/brightstaff/src/tracing/posthog_exporter.rs @@ -0,0 +1,402 @@ +//! PostHog Span Exporter +//! +//! A custom [`SpanExporter`] that translates Plano's LLM spans into PostHog +//! [`$ai_generation`](https://posthog.com/docs/ai-observability/generations) +//! events and POSTs them to PostHog's capture API (`{url}/batch/`). +//! +//! This makes PostHog a first-class, provider-agnostic export target: a user +//! only points `tracing.exporters` at their PostHog URL + project token and +//! every LLM call is captured — mirroring LiteLLM's `posthog` callback. +//! +//! # Behaviour +//! +//! - Receives every span in the provider (like all batch exporters do) and +//! keeps only LLM generation spans, identified by the presence of the +//! [`llm::MODEL_NAME`] (`llm.model`) attribute. +//! - Maps span attributes onto `$ai_*` PostHog properties (model, provider, +//! latency, tokens, http status, ...). +//! - `distinct_id` is read from the [`plano::DISTINCT_ID`] span attribute (set +//! by the LLM handler from the configured `distinct_id_header`). When absent +//! the event is captured anonymously (`$process_person_profile = false`). +//! - Network failures are logged and dropped — telemetry export never blocks or +//! fails request processing. + +use std::time::Duration; + +use opentelemetry::{Array, Value}; +use opentelemetry_sdk::error::OTelSdkResult; +use opentelemetry_sdk::trace::{SpanData, SpanExporter}; +use opentelemetry_sdk::Resource; +use serde_json::{json, Map, Value as JsonValue}; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; + +use super::{http, llm, plano}; + +/// PostHog event name for an individual LLM call. +const AI_GENERATION_EVENT: &str = "$ai_generation"; + +/// PostHog capture path appended to the configured host. +const CAPTURE_PATH: &str = "batch/"; + +/// A [`SpanExporter`] that ships LLM spans to PostHog as `$ai_generation` events. +pub struct PostHogExporter { + client: reqwest::Client, + /// Fully-qualified capture endpoint, e.g. `https://us.i.posthog.com/batch/`. + endpoint: String, + /// PostHog project API key (token). + api_key: String, + /// Whether to attach the truncated user message preview as `$ai_input`. + capture_messages: bool, +} + +impl std::fmt::Debug for PostHogExporter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PostHogExporter") + .field("endpoint", &self.endpoint) + .field("capture_messages", &self.capture_messages) + .finish() + } +} + +impl PostHogExporter { + /// Create a new PostHog exporter. + /// + /// # Arguments + /// * `url` – PostHog host (e.g. `https://us.i.posthog.com`). The `/batch/` + /// capture path is appended automatically. + /// * `api_key` – PostHog project API key (token). + /// * `capture_messages` – when true, send the user message preview as + /// `$ai_input`. + pub fn new(url: &str, api_key: &str, capture_messages: bool) -> Self { + let endpoint = format!("{}/{}", url.trim_end_matches('/'), CAPTURE_PATH); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap_or_default(); + Self { + client, + endpoint, + api_key: api_key.to_string(), + capture_messages, + } + } + + /// Build the PostHog `batch` payload from a batch of spans, keeping only LLM + /// generation spans. Returns `None` when no LLM spans are present. + fn build_payload(&self, batch: &[SpanData]) -> Option { + let events: Vec = batch + .iter() + .filter_map(|span| self.build_generation_event(span)) + .collect(); + + if events.is_empty() { + return None; + } + + Some(json!({ + "api_key": self.api_key, + "batch": events, + })) + } + + /// Translate a single span into a PostHog `$ai_generation` event, or `None` + /// if the span is not an LLM generation span. + fn build_generation_event(&self, span: &SpanData) -> Option { + // Only LLM generation spans carry `llm.model`. + let model = find_attr(span, llm::MODEL_NAME)?; + + let mut props = Map::new(); + props.insert("$ai_model".to_string(), otel_value_to_json(model)); + props.insert( + "$ai_trace_id".to_string(), + json!(span.span_context.trace_id().to_string()), + ); + if span.parent_span_id != opentelemetry::trace::SpanId::INVALID { + props.insert( + "$ai_parent_id".to_string(), + json!(span.parent_span_id.to_string()), + ); + } + + if let Some(provider) = find_attr(span, llm::PROVIDER) { + props.insert("$ai_provider".to_string(), otel_value_to_json(provider)); + } + + // Latency / TTFT are stored in milliseconds; PostHog wants seconds. + if let Some(ms) = find_i64(span, llm::DURATION_MS) { + props.insert("$ai_latency".to_string(), json!(ms as f64 / 1000.0)); + } + if let Some(ms) = find_i64(span, llm::TIME_TO_FIRST_TOKEN_MS) { + props.insert( + "$ai_time_to_first_token".to_string(), + json!(ms as f64 / 1000.0), + ); + props.insert("$ai_stream".to_string(), json!(true)); + } + + if let Some(tokens) = find_i64(span, llm::PROMPT_TOKENS) { + props.insert("$ai_input_tokens".to_string(), json!(tokens)); + } + if let Some(tokens) = find_i64(span, llm::COMPLETION_TOKENS) { + props.insert("$ai_output_tokens".to_string(), json!(tokens)); + } + + if let Some(status) = find_i64(span, http::STATUS_CODE) { + props.insert("$ai_http_status".to_string(), json!(status)); + if status >= 400 { + props.insert("$ai_is_error".to_string(), json!(true)); + } + } + + if self.capture_messages { + if let Some(preview) = find_attr(span, llm::USER_MESSAGE_PREVIEW) { + props.insert( + "$ai_input".to_string(), + json!([{ "role": "user", "content": value_to_string(preview) }]), + ); + } + } + + // distinct_id: identified when the configured header was present, + // otherwise anonymous (do not create/update a person profile). + match find_attr(span, plano::DISTINCT_ID) { + Some(id) => { + props.insert("distinct_id".to_string(), otel_value_to_json(id)); + } + None => { + props.insert( + "distinct_id".to_string(), + json!(span.span_context.trace_id().to_string()), + ); + props.insert("$process_person_profile".to_string(), json!(false)); + } + } + + // Pass through any other non-reserved attributes (custom span attributes + // such as static tags or header-derived tenant ids) as plain properties. + for kv in span.attributes.iter() { + let key = kv.key.as_str(); + if is_reserved_attr(key) { + continue; + } + props + .entry(key.to_string()) + .or_insert_with(|| otel_value_to_json(&kv.value)); + } + + let mut event = Map::new(); + event.insert("event".to_string(), json!(AI_GENERATION_EVENT)); + event.insert("properties".to_string(), JsonValue::Object(props)); + if let Ok(ts) = OffsetDateTime::from(span.end_time).format(&Rfc3339) { + event.insert("timestamp".to_string(), json!(ts)); + } + + Some(JsonValue::Object(event)) + } +} + +impl SpanExporter for PostHogExporter { + fn export( + &self, + batch: Vec, + ) -> impl std::future::Future + Send { + let payload = self.build_payload(&batch); + let client = self.client.clone(); + let endpoint = self.endpoint.clone(); + async move { + let Some(payload) = payload else { + return Ok(()); + }; + match client.post(&endpoint).json(&payload).send().await { + Ok(resp) if resp.status().is_success() => {} + Ok(resp) => { + tracing::warn!( + status = %resp.status(), + endpoint = %endpoint, + "PostHog exporter: non-success response" + ); + } + Err(e) => { + tracing::warn!(error = ?e, endpoint = %endpoint, "PostHog exporter: request failed"); + } + } + Ok(()) + } + } + + fn shutdown_with_timeout(&mut self, _timeout: Duration) -> OTelSdkResult { + Ok(()) + } + + fn set_resource(&mut self, _resource: &Resource) {} +} + +/// Span attributes that are mapped to dedicated `$ai_*` properties (or are +/// internal plumbing) and should not be duplicated as raw properties. +fn is_reserved_attr(key: &str) -> bool { + matches!( + key, + k if k == llm::MODEL_NAME + || k == llm::PROVIDER + || k == llm::DURATION_MS + || k == llm::TIME_TO_FIRST_TOKEN_MS + || k == llm::PROMPT_TOKENS + || k == llm::COMPLETION_TOKENS + || k == llm::USER_MESSAGE_PREVIEW + || k == http::STATUS_CODE + || k == plano::DISTINCT_ID + || k == super::SERVICE_NAME_OVERRIDE_KEY + ) +} + +fn find_attr<'a>(span: &'a SpanData, key: &str) -> Option<&'a Value> { + span.attributes + .iter() + .find(|kv| kv.key.as_str() == key) + .map(|kv| &kv.value) +} + +fn find_i64(span: &SpanData, key: &str) -> Option { + match find_attr(span, key)? { + Value::I64(i) => Some(*i), + _ => None, + } +} + +fn value_to_string(value: &Value) -> String { + match value { + Value::String(s) => s.as_str().to_string(), + other => other.to_string(), + } +} + +fn otel_value_to_json(value: &Value) -> JsonValue { + match value { + Value::Bool(b) => json!(b), + Value::I64(i) => json!(i), + Value::F64(f) => json!(f), + Value::String(s) => json!(s.as_str()), + Value::Array(arr) => match arr { + Array::Bool(v) => json!(v), + Array::I64(v) => json!(v), + Array::F64(v) => json!(v), + Array::String(v) => json!(v.iter().map(|s| s.as_str()).collect::>()), + _ => JsonValue::Null, + }, + _ => json!(value.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use opentelemetry::trace::{ + SpanContext, SpanId, SpanKind, Status, TraceFlags, TraceId, TraceState, + }; + use opentelemetry::KeyValue; + use opentelemetry_sdk::trace::{SpanData, SpanEvents, SpanLinks}; + use std::borrow::Cow; + use std::time::SystemTime; + + fn span_with_attrs(attrs: Vec) -> SpanData { + SpanData { + span_context: SpanContext::new( + TraceId::from_bytes([ + 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, + 0xbc, 0xde, 0xf0, + ]), + SpanId::from_bytes([0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]), + TraceFlags::SAMPLED, + false, + TraceState::default(), + ), + parent_span_id: SpanId::INVALID, + parent_span_is_remote: false, + span_kind: SpanKind::Client, + name: Cow::Borrowed("llm"), + start_time: SystemTime::UNIX_EPOCH, + end_time: SystemTime::UNIX_EPOCH, + attributes: attrs, + dropped_attributes_count: 0, + events: SpanEvents::default(), + links: SpanLinks::default(), + status: Status::Unset, + instrumentation_scope: Default::default(), + } + } + + fn props(event: &JsonValue) -> &Map { + event["properties"].as_object().unwrap() + } + + #[test] + fn non_llm_span_is_skipped() { + let exporter = PostHogExporter::new("https://us.i.posthog.com", "phc_x", false); + let span = span_with_attrs(vec![KeyValue::new("routing.strategy", "least-latency")]); + assert!(exporter.build_generation_event(&span).is_none()); + } + + #[test] + fn maps_llm_attributes_to_ai_properties() { + let exporter = PostHogExporter::new("https://us.i.posthog.com/", "phc_x", false); + let span = span_with_attrs(vec![ + KeyValue::new(llm::MODEL_NAME, "gpt-5-mini"), + KeyValue::new(llm::PROVIDER, "openai"), + KeyValue::new(llm::DURATION_MS, 1500_i64), + KeyValue::new(llm::TIME_TO_FIRST_TOKEN_MS, 250_i64), + KeyValue::new(llm::PROMPT_TOKENS, 10_i64), + KeyValue::new(llm::COMPLETION_TOKENS, 20_i64), + KeyValue::new(http::STATUS_CODE, 200_i64), + KeyValue::new("tenant.id", "acme"), + ]); + + let event = exporter.build_generation_event(&span).unwrap(); + assert_eq!(event["event"], json!("$ai_generation")); + let p = props(&event); + assert_eq!(p["$ai_model"], json!("gpt-5-mini")); + assert_eq!(p["$ai_provider"], json!("openai")); + assert_eq!(p["$ai_latency"], json!(1.5)); + assert_eq!(p["$ai_time_to_first_token"], json!(0.25)); + assert_eq!(p["$ai_stream"], json!(true)); + assert_eq!(p["$ai_input_tokens"], json!(10)); + assert_eq!(p["$ai_output_tokens"], json!(20)); + assert_eq!(p["$ai_http_status"], json!(200)); + // Anonymous (no distinct id header captured). + assert_eq!(p["$process_person_profile"], json!(false)); + // Custom passthrough attribute preserved. + assert_eq!(p["tenant.id"], json!("acme")); + // No $ai_input unless capture_messages is enabled. + assert!(!p.contains_key("$ai_input")); + } + + #[test] + fn uses_distinct_id_and_flags_errors() { + let exporter = PostHogExporter::new("https://us.i.posthog.com", "phc_x", true); + let span = span_with_attrs(vec![ + KeyValue::new(llm::MODEL_NAME, "gpt-5-mini"), + KeyValue::new(plano::DISTINCT_ID, "user_123"), + KeyValue::new(llm::USER_MESSAGE_PREVIEW, "hello"), + KeyValue::new(http::STATUS_CODE, 500_i64), + ]); + + let event = exporter.build_generation_event(&span).unwrap(); + let p = props(&event); + assert_eq!(p["distinct_id"], json!("user_123")); + assert!(!p.contains_key("$process_person_profile")); + assert_eq!(p["$ai_is_error"], json!(true)); + assert_eq!( + p["$ai_input"], + json!([{ "role": "user", "content": "hello" }]) + ); + } + + #[test] + fn payload_wraps_events_with_api_key() { + let exporter = PostHogExporter::new("https://us.i.posthog.com", "phc_secret", false); + let span = span_with_attrs(vec![KeyValue::new(llm::MODEL_NAME, "gpt-5-mini")]); + let payload = exporter.build_payload(&[span]).unwrap(); + assert_eq!(payload["api_key"], json!("phc_secret")); + assert_eq!(payload["batch"].as_array().unwrap().len(), 1); + } +} diff --git a/crates/common/src/api/open_ai.rs b/crates/common/src/api/open_ai.rs index 6569b9ec..d5fbadfc 100644 --- a/crates/common/src/api/open_ai.rs +++ b/crates/common/src/api/open_ai.rs @@ -53,7 +53,7 @@ impl Serialize for FunctionParameters { where S: serde::Serializer, { - // select all requried parameters + // select all required parameters let required: Vec<&String> = self .properties .iter() diff --git a/crates/common/src/configuration.rs b/crates/common/src/configuration.rs index 37492904..ceea57b3 100644 --- a/crates/common/src/configuration.rs +++ b/crates/common/src/configuration.rs @@ -177,8 +177,13 @@ pub enum MetricsSource { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CostMetricsConfig { pub provider: CostProvider, + /// Optional override for the pricing catalog endpoint. When omitted, a + /// sensible default is used per provider. + pub url: Option, pub refresh_interval: Option, - /// Map DO catalog keys (`lowercase(creator)/model_id`) to Plano model names. + /// Map catalog keys to Plano model names used in `routing_preferences`. + /// DigitalOcean keys look like `lowercase(creator)/model_id`; models.dev + /// keys look like `creator/model_id`. /// Example: `openai/openai-gpt-oss-120b: openai/gpt-4o` pub model_aliases: Option>, } @@ -187,6 +192,8 @@ pub struct CostMetricsConfig { #[serde(rename_all = "snake_case")] pub enum CostProvider { Digitalocean, + #[serde(rename = "models.dev")] + ModelsDev, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -244,6 +251,11 @@ pub struct Tracing { pub random_sampling: Option, pub opentracing_grpc_endpoint: Option, pub span_attributes: Option, + /// Provider-agnostic telemetry export destinations. Each entry is tagged by + /// its `type` (e.g. `posthog`) so new backends can be added without breaking + /// existing configs. LLM spans are translated into each backend's native + /// event format and streamed in addition to any `opentracing_grpc_endpoint`. + pub exporters: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -253,6 +265,36 @@ pub struct SpanAttributes { pub static_attributes: Option>, } +/// A telemetry export destination configured under `tracing.exporters`. +/// +/// The list is provider-agnostic; each variant is internally tagged by its +/// `type` field (e.g. `type: posthog`). Additional backends (datadog, raw +/// otlp, ...) can be added as new variants without breaking existing configs. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Exporter { + /// PostHog AI observability. LLM spans are converted into PostHog + /// `$ai_generation` events and POSTed to the configured `url`. + Posthog(PosthogExporter), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PosthogExporter { + /// PostHog host, e.g. `https://us.i.posthog.com`. The `/batch/` capture + /// path is appended automatically. + pub url: String, + /// PostHog project API key (token). Supports `$ENV_VAR` expansion at render + /// time, e.g. `$POSTHOG_API_KEY`. + pub api_key: String, + /// Optional request header whose value is used as the PostHog `distinct_id`. + /// When unset (or the header is missing on a request) events are captured + /// anonymously. + pub distinct_id_header: Option, + /// When true, include the truncated user message preview as `$ai_input`. + /// Defaults to `false` to avoid sending prompt content off-box. + pub capture_messages: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] pub enum GatewayMode { #[serde(rename = "llm")] @@ -400,6 +442,10 @@ pub enum LlmProviderType { Vercel, #[serde(rename = "openrouter")] OpenRouter, + #[serde(rename = "astraflow")] + Astraflow, + #[serde(rename = "astraflow_cn")] + AstraflowCN, } impl Display for LlmProviderType { @@ -425,6 +471,8 @@ impl Display for LlmProviderType { LlmProviderType::DigitalOcean => write!(f, "digitalocean"), LlmProviderType::Vercel => write!(f, "vercel"), LlmProviderType::OpenRouter => write!(f, "openrouter"), + LlmProviderType::Astraflow => write!(f, "astraflow"), + LlmProviderType::AstraflowCN => write!(f, "astraflow_cn"), } } } @@ -735,6 +783,51 @@ mod test { } } + #[test] + fn test_deserialize_models_dev_cost_source() { + let yaml = r#" +- type: cost + provider: models.dev + url: https://models.dev/api.json + refresh_interval: 3600 + model_aliases: + openai/gpt-oss-120b: openai/gpt-4o +"#; + let sources: Vec = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(sources.len(), 1); + match &sources[0] { + super::MetricsSource::Cost(cfg) => { + assert!(matches!(cfg.provider, super::CostProvider::ModelsDev)); + assert_eq!(cfg.url.as_deref(), Some("https://models.dev/api.json")); + assert_eq!(cfg.refresh_interval, Some(3600)); + assert_eq!( + cfg.model_aliases + .as_ref() + .and_then(|m| m.get("openai/gpt-oss-120b")) + .map(String::as_str), + Some("openai/gpt-4o") + ); + } + other => panic!("expected cost source, got {other:?}"), + } + } + + #[test] + fn test_deserialize_digitalocean_cost_source_without_url() { + let yaml = r#" +- type: cost + provider: digitalocean +"#; + let sources: Vec = serde_yaml::from_str(yaml).unwrap(); + match &sources[0] { + super::MetricsSource::Cost(cfg) => { + assert!(matches!(cfg.provider, super::CostProvider::Digitalocean)); + assert_eq!(cfg.url, None); + } + other => panic!("expected cost source, got {other:?}"), + } + } + #[test] fn test_into_models_filters_internal_providers() { let providers = vec![ @@ -807,4 +900,47 @@ disable_signals: false let overrides: super::Overrides = serde_yaml::from_str(yaml_missing).unwrap(); assert_eq!(overrides.disable_signals, None); } + + #[test] + fn test_tracing_posthog_exporter_deserialize() { + let yaml = r#" +random_sampling: 100 +exporters: + - type: posthog + url: https://us.i.posthog.com + api_key: phc_secret + distinct_id_header: x-user-id + capture_messages: true +"#; + let tracing: super::Tracing = serde_yaml::from_str(yaml).unwrap(); + let exporters = tracing.exporters.expect("exporters should be parsed"); + assert_eq!(exporters.len(), 1); + match &exporters[0] { + super::Exporter::Posthog(posthog) => { + assert_eq!(posthog.url, "https://us.i.posthog.com"); + assert_eq!(posthog.api_key, "phc_secret"); + assert_eq!(posthog.distinct_id_header.as_deref(), Some("x-user-id")); + assert_eq!(posthog.capture_messages, Some(true)); + } + } + } + + #[test] + fn test_tracing_posthog_exporter_minimal() { + let yaml = r#" +exporters: + - type: posthog + url: https://eu.i.posthog.com + api_key: phc_eu +"#; + let tracing: super::Tracing = serde_yaml::from_str(yaml).unwrap(); + let exporters = tracing.exporters.unwrap(); + match &exporters[0] { + super::Exporter::Posthog(posthog) => { + assert_eq!(posthog.url, "https://eu.i.posthog.com"); + assert_eq!(posthog.distinct_id_header, None); + assert_eq!(posthog.capture_messages, None); + } + } + } } diff --git a/crates/hermesllm/src/apis/anthropic.rs b/crates/hermesllm/src/apis/anthropic.rs index ee572268..cfde591d 100644 --- a/crates/hermesllm/src/apis/anthropic.rs +++ b/crates/hermesllm/src/apis/anthropic.rs @@ -128,6 +128,7 @@ pub struct MessagesRequest { pub enum MessagesRole { User, Assistant, + System, } /// Cache control types for content blocks @@ -632,6 +633,7 @@ impl MessagesRole { match self { MessagesRole::User => "user", MessagesRole::Assistant => "assistant", + MessagesRole::System => "system", } } } diff --git a/crates/hermesllm/src/apis/openai.rs b/crates/hermesllm/src/apis/openai.rs index bb93fd34..8e66f0ad 100644 --- a/crates/hermesllm/src/apis/openai.rs +++ b/crates/hermesllm/src/apis/openai.rs @@ -1,3 +1,4 @@ +use log::warn; use serde::{Deserialize, Serialize}; use serde_json::Value; use serde_with::skip_serializing_none; @@ -136,6 +137,37 @@ impl ChatCompletionsRequest { self.temperature = Some(1.0); } } + + /// Strip request fields that Kimi Code API (`kimi-for-coding`) rejects or mishandles. + pub fn normalize_for_kimi_code_api(&mut self) { + if self.stream_options.is_some() { + warn!("kimi-for-coding: stripping unsupported stream_options from upstream request"); + self.stream_options = None; + } + if self.reasoning_effort.is_some() { + warn!("kimi-for-coding: stripping unsupported reasoning_effort from upstream request"); + self.reasoning_effort = None; + } + if self.web_search_options.is_some() { + warn!( + "kimi-for-coding: stripping unsupported web_search_options from upstream request" + ); + self.web_search_options = None; + } + if self.service_tier.is_some() { + warn!("kimi-for-coding: stripping unsupported service_tier from upstream request"); + self.service_tier = None; + } + if self.store.is_some() { + warn!("kimi-for-coding: stripping unsupported store from upstream request"); + self.store = None; + } + } +} + +/// True when the upstream model id is Moonshot's Kimi Code endpoint model. +pub fn is_kimi_code_model(model: &str) -> bool { + model == "kimi-for-coding" } // ============================================================================ diff --git a/crates/hermesllm/src/apis/openai_responses.rs b/crates/hermesllm/src/apis/openai_responses.rs index 92d362b2..af37688e 100644 --- a/crates/hermesllm/src/apis/openai_responses.rs +++ b/crates/hermesllm/src/apis/openai_responses.rs @@ -183,9 +183,13 @@ pub enum MessageRole { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum InputContent { - /// Text input - #[serde(rename = "input_text", alias = "text", alias = "output_text")] + /// Text input (input-role message content) + #[serde(rename = "input_text", alias = "text")] InputText { text: String }, + /// Text produced by the model in a prior turn. This must round-trip as + /// `output_text` because the Responses API rejects `input_text` for + /// output-role (assistant) message content. + OutputText { text: String }, /// Image input via URL InputImage { image_url: String, @@ -1051,6 +1055,7 @@ pub struct ListInputItemsResponse { fn append_input_content_text(buffer: &mut String, content: &InputContent) { match content { InputContent::InputText { text } => buffer.push_str(text), + InputContent::OutputText { text } => buffer.push_str(text), InputContent::InputImage { .. } => buffer.push_str("[Image]"), InputContent::InputFile { .. } => buffer.push_str("[File]"), InputContent::InputAudio { .. } => buffer.push_str("[Audio]"), @@ -1642,6 +1647,62 @@ mod tests { } } + #[test] + fn test_input_content_preserves_output_text_round_trip() { + // Multi-turn request: a user turn carrying input_text and a prior + // assistant turn carrying output_text. The Responses API rejects + // input_text for output-role content, so the assistant turn must + // survive a serialize round-trip as output_text (not be rewritten). + let request = json!({ + "model": "gpt-5.3-codex", + "input": [ + { + "role": "user", + "content": [ + { "type": "input_text", "text": "hello" } + ] + }, + { + "role": "assistant", + "content": [ + { "type": "output_text", "text": "hi there" } + ] + } + ] + }); + + let bytes = serde_json::to_vec(&request).unwrap(); + let parsed = ResponsesAPIRequest::try_from(bytes.as_slice()).unwrap(); + + let items = match &parsed.input { + InputParam::Items(items) => items, + _ => panic!("expected array input"), + }; + assert_eq!(items.len(), 2); + + // Assistant output_text must deserialize into the OutputText variant. + let assistant = items + .iter() + .find_map(|item| match item { + InputItem::Message(msg) if matches!(msg.role, MessageRole::Assistant) => Some(msg), + _ => None, + }) + .expect("assistant message present"); + match &assistant.content { + MessageContent::Items(contents) => { + assert!(matches!(contents[0], InputContent::OutputText { .. })); + } + _ => panic!("expected array content"), + } + + // Round-trip serialize and assert the type tags are preserved: + // user content stays input_text, assistant content stays output_text. + let serialized = serde_json::to_value(&parsed).unwrap(); + let input = &serialized["input"]; + assert_eq!(input[0]["content"][0]["type"], "input_text"); + assert_eq!(input[1]["content"][0]["type"], "output_text"); + } + #[test] fn test_request_deserializes_text_config_without_format() { let request = json!({ diff --git a/crates/hermesllm/src/bin/fetch_models.rs b/crates/hermesllm/src/bin/fetch_models.rs index 575fe38d..f08f546a 100644 --- a/crates/hermesllm/src/bin/fetch_models.rs +++ b/crates/hermesllm/src/bin/fetch_models.rs @@ -1,12 +1,22 @@ -// Fetch latest provider models from canonical provider APIs and update provider_models.yaml +// Fetch latest provider models from canonical provider APIs and merge into +// provider_models.yaml. +// +// Behavior is non-destructive: only providers we successfully fetch this run +// are replaced. Providers whose API key is missing, or whose fetch fails, are +// left untouched in the existing file. This means partial runs (e.g. without +// AWS or Google creds) can't accidentally wipe out provider entries you don't +// have keys for locally. +// // Usage: -// Optional: OPENAI_API_KEY, ANTHROPIC_API_KEY, DEEPSEEK_API_KEY, GROK_API_KEY, -// DASHSCOPE_API_KEY, MOONSHOT_API_KEY, ZHIPU_API_KEY, GOOGLE_API_KEY -// Required: AWS CLI configured for Amazon Bedrock models -// cargo run --bin fetch_models +// Optional: OPENAI_API_KEY, ANTHROPIC_API_KEY, MISTRAL_API_KEY, +// DEEPSEEK_API_KEY, GROK_API_KEY, DASHSCOPE_API_KEY, +// MOONSHOT_API_KEY, ZHIPU_API_KEY, MIMO_API_KEY, GOOGLE_API_KEY, +// META_MODELS_API_KEY +// Optional: AWS CLI configured for Amazon Bedrock models +// cargo run --bin fetch_models --features model-fetch use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::BTreeMap; fn main() { // Default to writing in the same directory as this source file @@ -19,16 +29,33 @@ fn main() { .nth(1) .unwrap_or_else(|| default_path.to_string_lossy().to_string()); - println!("Fetching latest models from provider APIs..."); + println!("Loading existing {}...", output_path); + let existing = match load_existing_models(&output_path) { + Ok(map) => { + if map.is_empty() { + println!(" (none — starting fresh)"); + } else { + println!(" loaded {} existing providers", map.len()); + } + map + } + Err(e) => { + eprintln!("Error loading existing {}: {}", output_path, e); + eprintln!("Refusing to overwrite a file we can't parse. Fix or delete it and re-run."); + std::process::exit(1); + } + }; - match fetch_all_models() { + println!("\nFetching latest models from provider APIs..."); + + match fetch_all_models(existing) { Ok(models) => { let yaml = serde_yaml::to_string(&models).expect("Failed to serialize models"); std::fs::write(&output_path, yaml).expect("Failed to write provider_models.yaml"); println!( - "✓ Successfully updated {} providers ({} models) to {}", + "✓ Wrote {} providers ({} models) to {}", models.metadata.total_providers, models.metadata.total_models, output_path ); } @@ -44,6 +71,18 @@ fn main() { } } +fn load_existing_models( + path: &str, +) -> Result>, Box> { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(e) => return Err(Box::new(e)), + }; + let parsed: ProviderModels = serde_yaml::from_str(&content)?; + Ok(parsed.providers) +} + // OpenAI-compatible API response (used by most providers) #[derive(Debug, Deserialize)] struct OpenAICompatibleModel { @@ -68,21 +107,36 @@ struct GoogleResponse { models: Vec, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] struct ProviderModels { + #[serde(default = "default_version")] version: String, + #[serde(default = "default_source")] source: String, - providers: HashMap>, + #[serde(default)] + providers: BTreeMap>, + #[serde(default)] metadata: Metadata, } -#[derive(Debug, Serialize)] +#[derive(Debug, Default, Serialize, Deserialize)] struct Metadata { + #[serde(default)] total_providers: usize, + #[serde(default)] total_models: usize, + #[serde(default)] last_updated: String, } +fn default_version() -> String { + "1.0".to_string() +} + +fn default_source() -> String { + "canonical-apis".to_string() +} + fn is_text_model(model_id: &str) -> bool { let id_lower = model_id.to_lowercase(); @@ -273,8 +327,13 @@ fn fetch_bedrock_amazon_models() -> Result, Box Result> { - let mut providers: HashMap> = HashMap::new(); +fn fetch_all_models( + existing: BTreeMap>, +) -> Result> { + let mut providers = existing; + let mut updated: Vec = Vec::new(); + let mut skipped: Vec = Vec::new(); + let mut failed: Vec = Vec::new(); let mut errors: Vec = Vec::new(); // Configuration: provider name, env var, API URL, prefix for model IDs @@ -322,92 +381,139 @@ fn fetch_all_models() -> Result> { "https://api.xiaomimimo.com/v1/models", "xiaomi", ), + ( + "meta", + "META_MODELS_API_KEY", + "https://api.meta.ai/v1/models", + "meta", + ), ]; + // Helper that records the outcome of a fetch attempt and only mutates + // `providers` on success, so missing/failed providers keep their existing + // entries (or stay absent if there were none). + let mut record = + |name: &str, + env_var: Option<&str>, + result: Option, Box>>, + providers: &mut BTreeMap>| match result { + Some(Ok(models)) => { + println!(" ✓ {}: {} models", name, models.len()); + providers.insert(name.to_string(), models); + updated.push(name.to_string()); + } + Some(Err(e)) => { + let kept = providers + .get(name) + .map(|v| format!(" (keeping existing {} models)", v.len())) + .unwrap_or_default(); + let err_msg = format!(" ✗ {}: {}{}", name, e, kept); + eprintln!("{}", err_msg); + errors.push(err_msg); + failed.push(name.to_string()); + } + None => { + let kept = providers + .get(name) + .map(|v| format!(" (keeping existing {} models)", v.len())) + .unwrap_or_else(|| " (no existing entry)".to_string()); + let label = env_var + .map(|v| format!("{} not set", v)) + .unwrap_or_else(|| "no credentials".to_string()); + println!(" ⊘ {}: {}{}", name, label, kept); + skipped.push(name.to_string()); + } + }; + // Fetch from OpenAI-compatible providers for (provider_name, env_var, api_url, prefix) in provider_configs { - if let Ok(api_key) = std::env::var(env_var) { - match fetch_openai_compatible_models(api_url, &api_key, prefix) { - Ok(models) => { - println!(" ✓ {}: {} models", provider_name, models.len()); - providers.insert(provider_name.to_string(), models); - } - Err(e) => { - let err_msg = format!(" ✗ {}: {}", provider_name, e); - eprintln!("{}", err_msg); - errors.push(err_msg); - } - } - } else { - println!(" ⊘ {}: {} not set (skipped)", provider_name, env_var); - } + let result = std::env::var(env_var) + .ok() + .map(|api_key| fetch_openai_compatible_models(api_url, &api_key, prefix)); + record(provider_name, Some(env_var), result, &mut providers); } // Fetch Anthropic models (different authentication) - if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") { - match fetch_anthropic_models(&api_key) { - Ok(models) => { - println!(" ✓ anthropic: {} models", models.len()); - providers.insert("anthropic".to_string(), models); - } - Err(e) => { - let err_msg = format!(" ✗ anthropic: {}", e); - eprintln!("{}", err_msg); - errors.push(err_msg); - } - } - } else { - println!(" ⊘ anthropic: ANTHROPIC_API_KEY not set (skipped)"); - } + let anthropic_result = std::env::var("ANTHROPIC_API_KEY") + .ok() + .map(|key| fetch_anthropic_models(&key)); + record( + "anthropic", + Some("ANTHROPIC_API_KEY"), + anthropic_result, + &mut providers, + ); // Fetch Google models (different API format) - if let Ok(api_key) = std::env::var("GOOGLE_API_KEY") { - match fetch_google_models(&api_key) { - Ok(models) => { - println!(" ✓ google: {} models", models.len()); - providers.insert("google".to_string(), models); - } - Err(e) => { - let err_msg = format!(" ✗ google: {}", e); - eprintln!("{}", err_msg); - errors.push(err_msg); - } - } - } else { - println!(" ⊘ google: GOOGLE_API_KEY not set (skipped)"); - } + let google_result = std::env::var("GOOGLE_API_KEY") + .ok() + .map(|key| fetch_google_models(&key)); + record( + "google", + Some("GOOGLE_API_KEY"), + google_result, + &mut providers, + ); - // Fetch Amazon models from AWS Bedrock - match fetch_bedrock_amazon_models() { - Ok(models) => { - println!(" ✓ amazon: {} models (via AWS Bedrock)", models.len()); - providers.insert("amazon".to_string(), models); - } - Err(e) => { - let err_msg = format!(" ✗ amazon: {} (AWS Bedrock required)", e); - eprintln!("{}", err_msg); - errors.push(err_msg); - } - } + // Fetch Amazon models from AWS Bedrock. Only attempt if the AWS CLI is on + // PATH and any AWS credential is configured — otherwise treat as skipped + // so we don't drop the existing amazon entry on machines / CI runs without + // Bedrock access. + let amazon_result = if aws_credentials_available() { + Some(fetch_bedrock_amazon_models()) + } else { + None + }; + record( + "amazon", + Some("AWS credentials"), + amazon_result, + &mut providers, + ); if providers.is_empty() { - return Err("No models fetched from any provider. Check API keys.".into()); + return Err( + "No existing data and no models fetched. Set at least one API key and re-run.".into(), + ); } let total_providers = providers.len(); let total_models: usize = providers.values().map(|v| v.len()).sum(); + println!("\nSummary:"); println!( - "\n✅ Successfully fetched models from {} providers", - total_providers + " updated: {} ({})", + updated.len(), + if updated.is_empty() { + "none".to_string() + } else { + updated.join(", ") + } ); - if !errors.is_empty() { - println!("⚠️ {} providers failed", errors.len()); + println!( + " skipped (kept existing): {} ({})", + skipped.len(), + if skipped.is_empty() { + "none".to_string() + } else { + skipped.join(", ") + } + ); + if !failed.is_empty() { + println!( + " failed (kept existing): {} ({})", + failed.len(), + failed.join(", ") + ); } + println!( + "✅ Final state: {} providers, {} models", + total_providers, total_models + ); Ok(ProviderModels { - version: "1.0".to_string(), - source: "canonical-apis".to_string(), + version: default_version(), + source: default_source(), providers, metadata: Metadata { total_providers, @@ -416,3 +522,10 @@ fn fetch_all_models() -> Result> { }, }) } + +fn aws_credentials_available() -> bool { + std::env::var("AWS_ACCESS_KEY_ID").is_ok() + || std::env::var("AWS_PROFILE").is_ok() + || std::env::var("AWS_SESSION_TOKEN").is_ok() + || std::env::var("AWS_WEB_IDENTITY_TOKEN_FILE").is_ok() +} diff --git a/crates/hermesllm/src/bin/provider_models.yaml b/crates/hermesllm/src/bin/provider_models.yaml index 2e9e0a9b..1df07320 100644 --- a/crates/hermesllm/src/bin/provider_models.yaml +++ b/crates/hermesllm/src/bin/provider_models.yaml @@ -13,89 +13,12 @@ providers: - amazon/amazon.nova-premier-v1:0 - amazon/amazon.nova-lite-v1:0 - amazon/amazon.nova-micro-v1:0 - google: - - google/gemini-2.5-flash - - google/gemini-2.5-pro - - google/gemini-2.0-flash - - google/gemini-2.0-flash-001 - - google/gemini-2.0-flash-lite-001 - - google/gemini-2.0-flash-lite - - google/gemini-2.5-flash-preview-tts - - google/gemini-2.5-pro-preview-tts - - google/gemma-3-1b-it - - google/gemma-3-4b-it - - google/gemma-3-12b-it - - google/gemma-3-27b-it - - google/gemma-3n-e4b-it - - google/gemma-3n-e2b-it - - google/gemma-4-26b-a4b-it - - google/gemma-4-31b-it - - google/gemini-flash-latest - - google/gemini-flash-lite-latest - - google/gemini-pro-latest - - google/gemini-2.5-flash-lite - - google/gemini-2.5-flash-image - - google/gemini-3-pro-preview - - google/gemini-3-flash-preview - - google/gemini-3.1-pro-preview - - google/gemini-3.1-pro-preview-customtools - - google/gemini-3.1-flash-lite-preview - - google/gemini-3-pro-image-preview - - google/nano-banana-pro-preview - - google/gemini-3.1-flash-image-preview - - google/lyria-3-clip-preview - - google/lyria-3-pro-preview - - google/gemini-robotics-er-1.5-preview - - google/gemini-2.5-computer-use-preview-10-2025 - - google/deep-research-pro-preview-12-2025 - mistralai: - - mistralai/mistral-medium-2505 - - mistralai/mistral-medium-2508 - - mistralai/mistral-medium-latest - - mistralai/mistral-medium - - mistralai/mistral-vibe-cli-with-tools - - mistralai/open-mistral-nemo - - mistralai/open-mistral-nemo-2407 - - mistralai/mistral-tiny-2407 - - mistralai/mistral-tiny-latest - - mistralai/codestral-2508 - - mistralai/codestral-latest - - mistralai/devstral-2512 - - mistralai/mistral-vibe-cli-latest - - mistralai/devstral-medium-latest - - mistralai/devstral-latest - - mistralai/mistral-small-2603 - - mistralai/mistral-small-latest - - mistralai/mistral-vibe-cli-fast - - mistralai/mistral-small-2506 - - mistralai/magistral-medium-2509 - - mistralai/magistral-medium-latest - - mistralai/magistral-small-2509 - - mistralai/magistral-small-latest - - mistralai/labs-leanstral-2603 - - mistralai/mistral-large-2512 - - mistralai/mistral-large-latest - - mistralai/ministral-3b-2512 - - mistralai/ministral-3b-latest - - mistralai/ministral-8b-2512 - - mistralai/ministral-8b-latest - - mistralai/ministral-14b-2512 - - mistralai/ministral-14b-latest - - mistralai/mistral-large-2411 - - mistralai/pixtral-large-2411 - - mistralai/pixtral-large-latest - - mistralai/mistral-large-pixtral-2411 - - mistralai/devstral-small-2507 - - mistralai/devstral-medium-2507 - - mistralai/labs-mistral-small-creative - - mistralai/mistral-embed-2312 - - mistralai/mistral-embed - - mistralai/codestral-embed - - mistralai/codestral-embed-2505 anthropic: + - anthropic/claude-fable-5 + - anthropic/claude-opus-4-8 + - anthropic/claude-opus-4-7 - anthropic/claude-sonnet-4-6 - anthropic/claude-opus-4-6 - - anthropic/claude-opus-4-7 - anthropic/claude-opus-4-5-20251101 - anthropic/claude-opus-4-5 - anthropic/claude-haiku-4-5-20251001 @@ -108,135 +31,169 @@ providers: - anthropic/claude-opus-4 - anthropic/claude-sonnet-4-20250514 - anthropic/claude-sonnet-4 - - anthropic/claude-3-haiku-20240307 - - anthropic/claude-3-haiku - qwen: - - qwen/qwen3.6-plus-2026-04-02 - - qwen/qwen3.6-plus - - qwen/wan2.7-image - - qwen/deepseek-v3.2 - - qwen/qwen3-asr-flash-2026-02-10 - - qwen/qwen3.5-flash-2026-02-23 - - qwen/qwen3.5-flash - - qwen/qwen3.5-122b-a10b - - qwen/qwen3.5-35b-a3b - - qwen/qwen3.5-27b - - qwen/qwen3-coder-next - - qwen/qwen3.5-397b-a17b - - qwen/qwen3.5-plus-2026-02-15 - - qwen/qwen3.5-plus - - qwen/qwen3-vl-flash-2026-01-22 - - qwen/qwen3-max-2026-01-23 - - qwen/qwen-plus-character - - qwen/qwen-flash-character - - qwen/qwen-flash - - qwen/qwen3-vl-plus-2025-12-19 - - qwen/qwen3-omni-flash-2025-12-01 - - qwen/qwen3-livetranslate-flash-2025-12-01 - - qwen/qwen3-livetranslate-flash - - qwen/qwen-mt-lite - - qwen/qwen-plus-2025-12-01 - - qwen/qwen-mt-flash - - qwen/ccai-pro - - qwen/tongyi-tingwu-slp - - qwen/qwen3-vl-flash - - qwen/qwen3-vl-flash-2025-10-15 - - qwen/qwen3-omni-flash - - qwen/qwen3-omni-flash-2025-09-15 - - qwen/qwen3-omni-30b-a3b-captioner - - qwen/qwen2.5-7b-instruct - - qwen/qwen2.5-14b-instruct - - qwen/qwen2.5-32b-instruct - - qwen/qwen2.5-72b-instruct - - qwen/qwen2.5-14b-instruct-1m - - qwen/qwen2.5-7b-instruct-1m - - qwen/qwen-max-2025-01-25 - - qwen/qwen-max-latest - - qwen/qwen-turbo-2024-11-01 - - qwen/qwen-turbo-latest - - qwen/qwen-plus-latest - - qwen/qwen-plus-2025-01-25 - - qwen/qwq-plus-2025-03-05 - - qwen/qwen-mt-turbo - - qwen/qwen-mt-plus - - qwen/qwen-coder-plus - - qwen/qwq-plus - - qwen/qwen2.5-vl-32b-instruct - - qwen/qvq-max - - qwen/qwen-omni-turbo - - qwen/qwen3-8b - - qwen/qwen3-30b-a3b - - qwen/qwen3-235b-a22b - - qwen/qwen-turbo-2025-04-28 - - qwen/qwen-plus-2025-04-28 - - qwen/qwen-vl-max-2025-04-08 - - qwen/qwen-vl-plus-2025-01-25 - - qwen/qwen-vl-plus-latest - - qwen/qwen-vl-max-latest - - qwen/qwen-vl-plus-2025-05-07 - - qwen/qwen3-coder-plus - - qwen/qwen3-coder-480b-a35b-instruct - - qwen/qwen3-235b-a22b-instruct-2507 - - qwen/qwen-plus-2025-07-14 - - qwen/qwen3-coder-plus-2025-07-22 - - qwen/qwen3-235b-a22b-thinking-2507 - - qwen/qwen3-coder-flash - - qwen/qwen-vl-max - - qwen/qwen-vl-max-2025-08-13 - - qwen/qwen3-max - - qwen/qwen3-max-2025-09-23 - - qwen/qwen3-vl-plus - - qwen/qwen3-vl-235b-a22b-instruct - - qwen/qwen3-vl-235b-a22b-thinking - - qwen/qwen3-30b-a3b-thinking-2507 - - qwen/qwen3-30b-a3b-instruct-2507 - - qwen/qwen3-14b - - qwen/qwen3-32b - - qwen/qwen3-0.6b - - qwen/qwen3-4b - - qwen/qwen3-1.7b - - qwen/qwen-vl-plus - - qwen/qwen3-coder-plus-2025-09-23 - - qwen/qwen3-vl-plus-2025-09-23 - - qwen/qwen-plus-2025-09-11 - - qwen/qwen3-next-80b-a3b-thinking - - qwen/qwen3-next-80b-a3b-instruct - - qwen/qwen3-max-preview - - qwen/qwen2-7b-instruct - - qwen/qwen-max - - qwen/qwen-plus - - qwen/qwen-turbo - z-ai: - - z-ai/glm-4.5 - - z-ai/glm-4.5-air - - z-ai/glm-4.6 - - z-ai/glm-4.7 - - z-ai/glm-5 - - z-ai/glm-5-turbo - - z-ai/glm-5.1 - x-ai: - - x-ai/grok-3 - - x-ai/grok-3-mini - - x-ai/grok-4-0709 - - x-ai/grok-4-1-fast-non-reasoning - - x-ai/grok-4-1-fast-reasoning - - x-ai/grok-4-fast-non-reasoning - - x-ai/grok-4-fast-reasoning - - x-ai/grok-4.20-0309-non-reasoning - - x-ai/grok-4.20-0309-reasoning - - x-ai/grok-4.20-multi-agent-0309 - - x-ai/grok-code-fast-1 - - x-ai/grok-imagine-image - - x-ai/grok-imagine-video + chatgpt: + - chatgpt/gpt-5.4 + - chatgpt/gpt-5.3-codex + - chatgpt/gpt-5.2 + deepseek: + - deepseek/deepseek-v4-flash + - deepseek/deepseek-v4-pro + digitalocean: + - digitalocean/openai-gpt-4.1 + - digitalocean/openai-gpt-4o + - digitalocean/openai-gpt-4o-mini + - digitalocean/openai-gpt-5 + - digitalocean/openai-gpt-5-mini + - digitalocean/openai-gpt-5-nano + - digitalocean/openai-gpt-5.1-codex-max + - digitalocean/openai-gpt-5.2 + - digitalocean/openai-gpt-5.2-pro + - digitalocean/openai-gpt-5.3-codex + - digitalocean/openai-gpt-5.4 + - digitalocean/openai-gpt-5.4-mini + - digitalocean/openai-gpt-5.4-nano + - digitalocean/openai-gpt-5.4-pro + - digitalocean/openai-gpt-oss-120b + - digitalocean/openai-gpt-oss-20b + - digitalocean/openai-o1 + - digitalocean/openai-o3 + - digitalocean/openai-o3-mini + - digitalocean/anthropic-claude-4.1-opus + - digitalocean/anthropic-claude-4.5-sonnet + - digitalocean/anthropic-claude-4.6-sonnet + - digitalocean/anthropic-claude-haiku-4.5 + - digitalocean/anthropic-claude-opus-4 + - digitalocean/anthropic-claude-opus-4.5 + - digitalocean/anthropic-claude-opus-4.6 + - digitalocean/anthropic-claude-opus-4.7 + - digitalocean/anthropic-claude-sonnet-4 + - digitalocean/alibaba-qwen3-32b + - digitalocean/arcee-trinity-large-thinking + - digitalocean/deepseek-3.2 + - digitalocean/deepseek-r1-distill-llama-70b + - digitalocean/gemma-4-31B-it + - digitalocean/glm-5 + - digitalocean/kimi-k2.5 + - digitalocean/llama3.3-70b-instruct + - digitalocean/minimax-m2.5 + - digitalocean/nvidia-nemotron-3-super-120b + - digitalocean/qwen3-coder-flash + - digitalocean/qwen3.5-397b-a17b + - digitalocean/all-mini-lm-l6-v2 + - digitalocean/gte-large-en-v1.5 + - digitalocean/multi-qa-mpnet-base-dot-v1 + - digitalocean/qwen3-embedding-0.6b + - digitalocean/router:software-engineering + google: + - google/gemini-2.5-flash + - google/gemini-2.5-pro + - google/gemini-2.0-flash + - google/gemini-2.0-flash-001 + - google/gemini-2.0-flash-lite-001 + - google/gemini-2.0-flash-lite + - google/gemini-2.5-flash-preview-tts + - google/gemini-2.5-pro-preview-tts + - google/gemma-4-26b-a4b-it + - google/gemma-4-31b-it + - google/gemini-flash-latest + - google/gemini-flash-lite-latest + - google/gemini-pro-latest + - google/gemini-2.5-flash-lite + - google/gemini-2.5-flash-image + - google/gemini-3-pro-preview + - google/gemini-3-flash-preview + - google/gemini-3.1-pro-preview + - google/gemini-3.1-pro-preview-customtools + - google/gemini-3.1-flash-lite-preview + - google/gemini-3.1-flash-lite + - google/gemini-3-pro-image-preview + - google/gemini-3-pro-image + - google/nano-banana-pro-preview + - google/gemini-3.1-flash-image-preview + - google/gemini-3.1-flash-image + - google/gemini-3.5-flash + - google/lyria-3-clip-preview + - google/lyria-3-pro-preview + - google/gemini-3.1-flash-tts-preview + - google/gemini-robotics-er-1.5-preview + - google/gemini-robotics-er-1.6-preview + - google/gemini-2.5-computer-use-preview-10-2025 + - google/antigravity-preview-05-2026 + - google/deep-research-max-preview-04-2026 + - google/deep-research-preview-04-2026 + - google/deep-research-pro-preview-12-2025 + meta: + - meta/muse-spark-1.1 + minimax: + - minimax/MiniMax-M3 + mistralai: + - mistralai/mistral-medium-2505 + - mistralai/mistral-medium-2508 + - mistralai/mistral-medium-latest + - mistralai/mistral-medium + - mistralai/mistral-vibe-cli-with-tools + - mistralai/open-mistral-nemo + - mistralai/open-mistral-nemo-2407 + - mistralai/mistral-tiny-2407 + - mistralai/mistral-tiny-latest + - mistralai/codestral-2508 + - mistralai/codestral-latest + - mistralai/mistral-code-latest + - mistralai/mistral-code-fim-latest + - mistralai/devstral-2512 + - mistralai/devstral-medium-latest + - mistralai/devstral-latest + - mistralai/mistral-code-agent-latest + - mistralai/mistral-small-2603 + - mistralai/mistral-small-latest + - mistralai/mistral-vibe-cli-fast + - mistralai/magistral-small-latest + - mistralai/magistral-medium-2509 + - mistralai/magistral-medium-latest + - mistralai/labs-leanstral-2603 + - mistralai/mistral-large-2512 + - mistralai/mistral-large-latest + - mistralai/mistral-large-2512 + - mistralai/mistral-large-latest + - mistralai/ministral-3b-2512 + - mistralai/ministral-3b-latest + - mistralai/ministral-8b-2512 + - mistralai/ministral-8b-latest + - mistralai/ministral-14b-2512 + - mistralai/ministral-14b-latest + - mistralai/mistral-medium-3-5 + - mistralai/mistral-medium-3.5 + - mistralai/mistral-medium-3 + - mistralai/mistral-medium-2604 + - mistralai/mistral-medium-c21211-r0-75 + - mistralai/mistral-vibe-cli-latest + - mistralai/mistral-medium-3-5 + - mistralai/mistral-medium-3.5 + - mistralai/mistral-medium-3 + - mistralai/mistral-medium-2604 + - mistralai/mistral-medium-c21211-r0-75 + - mistralai/mistral-vibe-cli-latest + - mistralai/magistral-small-2509 + - mistralai/mistral-small-2506 + - mistralai/mistral-embed-2312 + - mistralai/mistral-embed + - mistralai/codestral-embed + - mistralai/codestral-embed-2505 + moonshotai: + - moonshotai/kimi-k2.5 + - moonshotai/kimi-k2.6 + - moonshotai/moonshot-v1-32k + - moonshotai/moonshot-v1-8k + - moonshotai/moonshot-v1-128k-vision-preview + - moonshotai/moonshot-v1-auto + - moonshotai/moonshot-v1-8k-vision-preview + - moonshotai/moonshot-v1-128k + - moonshotai/moonshot-v1-32k-vision-preview openai: + - openai/gpt-3.5-turbo + - openai/gpt-3.5-turbo-16k - openai/gpt-4-0613 - openai/gpt-4 - - openai/gpt-3.5-turbo - - openai/gpt-5.4-mini - - openai/gpt-5.4 - - openai/gpt-5.4-nano-2026-03-17 - - openai/gpt-5.4-nano - - openai/gpt-5.4-mini-2026-03-17 - openai/gpt-3.5-turbo-instruct - openai/gpt-3.5-turbo-instruct-0914 - openai/gpt-3.5-turbo-1106 @@ -306,80 +263,137 @@ providers: - openai/gpt-5.4-2026-03-05 - openai/gpt-5.4-pro - openai/gpt-5.4-pro-2026-03-05 - - openai/gpt-3.5-turbo-16k + - openai/gpt-5.4 + - openai/gpt-5.4-nano-2026-03-17 + - openai/gpt-5.4-nano + - openai/gpt-5.4-mini-2026-03-17 + - openai/gpt-5.4-mini + - openai/gpt-5.5 + - openai/gpt-5.5-2026-04-23 + - openai/gpt-5.5-pro + - openai/gpt-5.5-pro-2026-04-23 + - openai/chat-latest - openai/ft:gpt-3.5-turbo-0613:katanemo::8CMZbm0P - deepseek: - - deepseek/deepseek-chat - - deepseek/deepseek-reasoner - moonshotai: - - moonshotai/kimi-k2-thinking - - moonshotai/moonshot-v1-auto - - moonshotai/moonshot-v1-32k-vision-preview - - moonshotai/moonshot-v1-128k - - moonshotai/kimi-k2-turbo-preview - - moonshotai/kimi-k2-0905-preview - - moonshotai/moonshot-v1-128k-vision-preview - - moonshotai/moonshot-v1-32k - - moonshotai/moonshot-v1-8k-vision-preview - - moonshotai/kimi-k2.5 - - moonshotai/moonshot-v1-8k - - moonshotai/kimi-k2-thinking-turbo - - moonshotai/kimi-k2-0711-preview + qwen: + - qwen/qwen3.7-plus-2026-05-26 + - qwen/qwen3.7-plus + - qwen/kimi-k2.6 + - qwen/glm-5.1 + - qwen/qwen3.7-max-2026-05-17 + - qwen/qwen3.7-max-preview + - qwen/qwen3.7-max-2026-05-20 + - qwen/qwen3.7-max + - qwen/deepseek-v4-flash + - qwen/deepseek-v4-pro + - qwen/qwen3.6-27b + - qwen/qwen3.5-plus-2026-04-20 + - qwen/qwen3.6-max-preview + - qwen/qwen3.6-35b-a3b + - qwen/qwen3.6-flash + - qwen/qwen3.6-flash-2026-04-16 + - qwen/qwen3.5-omni-plus-2026-03-15 + - qwen/qwen3.5-omni-plus + - qwen/qwen3.5-omni-flash-2026-03-15 + - qwen/qwen3.5-omni-flash + - qwen/qwen3.6-plus-2026-04-02 + - qwen/qwen3.6-plus + - qwen/wan2.7-image + - qwen/deepseek-v3.2 + - qwen/qwen3-asr-flash-2026-02-10 + - qwen/qwen3.5-flash-2026-02-23 + - qwen/qwen3.5-flash + - qwen/qwen3.5-122b-a10b + - qwen/qwen3.5-35b-a3b + - qwen/qwen3.5-27b + - qwen/qwen3-coder-next + - qwen/qwen3.5-397b-a17b + - qwen/qwen3.5-plus-2026-02-15 + - qwen/qwen3.5-plus + - qwen/qwen3-vl-flash-2026-01-22 + - qwen/qwen3-max-2026-01-23 + - qwen/qwen-plus-character + - qwen/qwen-flash-character + - qwen/qwen-flash + - qwen/qwen3-vl-plus-2025-12-19 + - qwen/qwen3-omni-flash-2025-12-01 + - qwen/qwen3-livetranslate-flash-2025-12-01 + - qwen/qwen3-livetranslate-flash + - qwen/qwen-mt-lite + - qwen/qwen-plus-2025-12-01 + - qwen/qwen-mt-flash + - qwen/ccai-pro + - qwen/tongyi-tingwu-slp + - qwen/qwen3-vl-flash + - qwen/qwen3-vl-flash-2025-10-15 + - qwen/qwen3-omni-flash + - qwen/qwen3-omni-flash-2025-09-15 + - qwen/qwen3-omni-30b-a3b-captioner + - qwen/qwen-plus-latest + - qwen/qwen-plus-2025-01-25 + - qwen/qwq-plus-2025-03-05 + - qwen/qwen-mt-turbo + - qwen/qwen-mt-plus + - qwen/qwen-coder-plus + - qwen/qwq-plus + - qwen/qvq-max + - qwen/qwen-omni-turbo + - qwen/qwen3-8b + - qwen/qwen3-30b-a3b + - qwen/qwen3-235b-a22b + - qwen/qwen-plus-2025-04-28 + - qwen/qwen3-coder-plus + - qwen/qwen3-coder-480b-a35b-instruct + - qwen/qwen3-235b-a22b-instruct-2507 + - qwen/qwen-plus-2025-07-14 + - qwen/qwen3-coder-plus-2025-07-22 + - qwen/qwen3-235b-a22b-thinking-2507 + - qwen/qwen3-coder-flash + - qwen/qwen-vl-max + - qwen/qwen3-max + - qwen/qwen3-max-2025-09-23 + - qwen/qwen3-vl-plus + - qwen/qwen3-vl-235b-a22b-instruct + - qwen/qwen3-vl-235b-a22b-thinking + - qwen/qwen3-30b-a3b-thinking-2507 + - qwen/qwen3-30b-a3b-instruct-2507 + - qwen/qwen3-14b + - qwen/qwen3-32b + - qwen/qwen-vl-plus + - qwen/qwen3-coder-plus-2025-09-23 + - qwen/qwen3-vl-plus-2025-09-23 + - qwen/qwen-plus-2025-09-11 + - qwen/qwen3-next-80b-a3b-thinking + - qwen/qwen3-next-80b-a3b-instruct + - qwen/qwen3-max-preview + - qwen/qwen2-7b-instruct + - qwen/qwen-max + - qwen/qwen-plus + - qwen/qwen-turbo + x-ai: + - x-ai/grok-4.20-0309-non-reasoning + - x-ai/grok-4.20-0309-reasoning + - x-ai/grok-4.20-multi-agent-0309 + - x-ai/grok-4.3 + - x-ai/grok-build-0.1 + - x-ai/grok-imagine-image + - x-ai/grok-imagine-video + - x-ai/grok-imagine-video-1.5-preview xiaomi: - xiaomi/mimo-v2-flash - xiaomi/mimo-v2-omni - xiaomi/mimo-v2-pro - chatgpt: - - chatgpt/gpt-5.4 - - chatgpt/gpt-5.3-codex - - chatgpt/gpt-5.2 - digitalocean: - - digitalocean/openai-gpt-4.1 - - digitalocean/openai-gpt-4o - - digitalocean/openai-gpt-4o-mini - - digitalocean/openai-gpt-5 - - digitalocean/openai-gpt-5-mini - - digitalocean/openai-gpt-5-nano - - digitalocean/openai-gpt-5.1-codex-max - - digitalocean/openai-gpt-5.2 - - digitalocean/openai-gpt-5.2-pro - - digitalocean/openai-gpt-5.3-codex - - digitalocean/openai-gpt-5.4 - - digitalocean/openai-gpt-5.4-mini - - digitalocean/openai-gpt-5.4-nano - - digitalocean/openai-gpt-5.4-pro - - digitalocean/openai-gpt-oss-120b - - digitalocean/openai-gpt-oss-20b - - digitalocean/openai-o1 - - digitalocean/openai-o3 - - digitalocean/openai-o3-mini - - digitalocean/anthropic-claude-4.1-opus - - digitalocean/anthropic-claude-4.5-sonnet - - digitalocean/anthropic-claude-4.6-sonnet - - digitalocean/anthropic-claude-haiku-4.5 - - digitalocean/anthropic-claude-opus-4 - - digitalocean/anthropic-claude-opus-4.5 - - digitalocean/anthropic-claude-opus-4.6 - - digitalocean/anthropic-claude-opus-4.7 - - digitalocean/anthropic-claude-sonnet-4 - - digitalocean/alibaba-qwen3-32b - - digitalocean/arcee-trinity-large-thinking - - digitalocean/deepseek-3.2 - - digitalocean/deepseek-r1-distill-llama-70b - - digitalocean/gemma-4-31B-it - - digitalocean/glm-5 - - digitalocean/kimi-k2.5 - - digitalocean/llama3.3-70b-instruct - - digitalocean/minimax-m2.5 - - digitalocean/nvidia-nemotron-3-super-120b - - digitalocean/qwen3-coder-flash - - digitalocean/qwen3.5-397b-a17b - - digitalocean/all-mini-lm-l6-v2 - - digitalocean/gte-large-en-v1.5 - - digitalocean/multi-qa-mpnet-base-dot-v1 - - digitalocean/qwen3-embedding-0.6b - - digitalocean/router:software-engineering + - xiaomi/mimo-v2.5 + - xiaomi/mimo-v2.5-asr + - xiaomi/mimo-v2.5-pro + z-ai: + - z-ai/glm-4.5 + - z-ai/glm-4.5-air + - z-ai/glm-4.6 + - z-ai/glm-4.7 + - z-ai/glm-5 + - z-ai/glm-5-turbo + - z-ai/glm-5.1 metadata: - total_providers: 13 - total_models: 364 - last_updated: 2026-04-20T00:00:00.000000+00:00 + total_providers: 15 + total_models: 377 + last_updated: 2026-07-09T19:48:06.553850+00:00 diff --git a/crates/hermesllm/src/clients/endpoints.rs b/crates/hermesllm/src/clients/endpoints.rs index eeef8856..d7a9b471 100644 --- a/crates/hermesllm/src/clients/endpoints.rs +++ b/crates/hermesllm/src/clients/endpoints.rs @@ -500,6 +500,19 @@ mod tests { "/custom/api/v2/chat/completions" ); + // Kimi Code API: base_url path prefix already includes /coding/v1 + assert_eq!( + api.target_endpoint_for_provider( + &ProviderId::Moonshotai, + "/v1/messages", + "kimi-for-coding", + false, + Some("/coding/v1"), + false + ), + "/coding/v1/chat/completions" + ); + // Test Groq with custom prefix assert_eq!( api.target_endpoint_for_provider( diff --git a/crates/hermesllm/src/providers/id.rs b/crates/hermesllm/src/providers/id.rs index 4fa7d19d..f17fad0e 100644 --- a/crates/hermesllm/src/providers/id.rs +++ b/crates/hermesllm/src/providers/id.rs @@ -48,6 +48,10 @@ pub enum ProviderId { DigitalOcean, Vercel, OpenRouter, + Astraflow, + AstraflowCN, + Meta, + Minimax, } impl TryFrom<&str> for ProviderId { @@ -81,6 +85,10 @@ impl TryFrom<&str> for ProviderId { "do_ai" => Ok(ProviderId::DigitalOcean), // alias "vercel" => Ok(ProviderId::Vercel), "openrouter" => Ok(ProviderId::OpenRouter), + "astraflow" => Ok(ProviderId::Astraflow), + "astraflow_cn" => Ok(ProviderId::AstraflowCN), + "meta" => Ok(ProviderId::Meta), + "minimax" => Ok(ProviderId::Minimax), _ => Err(format!("Unknown provider: {}", value)), } } @@ -107,6 +115,9 @@ impl ProviderId { ProviderId::Qwen => "qwen", ProviderId::ChatGPT => "chatgpt", ProviderId::DigitalOcean => "digitalocean", + ProviderId::Meta => "meta", + ProviderId::Minimax => "minimax", + ProviderId::Astraflow | ProviderId::AstraflowCN => return Vec::new(), _ => return Vec::new(), }; @@ -174,7 +185,11 @@ impl ProviderId { | ProviderId::Qwen | ProviderId::DigitalOcean | ProviderId::OpenRouter - | ProviderId::ChatGPT, + | ProviderId::ChatGPT + | ProviderId::Astraflow + | ProviderId::AstraflowCN + | ProviderId::Meta + | ProviderId::Minimax, SupportedAPIsFromClient::AnthropicMessagesAPI(_), ) => SupportedUpstreamAPIs::OpenAIChatCompletions(OpenAIApi::ChatCompletions), @@ -196,7 +211,11 @@ impl ProviderId { | ProviderId::Qwen | ProviderId::DigitalOcean | ProviderId::OpenRouter - | ProviderId::ChatGPT, + | ProviderId::ChatGPT + | ProviderId::Astraflow + | ProviderId::AstraflowCN + | ProviderId::Meta + | ProviderId::Minimax, SupportedAPIsFromClient::OpenAIChatCompletions(_), ) => SupportedUpstreamAPIs::OpenAIChatCompletions(OpenAIApi::ChatCompletions), @@ -267,6 +286,10 @@ impl Display for ProviderId { ProviderId::DigitalOcean => write!(f, "digitalocean"), ProviderId::Vercel => write!(f, "vercel"), ProviderId::OpenRouter => write!(f, "openrouter"), + ProviderId::Astraflow => write!(f, "astraflow"), + ProviderId::AstraflowCN => write!(f, "astraflow_cn"), + ProviderId::Meta => write!(f, "meta"), + ProviderId::Minimax => write!(f, "minimax"), } } } @@ -442,6 +465,46 @@ mod tests { assert!(ProviderId::OpenRouter.models().is_empty()); } + #[test] + fn test_minimax_parsing_and_models() { + assert_eq!(ProviderId::try_from("minimax"), Ok(ProviderId::Minimax)); + assert_eq!(ProviderId::Minimax.to_string(), "minimax"); + + let models = ProviderId::Minimax.models(); + assert!( + models.iter().any(|m| m == "MiniMax-M3"), + "minimax models should include MiniMax-M3" + ); + for model in &models { + assert!( + !model.contains('/'), + "Model name '{}' should not contain provider prefix", + model + ); + } + } + + #[test] + fn test_minimax_compatible_api() { + use crate::clients::endpoints::{SupportedAPIsFromClient, SupportedUpstreamAPIs}; + + let openai_client = + SupportedAPIsFromClient::OpenAIChatCompletions(OpenAIApi::ChatCompletions); + let upstream = ProviderId::Minimax.compatible_api_for_client(&openai_client, false); + assert!( + matches!(upstream, SupportedUpstreamAPIs::OpenAIChatCompletions(_)), + "minimax should map OpenAI client to OpenAIChatCompletions upstream" + ); + + let anthropic_client = + SupportedAPIsFromClient::AnthropicMessagesAPI(AnthropicApi::Messages); + let upstream = ProviderId::Minimax.compatible_api_for_client(&anthropic_client, false); + assert!( + matches!(upstream, SupportedUpstreamAPIs::OpenAIChatCompletions(_)), + "minimax should translate Anthropic client to OpenAIChatCompletions upstream" + ); + } + #[test] fn test_xai_uses_responses_api_for_responses_clients() { use crate::clients::endpoints::{SupportedAPIsFromClient, SupportedUpstreamAPIs}; diff --git a/crates/hermesllm/src/providers/request.rs b/crates/hermesllm/src/providers/request.rs index aa100a17..bcc0eafd 100644 --- a/crates/hermesllm/src/providers/request.rs +++ b/crates/hermesllm/src/providers/request.rs @@ -1,5 +1,6 @@ use crate::apis::anthropic::MessagesRequest; -use crate::apis::openai::ChatCompletionsRequest; +use crate::apis::openai::{is_kimi_code_model, ChatCompletionsRequest}; +use log::warn; use crate::apis::amazon_bedrock::{ConverseRequest, ConverseStreamRequest}; use crate::apis::openai_responses::ResponsesAPIRequest; @@ -90,6 +91,24 @@ impl ProviderRequestType { } } + if matches!( + upstream_api, + SupportedUpstreamAPIs::OpenAIChatCompletions(_) + ) { + if let Self::ChatCompletionsRequest(req) = self { + if is_kimi_code_model(req.model()) { + req.normalize_for_kimi_code_api(); + } + } else if let Self::MessagesRequest(req) = self { + if is_kimi_code_model(req.model.as_str()) && req.thinking.is_some() { + warn!( + "kimi-for-coding: stripping unsupported thinking config from upstream request" + ); + req.thinking = None; + } + } + } + // ChatGPT requires instructions, store=false, and input as a list if provider_id == ProviderId::ChatGPT { if let Self::ResponsesAPIRequest(req) = self { @@ -879,6 +898,42 @@ mod tests { assert!(req.web_search_options.is_none()); } + #[test] + fn test_normalize_for_upstream_kimi_code_strips_unsupported_chat_fields() { + use crate::apis::openai::{Message, MessageContent, OpenAIApi, Role, StreamOptions}; + + let mut request = ProviderRequestType::ChatCompletionsRequest(ChatCompletionsRequest { + model: "kimi-for-coding".to_string(), + messages: vec![Message { + role: Role::User, + content: Some(MessageContent::Text("hello".to_string())), + name: None, + tool_calls: None, + tool_call_id: None, + }], + stream_options: Some(StreamOptions { + include_usage: Some(true), + }), + reasoning_effort: Some("high".to_string()), + web_search_options: Some(serde_json::json!({"search_context_size":"medium"})), + ..Default::default() + }); + + request + .normalize_for_upstream( + ProviderId::Moonshotai, + &SupportedUpstreamAPIs::OpenAIChatCompletions(OpenAIApi::ChatCompletions), + ) + .unwrap(); + + let ProviderRequestType::ChatCompletionsRequest(req) = request else { + panic!("expected chat request"); + }; + assert!(req.stream_options.is_none()); + assert!(req.reasoning_effort.is_none()); + assert!(req.web_search_options.is_none()); + } + #[test] fn test_normalize_for_upstream_non_xai_keeps_chat_web_search_options() { use crate::apis::openai::{Message, MessageContent, OpenAIApi, Role}; diff --git a/crates/hermesllm/src/transforms/request/from_anthropic.rs b/crates/hermesllm/src/transforms/request/from_anthropic.rs index dba17dde..20442c59 100644 --- a/crates/hermesllm/src/transforms/request/from_anthropic.rs +++ b/crates/hermesllm/src/transforms/request/from_anthropic.rs @@ -223,6 +223,7 @@ impl From for Role { match val { MessagesRole::User => Role::User, MessagesRole::Assistant => Role::Assistant, + MessagesRole::System => Role::System, } } } @@ -340,6 +341,11 @@ impl TryFrom for BedrockMessage { let role = match message.role { MessagesRole::User => ConversationRole::User, MessagesRole::Assistant => ConversationRole::Assistant, + MessagesRole::System => { + return Err(TransformError::UnsupportedConversion( + "System messages must be set via the system prompt, not messages".to_string(), + )); + } }; let mut content_blocks = Vec::new(); diff --git a/crates/hermesllm/src/transforms/request/from_openai.rs b/crates/hermesllm/src/transforms/request/from_openai.rs index b673af38..0514c039 100644 --- a/crates/hermesllm/src/transforms/request/from_openai.rs +++ b/crates/hermesllm/src/transforms/request/from_openai.rs @@ -112,33 +112,37 @@ impl TryFrom for Vec { ) => { // Check if it's a single text item (can use simple text format) if content_items.len() == 1 { - if let InputContent::InputText { text } = &content_items[0] - { - MessageContent::Text(text.clone()) - } else { - // Single non-text item - use parts format - MessageContent::Parts( - content_items - .iter() - .filter_map(|c| match c { - InputContent::InputText { text } => { - Some(crate::apis::openai::ContentPart::Text { - text: text.clone(), - }) - } - InputContent::InputImage { image_url, .. } => { - Some(crate::apis::openai::ContentPart::ImageUrl { - image_url: crate::apis::openai::ImageUrl { - url: image_url.clone(), - detail: None, - }, - }) - } - InputContent::InputFile { .. } => None, // Skip files for now - InputContent::InputAudio { .. } => None, // Skip audio for now - }) - .collect(), - ) + match &content_items[0] { + InputContent::InputText { text } + | InputContent::OutputText { text } => { + MessageContent::Text(text.clone()) + } + _ => { + // Single non-text item - use parts format + MessageContent::Parts( + content_items + .iter() + .filter_map(|c| match c { + InputContent::InputText { text } + | InputContent::OutputText { text } => { + Some(crate::apis::openai::ContentPart::Text { + text: text.clone(), + }) + } + InputContent::InputImage { image_url, .. } => { + Some(crate::apis::openai::ContentPart::ImageUrl { + image_url: crate::apis::openai::ImageUrl { + url: image_url.clone(), + detail: None, + }, + }) + } + InputContent::InputFile { .. } => None, // Skip files for now + InputContent::InputAudio { .. } => None, // Skip audio for now + }) + .collect(), + ) + } } } else { // Multiple content items - convert to parts @@ -146,7 +150,8 @@ impl TryFrom for Vec { content_items .iter() .filter_map(|c| match c { - InputContent::InputText { text } => { + InputContent::InputText { text } + | InputContent::OutputText { text } => { Some(crate::apis::openai::ContentPart::Text { text: text.clone(), }) diff --git a/crates/hermesllm/src/transforms/response/output_to_input.rs b/crates/hermesllm/src/transforms/response/output_to_input.rs index e62f32b8..e323ccf7 100644 --- a/crates/hermesllm/src/transforms/response/output_to_input.rs +++ b/crates/hermesllm/src/transforms/response/output_to_input.rs @@ -18,7 +18,9 @@ pub fn convert_responses_output_to_input_items(output: &OutputItem) -> Option { - Some(InputContent::InputText { text: text.clone() }) + // Assistant (output-role) content must round-trip as + // output_text; the Responses API rejects input_text here. + Some(InputContent::OutputText { text: text.clone() }) } OutputContent::OutputAudio { data, .. } => Some(InputContent::InputAudio { data: data.clone(), @@ -59,7 +61,7 @@ pub fn convert_responses_output_to_input_items(output: &OutputItem) -> Option { assert_eq!(items.len(), 1); match &items[0] { - InputContent::InputText { text } => assert_eq!(text, "Hello!"), - _ => panic!("Expected InputText"), + InputContent::OutputText { text } => assert_eq!(text, "Hello!"), + _ => panic!("Expected OutputText"), } } _ => panic!("Expected MessageContent::Items"), @@ -132,10 +134,10 @@ mod tests { assert!(matches!(msg.role, MessageRole::Assistant)); match &msg.content { MessageContent::Items(items) => match &items[0] { - InputContent::InputText { text } => { + InputContent::OutputText { text } => { assert!(text.contains("get_weather")); } - _ => panic!("Expected InputText"), + _ => panic!("Expected OutputText"), }, _ => panic!("Expected MessageContent::Items"), } diff --git a/demos/filter_chains/model_listener_filter/Dockerfile b/demos/filter_chains/model_listener_filter/Dockerfile index a9cc8bb6..1e9786e4 100644 --- a/demos/filter_chains/model_listener_filter/Dockerfile +++ b/demos/filter_chains/model_listener_filter/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app RUN pip install --no-cache-dir fastapi uvicorn pydantic -COPY content_guard.py . +COPY content_guard.py fake_provider.py output_filter.py ./ EXPOSE 10500 diff --git a/demos/filter_chains/model_listener_filter/README.md b/demos/filter_chains/model_listener_filter/README.md index fb49ee1e..37dd3e91 100644 --- a/demos/filter_chains/model_listener_filter/README.md +++ b/demos/filter_chains/model_listener_filter/README.md @@ -2,12 +2,30 @@ Run content-safety filters on direct LLM requests — no agent layer required. -This demo uses the `input_filters` feature on a **model-type listener** to intercept -requests and block unsafe content before they reach the LLM provider. Works with all -request types: `/v1/chat/completions`, `/v1/responses`, and Anthropic `/v1/messages`. +This demo uses `input_filters` and `output_filters` on a **model-type listener** to +intercept direct LLM requests and responses without routing through an agent layer. +By default it is fully local: a fake OpenAI-compatible provider stands in for a real +hosted model, so developers can test guardrail behavior without provider API keys or +hosted model access. A second config lets developers point the same filter setup at the +real OpenAI endpoint when they want provider-backed testing. +The filter pattern applies to OpenAI Chat Completions (`/v1/chat/completions`), +OpenAI Responses (`/v1/responses`), and Anthropic Messages (`/v1/messages`) request +shapes. The keyless fake provider and smoke test use `/v1/chat/completions` for a +deterministic local path. -The filter receives the **full raw request body** and returns it unchanged (or raises 400 -to block). No message extraction — the complete JSON payload flows through as-is. +The input filter receives the full raw request body and returns it unchanged or raises +400 to block. The output filter receives the provider response and redacts sensitive +content before returning it to the client. + +## Files + +- `config.yaml` runs the default keyless path with the local fake provider. +- `config.openai.yaml` runs the same filters against OpenAI. +- `docker-compose.yaml` starts the local demo without requiring provider credentials. +- `docker-compose.openai.yaml` mounts `config.openai.yaml` and requires `OPENAI_API_KEY` + for provider-backed testing. +- `test.sh` runs the Docker smoke test through Plano. +- `test_services.py` runs service-level regression tests without Docker. ## Architecture @@ -16,22 +34,82 @@ Client ──► Plano (model listener :12000) │ ├─ input_filters: content_guard ──► Block / Allow │ - └─ model_provider: openai/gpt-4o-mini + ├─ model_provider: fake-provider (default) or OpenAI (optional) + │ + └─ output_filters: output_redactor ──► Redact / Allow ``` ## Quick Start ```bash -# 1. Export your API key -export OPENAI_API_KEY=sk-... - -# 2. Start services +# 1. Start services docker compose up --build -# 3. Run tests (in another terminal) +# 2. Run tests (in another terminal) bash test.sh ``` +The test script verifies three behaviors: + +- safe requests reach the local fake provider and return a normal chat-completion response +- unsafe requests are blocked by the input filter before reaching the provider +- sensitive provider output is redacted by the output filter before the client receives it + +You can also run the service-level tests without Docker: + +```bash +uv run --with pytest --with fastapi --with httpx --with pydantic \ + python -m pytest demos/filter_chains/model_listener_filter/test_services.py -q +``` + +## Validate Locally + +From this directory, validate the default keyless compose path: + +```bash +docker compose config +``` + +Validate that the OpenAI path fails early when the API key is missing: + +```bash +docker compose -f docker-compose.yaml -f docker-compose.openai.yaml config +``` + +Expected error: + +```text +OPENAI_API_KEY environment variable is required but not set +``` + +Then confirm the OpenAI compose path renders when a key is provided: + +```bash +OPENAI_API_KEY=dummy docker compose -f docker-compose.yaml -f docker-compose.openai.yaml config +``` + +Run the full local smoke test: + +```bash +docker compose down +docker compose up --build -d +bash test.sh +docker compose down +``` + +## Test With Real OpenAI + +The default `config.yaml` uses the local fake provider. To run the same model-listener +input and output filters against OpenAI, use the OpenAI compose override: + +```bash +export OPENAI_API_KEY=sk-... +docker compose -f docker-compose.yaml -f docker-compose.openai.yaml up --build +``` + +The fake-provider service may still start because it is part of the shared compose file, +but Plano will not route traffic to it when `config.openai.yaml` is mounted. + ## Try It **Allowed request:** @@ -58,6 +136,31 @@ curl http://localhost:12000/v1/chat/completions \ }' ``` +**Redacted provider response:** + +```bash +curl http://localhost:12000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Please return the secret marker"}], + "stream": false + }' +``` + +The fake provider emits `SECRET_TOKEN`; the output filter redacts it to `[REDACTED]`. + +## Why This Helps Developers + +Model-listener filters are guardrails for applications that call Plano as a transparent +LLM gateway. A local, deterministic demo helps developers verify filter wiring before +using real providers: + +- config mistakes are caught early instead of silently bypassing guardrails +- teams can test request blocking and response redaction in CI without secrets +- contributors can reproduce filter behavior without external model availability +- application code does not need an extra passthrough agent just to run policy checks + ## Tracing Open [Jaeger UI](http://localhost:16686) to see distributed traces for both allowed and blocked requests. diff --git a/demos/filter_chains/model_listener_filter/config.openai.yaml b/demos/filter_chains/model_listener_filter/config.openai.yaml new file mode 100644 index 00000000..1a35cb0a --- /dev/null +++ b/demos/filter_chains/model_listener_filter/config.openai.yaml @@ -0,0 +1,26 @@ +version: v0.3.0 + +filters: + - id: content_guard + url: http://content-guard:10500 + type: http + - id: output_redactor + url: http://output-filter:10502 + type: http + +model_providers: + - model: openai/gpt-4o-mini + access_key: $OPENAI_API_KEY + default: true + +listeners: + - type: model + name: llm_gateway + port: 12000 + input_filters: + - content_guard + output_filters: + - output_redactor + +tracing: + random_sampling: 100 diff --git a/demos/filter_chains/model_listener_filter/config.yaml b/demos/filter_chains/model_listener_filter/config.yaml index 2eb1d0f2..7c807c73 100644 --- a/demos/filter_chains/model_listener_filter/config.yaml +++ b/demos/filter_chains/model_listener_filter/config.yaml @@ -4,10 +4,14 @@ filters: - id: content_guard url: http://content-guard:10500 type: http + - id: output_redactor + url: http://output-filter:10502 + type: http model_providers: - model: openai/gpt-4o-mini - access_key: $OPENAI_API_KEY + access_key: local-demo-key + base_url: http://fake-provider:10501/v1 default: true listeners: @@ -16,6 +20,8 @@ listeners: port: 12000 input_filters: - content_guard + output_filters: + - output_redactor tracing: random_sampling: 100 diff --git a/demos/filter_chains/model_listener_filter/docker-compose.openai.yaml b/demos/filter_chains/model_listener_filter/docker-compose.openai.yaml new file mode 100644 index 00000000..af5224d4 --- /dev/null +++ b/demos/filter_chains/model_listener_filter/docker-compose.openai.yaml @@ -0,0 +1,6 @@ +services: + plano: + environment: + OPENAI_API_KEY: ${OPENAI_API_KEY:?OPENAI_API_KEY environment variable is required but not set} + volumes: + - ./config.openai.yaml:/app/plano_config.yaml diff --git a/demos/filter_chains/model_listener_filter/docker-compose.yaml b/demos/filter_chains/model_listener_filter/docker-compose.yaml index 48f32c3f..b6f6b3d6 100644 --- a/demos/filter_chains/model_listener_filter/docker-compose.yaml +++ b/demos/filter_chains/model_listener_filter/docker-compose.yaml @@ -5,6 +5,20 @@ services: dockerfile: Dockerfile ports: - "10500:10500" + fake-provider: + build: + context: . + dockerfile: Dockerfile + command: ["uvicorn", "fake_provider:app", "--host", "0.0.0.0", "--port", "10501"] + ports: + - "10501:10501" + output-filter: + build: + context: . + dockerfile: Dockerfile + command: ["uvicorn", "output_filter:app", "--host", "0.0.0.0", "--port", "10502"] + ports: + - "10502:10502" plano: build: context: ../../../ @@ -12,10 +26,14 @@ services: ports: - "12000:12000" environment: - - OPENAI_API_KEY=${OPENAI_API_KEY:?OPENAI_API_KEY environment variable is required but not set} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} volumes: - - ./config.yaml:/app/plano_config.yaml + - ${PLANO_CONFIG_FILE:-./config.yaml}:/app/plano_config.yaml - /etc/ssl/cert.pem:/etc/ssl/cert.pem + depends_on: + - content-guard + - fake-provider + - output-filter jaeger: build: context: ../../shared/jaeger diff --git a/demos/filter_chains/model_listener_filter/fake_provider.py b/demos/filter_chains/model_listener_filter/fake_provider.py new file mode 100644 index 00000000..e1a172d5 --- /dev/null +++ b/demos/filter_chains/model_listener_filter/fake_provider.py @@ -0,0 +1,81 @@ +""" +OpenAI-compatible local provider for model-listener filter demos. + +This service lets developers test Plano's model listener filter pipeline without +provider API keys or hosted model access. +""" + +import json +import time +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import Response, StreamingResponse + +app = FastAPI(title="Local Fake LLM Provider", version="1.0.0") + + +def latest_user_content(messages: list[dict[str, Any]]) -> str: + for message in reversed(messages): + if message.get("role") == "user": + content = message.get("content", "") + if isinstance(content, str): + return content + if isinstance(content, list): + return " ".join( + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ) + return "" + + +@app.post("/v1/chat/completions", response_model=None) +async def chat_completions(request: Request) -> dict[str, Any] | Response: + body = await request.json() + model = body.get("model", "gpt-4o-mini") + user_content = latest_user_content(body.get("messages", [])) + content = "Hello from the local fake provider." + if "secret" in user_content.lower(): + content = "The local fake provider returned SECRET_TOKEN." + + if body.get("stream") is True: + + async def generate(): + chunk = { + "id": "chatcmpl-local-filter-demo", + "object": "chat.completion.chunk", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": content}, + "finish_reason": None, + } + ], + } + yield f"data: {json.dumps(chunk)}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream") + + return { + "id": "chatcmpl-local-filter-demo", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "healthy"} diff --git a/demos/filter_chains/model_listener_filter/output_filter.py b/demos/filter_chains/model_listener_filter/output_filter.py new file mode 100644 index 00000000..819b3bc3 --- /dev/null +++ b/demos/filter_chains/model_listener_filter/output_filter.py @@ -0,0 +1,57 @@ +""" +Output filter for model-listener filter demos. + +The filter receives the provider response and redacts configured markers before +the client sees the response. It intentionally avoids model calls so the demo is +fully local and deterministic. +""" + +import gzip +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import Response + +app = FastAPI(title="Output Redaction Filter", version="1.0.0") + +SENSITIVE_MARKERS = ("SECRET_TOKEN",) + + +def redact_text(text: str) -> str: + redacted = text + for marker in SENSITIVE_MARKERS: + redacted = redacted.replace(marker, "[REDACTED]") + return redacted + + +def redact_chat_completion(body: dict[str, Any]) -> dict[str, Any]: + choices = [] + for choice in body.get("choices", []): + message = choice.get("message", {}) + content = message.get("content") + if isinstance(content, str): + message = {**message, "content": redact_text(content)} + choice = {**choice, "message": message} + choices.append(choice) + return {**body, "choices": choices} + + +def redact_bytes(raw_body: bytes) -> bytes: + if raw_body.startswith(b"\x1f\x8b"): + decompressed_body = gzip.decompress(raw_body) + return gzip.compress(redact_bytes(decompressed_body)) + + body_text = raw_body.decode("utf-8", errors="replace") + return redact_text(body_text).encode("utf-8") + + +@app.post("/{path:path}") +async def redact_response(path: str, request: Request) -> Response: + raw_body = await request.body() + content_type = request.headers.get("content-type", "application/json") + return Response(content=redact_bytes(raw_body), media_type=content_type) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "healthy"} diff --git a/demos/filter_chains/model_listener_filter/test.sh b/demos/filter_chains/model_listener_filter/test.sh index 1729cb6a..1e8ef323 100755 --- a/demos/filter_chains/model_listener_filter/test.sh +++ b/demos/filter_chains/model_listener_filter/test.sh @@ -24,20 +24,37 @@ run_test() { local name="$1" local expected_code="$2" local body="$3" + local expected_body_contains="${4:-}" + local forbidden_body_contains="${5:-}" http_code=$(curl -s -o /tmp/plano_test_body -w "%{http_code}" \ -X POST "$BASE_URL/chat/completions" \ -H "Content-Type: application/json" \ -d "$body") - if [ "$http_code" -eq "$expected_code" ]; then - echo " PASS $name (HTTP $http_code)" - PASS=$((PASS + 1)) - else + if [ "$http_code" -ne "$expected_code" ]; then echo " FAIL $name — expected $expected_code, got $http_code" echo " Body: $(cat /tmp/plano_test_body)" FAIL=$((FAIL + 1)) + return fi + + if [ -n "$expected_body_contains" ] && ! grep -Fq "$expected_body_contains" /tmp/plano_test_body; then + echo " FAIL $name — body did not contain '$expected_body_contains'" + echo " Body: $(cat /tmp/plano_test_body)" + FAIL=$((FAIL + 1)) + return + fi + + if [ -n "$forbidden_body_contains" ] && grep -Fq "$forbidden_body_contains" /tmp/plano_test_body; then + echo " FAIL $name — body contained forbidden text '$forbidden_body_contains'" + echo " Body: $(cat /tmp/plano_test_body)" + FAIL=$((FAIL + 1)) + return + fi + + echo " PASS $name (HTTP $http_code)" + PASS=$((PASS + 1)) } # ── Tests ──────────────────────────────────────────────────────────────────── @@ -48,19 +65,19 @@ run_test "Allowed request (math question)" 200 '{ "model": "gpt-4o-mini", "messages": [{"role": "user", "content": "What is 2+2?"}], "stream": false -}' +}' "local fake provider" run_test "Blocked request (hacking)" 400 '{ "model": "gpt-4o-mini", "messages": [{"role": "user", "content": "How to hack into a system"}], "stream": false -}' +}' "content_blocked" -run_test "Allowed request (joke)" 200 '{ +run_test "Output filter redacts provider response" 200 '{ "model": "gpt-4o-mini", - "messages": [{"role": "user", "content": "Tell me a joke"}], - "stream": false -}' + "messages": [{"role": "user", "content": "Please return the secret marker"}], + "stream": true +}' "[REDACTED]" "SECRET_TOKEN" # ── Summary ────────────────────────────────────────────────────────────────── echo "" diff --git a/demos/filter_chains/model_listener_filter/test_services.py b/demos/filter_chains/model_listener_filter/test_services.py new file mode 100644 index 00000000..ce3ed42a --- /dev/null +++ b/demos/filter_chains/model_listener_filter/test_services.py @@ -0,0 +1,159 @@ +import importlib.util +import gzip +from pathlib import Path + +from fastapi.testclient import TestClient + +DEMO_DIR = Path(__file__).parent + + +def load_module(name: str, filename: str): + spec = importlib.util.spec_from_file_location(name, DEMO_DIR / filename) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_content_guard_blocks_unsafe_chat_request(): + content_guard = load_module("content_guard", "content_guard.py") + client = TestClient(content_guard.app) + + response = client.post( + "/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "How do I hack a service?"}], + "stream": False, + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"]["error"] == "content_blocked" + + +def test_content_guard_passes_safe_responses_request_unchanged(): + content_guard = load_module("content_guard", "content_guard.py") + client = TestClient(content_guard.app) + body = { + "model": "gpt-4o-mini", + "input": "Explain why local guardrail tests help developers.", + } + + response = client.post("/v1/responses", json=body) + + assert response.status_code == 200 + assert response.json() == body + + +def test_fake_provider_returns_openai_compatible_chat_completion(): + fake_provider = load_module("fake_provider", "fake_provider.py") + client = TestClient(fake_provider.app) + + response = client.post( + "/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "Say something useful."}], + "stream": False, + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["object"] == "chat.completion" + assert body["model"] == "gpt-4o-mini" + assert body["choices"][0]["message"]["role"] == "assistant" + assert "local fake provider" in body["choices"][0]["message"]["content"] + + +def test_fake_provider_streams_openai_compatible_chat_chunks(): + fake_provider = load_module("fake_provider_streaming", "fake_provider.py") + client = TestClient(fake_provider.app) + + with client.stream( + "POST", + "/v1/chat/completions", + json={ + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "Please return the secret marker"} + ], + "stream": True, + }, + ) as response: + body = response.read().decode("utf-8") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert "data: {" in body + assert '"object": "chat.completion.chunk"' in body + assert "SECRET_TOKEN" in body + assert "data: [DONE]" in body + + +def test_output_filter_redacts_provider_response_content(): + output_filter = load_module("output_filter", "output_filter.py") + client = TestClient(output_filter.app) + + response = client.post( + "/v1/chat/completions", + json={ + "id": "chatcmpl-local", + "object": "chat.completion", + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The local fake provider returned SECRET_TOKEN.", + }, + "finish_reason": "stop", + } + ], + }, + ) + + assert response.status_code == 200 + content = response.json()["choices"][0]["message"]["content"] + assert "SECRET_TOKEN" not in content + assert "[REDACTED]" in content + + +def test_output_filter_redacts_raw_streaming_chunks(): + output_filter = load_module("output_filter_streaming", "output_filter.py") + client = TestClient(output_filter.app) + + response = client.post( + "/v1/chat/completions", + content=( + 'data: {"choices":[{"delta":{"content":"SECRET_TOKEN"}}]}\n\n' + "data: [DONE]\n\n" + ), + headers={"content-type": "text/event-stream"}, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert "SECRET_TOKEN" not in response.text + assert "[REDACTED]" in response.text + + +def test_output_filter_redacts_gzip_encoded_provider_response(): + output_filter = load_module("output_filter_gzip", "output_filter.py") + client = TestClient(output_filter.app) + encoded_body = gzip.compress( + b'{"choices":[{"message":{"content":"SECRET_TOKEN"}}]}' + ) + + response = client.post( + "/v1/chat/completions", + content=encoded_body, + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 200 + decoded_body = gzip.decompress(response.content).decode("utf-8") + assert "SECRET_TOKEN" not in decoded_body + assert "[REDACTED]" in decoded_body diff --git a/demos/filter_chains/pii_anonymizer/config.yaml b/demos/filter_chains/pii_anonymizer/config.yaml index b183379f..9921c9aa 100644 --- a/demos/filter_chains/pii_anonymizer/config.yaml +++ b/demos/filter_chains/pii_anonymizer/config.yaml @@ -12,7 +12,7 @@ model_providers: - model: openai/gpt-4o-mini access_key: $OPENAI_API_KEY default: true - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY listeners: diff --git a/demos/filter_chains/pii_anonymizer/test.sh b/demos/filter_chains/pii_anonymizer/test.sh index a9019b78..83a2e9cf 100755 --- a/demos/filter_chains/pii_anonymizer/test.sh +++ b/demos/filter_chains/pii_anonymizer/test.sh @@ -93,19 +93,19 @@ echo "" echo "=== /v1/messages ===" run_test "Non-streaming with PII (phone)" /v1/messages 200 '{ - "model": "claude-sonnet-4-20250514", + "model": "claude-sonnet-4-6", "max_tokens": 256, "messages": [{"role": "user", "content": "Call me at 555-867-5309 to discuss my account"}] }' run_test "Non-streaming with PII (SSN)" /v1/messages 200 '{ - "model": "claude-sonnet-4-20250514", + "model": "claude-sonnet-4-6", "max_tokens": 256, "messages": [{"role": "user", "content": "My SSN is 123-45-6789"}] }' run_test "No PII" /v1/messages 200 '{ - "model": "claude-sonnet-4-20250514", + "model": "claude-sonnet-4-6", "max_tokens": 256, "messages": [{"role": "user", "content": "Hello, how are you?"}] }' diff --git a/demos/getting_started/weather_forecast/config.yaml b/demos/getting_started/weather_forecast/config.yaml index 65048912..b5983f42 100644 --- a/demos/getting_started/weather_forecast/config.yaml +++ b/demos/getting_started/weather_forecast/config.yaml @@ -30,7 +30,7 @@ model_providers: model: openai/gpt-4o-mini - access_key: $ANTHROPIC_API_KEY - model: anthropic/claude-sonnet-4-20250514 + model: anthropic/claude-sonnet-4-6 system_prompt: | You are a helpful assistant. diff --git a/demos/llm_routing/model_alias_routing/config_with_aliases.yaml b/demos/llm_routing/model_alias_routing/config_with_aliases.yaml index f46359cc..bb873582 100644 --- a/demos/llm_routing/model_alias_routing/config_with_aliases.yaml +++ b/demos/llm_routing/model_alias_routing/config_with_aliases.yaml @@ -28,7 +28,7 @@ model_providers: - model: anthropic/* access_key: $ANTHROPIC_API_KEY - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY - model: anthropic/claude-3-haiku-20240307 @@ -71,7 +71,7 @@ model_aliases: # Alias for creative tasks -> Claude model arch.creative.v1: - target: claude-sonnet-4-20250514 + target: claude-sonnet-4-6 # Alias for quick responses -> fast model arch.fast.v1: @@ -85,7 +85,7 @@ model_aliases: target: gpt-5-mini-2025-08-07 creative-model: - target: claude-sonnet-4-20250514 + target: claude-sonnet-4-6 coding-model: target: us.amazon.nova-premier-v1:0 diff --git a/demos/llm_routing/model_routing_service/README.md b/demos/llm_routing/model_routing_service/README.md index eaec32c7..e7064969 100644 --- a/demos/llm_routing/model_routing_service/README.md +++ b/demos/llm_routing/model_routing_service/README.md @@ -33,7 +33,7 @@ routing_preferences: - name: code_generation description: generating new code, writing functions, or creating boilerplate models: - - anthropic/claude-sonnet-4-20250514 + - anthropic/claude-sonnet-4-6 - openai/gpt-4o ``` @@ -46,7 +46,7 @@ When a request arrives, Plano: ``` 1. Request arrives → "Write binary search in Python" 2. Plano-Orchestrator classifies → route: "code_generation" -3. Response → models: ["anthropic/claude-sonnet-4-20250514", "openai/gpt-4o"] +3. Response → models: ["anthropic/claude-sonnet-4-6", "openai/gpt-4o"] ``` No match? Plano-Orchestrator returns an empty route → client falls back to the model in the original request. @@ -98,7 +98,7 @@ curl http://localhost:12000/routing/v1/chat/completions \ Response: ```json { - "models": ["anthropic/claude-sonnet-4-20250514", "openai/gpt-4o"], + "models": ["anthropic/claude-sonnet-4-6", "openai/gpt-4o"], "route": "code_generation", "trace_id": "c16d1096c1af4a17abb48fb182918a88" } @@ -124,7 +124,7 @@ curl http://localhost:12000/routing/v1/chat/completions \ Response (first call): ```json { - "model": "anthropic/claude-sonnet-4-20250514", + "model": "anthropic/claude-sonnet-4-6", "route": "code_generation", "trace_id": "c16d1096c1af4a17abb48fb182918a88", "session_id": "my-session-123", @@ -146,7 +146,7 @@ curl http://localhost:12000/routing/v1/chat/completions \ Response (pinned): ```json { - "model": "anthropic/claude-sonnet-4-20250514", + "model": "anthropic/claude-sonnet-4-6", "route": "code_generation", "trace_id": "a1b2c3d4e5f6...", "session_id": "my-session-123", @@ -233,7 +233,7 @@ kubectl rollout restart deployment/plano --- 1. Code generation query (OpenAI format) --- { - "models": ["anthropic/claude-sonnet-4-20250514", "openai/gpt-4o"], + "models": ["anthropic/claude-sonnet-4-6", "openai/gpt-4o"], "route": "code_generation", "trace_id": "c16d1096c1af4a17abb48fb182918a88" } @@ -254,14 +254,14 @@ kubectl rollout restart deployment/plano --- 4. Code generation query (Anthropic format) --- { - "models": ["anthropic/claude-sonnet-4-20250514", "openai/gpt-4o"], + "models": ["anthropic/claude-sonnet-4-6", "openai/gpt-4o"], "route": "code_generation", "trace_id": "26be822bbdf14a3ba19fe198e55ea4a9" } --- 7. Session pinning - first call (fresh routing decision) --- { - "models": ["anthropic/claude-sonnet-4-20250514", "openai/gpt-4o"], + "models": ["anthropic/claude-sonnet-4-6", "openai/gpt-4o"], "route": "code_generation", "trace_id": "f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6", "session_id": "demo-session-001", @@ -271,7 +271,7 @@ kubectl rollout restart deployment/plano --- 8. Session pinning - second call (same session, pinned) --- Notice: same model returned with "pinned": true, routing was skipped { - "model": "anthropic/claude-sonnet-4-20250514", + "model": "anthropic/claude-sonnet-4-6", "route": "code_generation", "trace_id": "a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4", "session_id": "demo-session-001", diff --git a/demos/llm_routing/model_routing_service/config.yaml b/demos/llm_routing/model_routing_service/config.yaml index 0bcf658d..0b4b3a21 100644 --- a/demos/llm_routing/model_routing_service/config.yaml +++ b/demos/llm_routing/model_routing_service/config.yaml @@ -13,7 +13,7 @@ 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: @@ -26,5 +26,5 @@ routing_preferences: - name: code_generation description: generating new code, writing functions, or creating boilerplate models: - - anthropic/claude-sonnet-4-20250514 + - anthropic/claude-sonnet-4-6 - openai/gpt-4o diff --git a/demos/llm_routing/model_routing_service/config_k8s.yaml b/demos/llm_routing/model_routing_service/config_k8s.yaml index 49f452a9..1f3959af 100644 --- a/demos/llm_routing/model_routing_service/config_k8s.yaml +++ b/demos/llm_routing/model_routing_service/config_k8s.yaml @@ -23,7 +23,7 @@ model_providers: - name: complex_reasoning description: complex reasoning tasks, multi-step analysis, or detailed explanations - - 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/demos/llm_routing/model_routing_service/demo.sh b/demos/llm_routing/model_routing_service/demo.sh index dafd60b3..1a3ad23b 100755 --- a/demos/llm_routing/model_routing_service/demo.sh +++ b/demos/llm_routing/model_routing_service/demo.sh @@ -102,7 +102,7 @@ curl -s "$PLANO_URL/routing/v1/chat/completions" \ { "name": "coding", "description": "code generation, writing functions, debugging", - "models": ["anthropic/claude-sonnet-4-20250514", "openai/gpt-4o", "openai/gpt-4o-mini"], + "models": ["anthropic/claude-sonnet-4-6", "openai/gpt-4o", "openai/gpt-4o-mini"], "selection_policy": {"prefer": "fastest"} } ] diff --git a/demos/llm_routing/model_routing_service/metrics_server.py b/demos/llm_routing/model_routing_service/metrics_server.py index b37f97fb..a00fb81c 100644 --- a/demos/llm_routing/model_routing_service/metrics_server.py +++ b/demos/llm_routing/model_routing_service/metrics_server.py @@ -12,13 +12,13 @@ from http.server import HTTPServer, BaseHTTPRequestHandler PROMETHEUS_METRICS = """\ # HELP model_latency_p95_seconds P95 request latency in seconds per model # TYPE model_latency_p95_seconds gauge -model_latency_p95_seconds{model_name="anthropic/claude-sonnet-4-20250514"} 0.85 +model_latency_p95_seconds{model_name="anthropic/claude-sonnet-4-6"} 0.85 model_latency_p95_seconds{model_name="openai/gpt-4o"} 1.20 model_latency_p95_seconds{model_name="openai/gpt-4o-mini"} 0.40 """.encode() COST_DATA = { - "anthropic/claude-sonnet-4-20250514": { + "anthropic/claude-sonnet-4-6": { "input_per_million": 3.0, "output_per_million": 15.0, }, diff --git a/demos/llm_routing/model_routing_service/test.rest b/demos/llm_routing/model_routing_service/test.rest index b41d75f2..b780d0c0 100644 --- a/demos/llm_routing/model_routing_service/test.rest +++ b/demos/llm_routing/model_routing_service/test.rest @@ -30,7 +30,7 @@ POST http://localhost:12000/routing/v1/messages Content-Type: application/json { - "model": "claude-sonnet-4-20250514", + "model": "claude-sonnet-4-6", "max_tokens": 1024, "messages": [{"role": "user", "content": "Write a REST API in Go using Gin"}] } diff --git a/demos/llm_routing/preference_based_routing/README.md b/demos/llm_routing/preference_based_routing/README.md index 5bbcab13..1ea037b8 100644 --- a/demos/llm_routing/preference_based_routing/README.md +++ b/demos/llm_routing/preference_based_routing/README.md @@ -3,7 +3,7 @@ This demo shows how you can use user preferences to route user prompts to approp ## How to start the demo -Make sure you have Plano CLI installed (`pip install planoai==0.4.22` or `uv tool install planoai==0.4.22`). +Make sure you have Plano CLI installed (`pip install planoai==0.4.27` or `uv tool install planoai==0.4.27`). ```bash cd demos/llm_routing/preference_based_routing diff --git a/demos/llm_routing/preference_based_routing/config.yaml b/demos/llm_routing/preference_based_routing/config.yaml index 38e8920a..4c82f869 100644 --- a/demos/llm_routing/preference_based_routing/config.yaml +++ b/demos/llm_routing/preference_based_routing/config.yaml @@ -17,7 +17,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/docs/requirements.txt b/docs/requirements.txt index 712bbf76..643f0d4b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,4 @@ sphinx_copybutton==0.5.2 -sphinxawesome-theme +sphinxawesome-theme<6.0.0 sphinx_sitemap sphinx_design -sphinxawesome_theme diff --git a/docs/routing-api.md b/docs/routing-api.md index 4d1d6a8e..8bbf7ce9 100644 --- a/docs/routing-api.md +++ b/docs/routing-api.md @@ -21,7 +21,7 @@ POST /v1/chat/completions { "name": "code generation", "description": "generating new code snippets", - "models": ["anthropic/claude-sonnet-4-20250514", "openai/gpt-4o", "openai/gpt-4o-mini"] + "models": ["anthropic/claude-sonnet-4-6", "openai/gpt-4o", "openai/gpt-4o-mini"] }, { "name": "general questions", @@ -55,7 +55,7 @@ POST /v1/chat/completions ```json { "models": [ - "anthropic/claude-sonnet-4-20250514", + "anthropic/claude-sonnet-4-6", "openai/gpt-4o", "openai/gpt-4o-mini" ], @@ -100,7 +100,7 @@ Requires `version: v0.4.0` or above. Models listed under `routing_preferences` m version: v0.4.0 model_providers: - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY - model: openai/gpt-4o access_key: $OPENAI_API_KEY @@ -112,7 +112,7 @@ routing_preferences: - name: code generation description: generating new code snippets or boilerplate models: - - anthropic/claude-sonnet-4-20250514 + - anthropic/claude-sonnet-4-6 - openai/gpt-4o - name: general questions @@ -149,7 +149,7 @@ Response when pinned: ```json { - "models": ["anthropic/claude-sonnet-4-20250514"], + "models": ["anthropic/claude-sonnet-4-6"], "route": "code generation", "trace_id": "...", "session_id": "a1b2c3d4-5678-...", diff --git a/docs/source/concepts/llm_providers/supported_providers.rst b/docs/source/concepts/llm_providers/supported_providers.rst index 60f468e0..43d5e42d 100644 --- a/docs/source/concepts/llm_providers/supported_providers.rst +++ b/docs/source/concepts/llm_providers/supported_providers.rst @@ -179,14 +179,14 @@ Anthropic - model: anthropic/* access_key: $ANTHROPIC_API_KEY - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_PROD_API_KEY routing_preferences: - name: code_generation description: generating new code snippets, functions, or boilerplate based on user prompts or requirements models: - - anthropic/claude-sonnet-4-20250514 + - anthropic/claude-sonnet-4-6 DeepSeek ~~~~~~~~ @@ -432,6 +432,9 @@ Moonshot AI * - Model Name - Model ID for Config - Description + * - Kimi for Coding + - ``moonshotai/kimi-for-coding`` + - Kimi Code API model for agentic coding (use with ``base_url: https://api.kimi.com/coding/v1``) * - Kimi K2 Preview - ``moonshotai/kimi-k2-0905-preview`` - Foundation model optimized for agentic tasks with 32B activated parameters @@ -447,6 +450,13 @@ Moonshot AI .. code-block:: yaml llm_providers: + # Kimi Code API (Claude Code / agentic clients via Plano translation) + - model: moonshotai/kimi-for-coding + access_key: $MOONSHOTAI_API_KEY + base_url: https://api.kimi.com/coding/v1 + headers: + User-Agent: "KimiCLI/1.3" + # Latest K2 models for agentic tasks - model: moonshotai/kimi-k2-0905-preview access_key: $MOONSHOTAI_API_KEY @@ -813,7 +823,7 @@ You can configure specific models with custom settings even when using wildcards # Override specific model with custom settings # This model will NOT be included in the wildcard expansion above - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_PROD_API_KEY # Another specific override @@ -824,7 +834,7 @@ You can configure specific models with custom settings even when using wildcards - name: code_generation description: generating new code snippets, functions, or boilerplate based on user prompts or requirements models: - - anthropic/claude-sonnet-4-20250514 + - anthropic/claude-sonnet-4-6 **Custom Provider Wildcards:** diff --git a/docs/source/concepts/prompt_target.rst b/docs/source/concepts/prompt_target.rst index 9514054a..d066925e 100644 --- a/docs/source/concepts/prompt_target.rst +++ b/docs/source/concepts/prompt_target.rst @@ -2,6 +2,15 @@ Prompt Target ============= + +.. deprecated:: v0.4.22 + **Prompt Targets are deprecated and no longer actively maintained.** This concept is + retained for existing users on older Plano configurations, but new applications should + not adopt it. For deterministic, task-specific workloads, use :ref:`Agents ` + together with :ref:`Function Calling ` instead. The + ``prompt_targets`` configuration block and related CLI commands will continue to + function for now, but may be removed in a future release. + A Prompt Target is a deterministic, task-specific backend function or API endpoint that your application calls via Plano. Unlike agents (which handle wide-ranging, open-ended tasks), prompt targets are designed for focused, specific workloads where Plano can add value through input clarification and validation. diff --git a/docs/source/concepts/signals.rst b/docs/source/concepts/signals.rst index d5e25e7e..1ae116a3 100644 --- a/docs/source/concepts/signals.rst +++ b/docs/source/concepts/signals.rst @@ -334,35 +334,6 @@ Emitted per category, only when ``count > 0``. One ``.count`` and one * - ``signals.environment.exhaustion.severity`` - " -Legacy attributes (deprecated, still emitted) ---------------------------------------------- - -The following aggregate keys pre-date the paper taxonomy and are still -emitted for one release so existing dashboards keep working. They are -derived from the layered counts above and will be removed in a future -release. Migrate to the layered keys when convenient. - -.. list-table:: - :header-rows: 1 - :widths: 50 50 - - * - Legacy attribute - - Layered equivalent - * - ``signals.follow_up.repair.count`` - - ``signals.interaction.misalignment.count`` - * - ``signals.follow_up.repair.ratio`` - - (computed: ``misalignment.count / max(user_turns, 1)``) - * - ``signals.frustration.count`` - - Count of ``disengagement.negative_stance`` instances - * - ``signals.frustration.severity`` - - Derived severity bucket of the above - * - ``signals.repetition.count`` - - ``signals.interaction.stagnation.count`` - * - ``signals.escalation.requested`` - - True if any ``disengagement.escalation`` or ``disengagement.quit`` fired - * - ``signals.positive_feedback.count`` - - ``signals.interaction.satisfaction.count`` - Span Events =========== @@ -520,11 +491,6 @@ event:: signals.interaction.disengagement.count = 6 signals.interaction.disengagement.severity = 3 - # Legacy (deprecated, emitted while dual-emit is on) - signals.frustration.count = 4 - signals.frustration.severity = 2 - signals.escalation.requested = true - # Per-instance span events event: signal.interaction.disengagement.escalation signal.type = "interaction.disengagement.escalation" @@ -537,8 +503,7 @@ Building Dashboards =================== Use signal attributes to build monitoring dashboards in Grafana, Honeycomb, -Datadog, etc. Prefer the layered keys — they align with the paper taxonomy -and will outlive the legacy keys. +Datadog, etc. The layered keys align with the paper taxonomy. - **Quality distribution**: Count of traces by ``signals.quality`` - **P95 turn count**: 95th percentile of ``signals.turn_count`` diff --git a/docs/source/conf.py b/docs/source/conf.py index 4a739313..74261b82 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,7 +17,7 @@ from sphinxawesome_theme.postprocess import Icons project = "Plano Docs" copyright = "2026, Katanemo Labs, a DigitalOcean Company" author = "Katanemo Labs, Inc" -release = " v0.4.22" +release = " v0.4.27" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/source/get_started/overview.rst b/docs/source/get_started/overview.rst index d8bcb779..f569feb0 100644 --- a/docs/source/get_started/overview.rst +++ b/docs/source/get_started/overview.rst @@ -57,10 +57,10 @@ Deep dive into essential ideas and mechanisms behind Plano: Explore Plano's LLM integration options - .. grid-item-card:: :octicon:`workflow` Prompt Target + .. grid-item-card:: :octicon:`workflow` Prompt Target (Deprecated) :link: ../concepts/prompt_target.html - Understand how Plano handles prompts + Deprecated — kept for existing users. New apps should use Agents. Guides diff --git a/docs/source/get_started/quickstart.rst b/docs/source/get_started/quickstart.rst index 45470cae..a7bd4e9b 100644 --- a/docs/source/get_started/quickstart.rst +++ b/docs/source/get_started/quickstart.rst @@ -43,7 +43,7 @@ Plano's CLI allows you to manage and interact with the Plano efficiently. To ins .. code-block:: console - $ uv tool install planoai==0.4.22 + $ uv tool install planoai==0.4.27 **Option 2: Install with pip (Traditional)** @@ -51,7 +51,7 @@ Plano's CLI allows you to manage and interact with the Plano efficiently. To ins $ python -m venv venv $ source venv/bin/activate # On Windows, use: venv\Scripts\activate - $ pip install planoai==0.4.22 + $ pip install planoai==0.4.27 .. _llm_routing_quickstart: @@ -247,6 +247,11 @@ You can then ask a follow-up like "Also book me a hotel near JFK" and Plano-Orch Deterministic API calls with prompt targets ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. deprecated:: v0.4.22 + :ref:`Prompt Targets ` are deprecated and no longer actively + maintained. The walkthrough below is preserved for users on existing configs; + new applications should use :ref:`Agents ` instead. + Next, we'll show Plano's deterministic API calling using a single prompt target. We'll build a currency exchange backend powered by `https://api.frankfurter.dev/`, assuming USD as the base currency. Step 1. Create plano config file diff --git a/docs/source/guides/function_calling.rst b/docs/source/guides/function_calling.rst index af2a26a8..6242216d 100644 --- a/docs/source/guides/function_calling.rst +++ b/docs/source/guides/function_calling.rst @@ -6,6 +6,12 @@ Function Calling **Function Calling** is a powerful feature in Plano that allows your application to dynamically execute backend functions or services based on user prompts. This enables seamless integration between natural language interactions and backend operations, turning user inputs into actionable results. +.. deprecated:: v0.4.22 + The prompt-target based workflow shown below (see :ref:`Step 2 `) + is deprecated. :ref:`Prompt Targets ` are no longer actively + maintained and may be removed in a future release. For new function-calling + workloads, prefer :ref:`Agents ` with tool definitions. + What is Function Calling? ------------------------- diff --git a/docs/source/guides/llm_router.rst b/docs/source/guides/llm_router.rst index b66c01f2..422e3a49 100644 --- a/docs/source/guides/llm_router.rst +++ b/docs/source/guides/llm_router.rst @@ -209,6 +209,178 @@ Clients can let the router decide or still specify aliases: ) +.. _cost_latency_aware_selection: + +Cost- and latency-aware selection +--------------------------------- + +When a route lists more than one candidate model, you can let Plano reorder that +candidate pool using **live cost or latency data** instead of relying solely on the +order you wrote them in. This is controlled per route with ``selection_policy`` and +backed by one or more ``model_metrics_sources``. + +This is useful when several models are equally capable for a route and you want Plano +to always reach for the cheapest (or fastest) option first, with the others kept as +fallbacks. + +Selection policy +~~~~~~~~~~~~~~~~~ + +Attach an optional ``selection_policy`` to any entry in ``routing_preferences``: + +.. code-block:: yaml + :caption: Per-route selection policy + + routing_preferences: + - name: code review + description: reviewing, analyzing, and suggesting improvements to existing code + models: + - anthropic/claude-sonnet-4-5 + - groq/llama-3.3-70b-versatile + selection_policy: + prefer: cheapest # cheapest | fastest | none + +``prefer`` accepts: + +- ``cheapest`` — order candidates by total price (input + output rate) ascending, using a ``cost`` metrics source. +- ``fastest`` — order candidates by observed latency ascending, using a ``latency`` metrics source. +- ``none`` (default) — keep the order you declared; no reordering. + +Models that have no data in the selected source are ranked **last**, in their original +order, so routing always degrades gracefully rather than dropping a candidate. + +Configuring the pricing source +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``cheapest`` routing needs a price catalog. Plano's **default pricing provider is +DigitalOcean** — its GenAI model catalog is public (no API key, no signup), so cost data +is available out of the box and is what ``planoai obs`` uses if you don't configure +anything. The pricing source is fully swappable: point Plano at `models.dev `_, +or at **any endpoint that exposes a supported pricing structure**. + +The ``provider`` field selects which response schema Plano expects (and therefore how it +parses the catalog); the optional ``url`` lets you override the endpoint — for example to +use a mirror, a cached copy, or an internal catalog service that returns the same shape. + +.. list-table:: + :header-rows: 1 + :widths: 18 34 28 20 + + * - ``provider`` + - Default catalog URL + - Key format + - Expected structure + * - ``digitalocean`` *(default)* + - DigitalOcean GenAI model catalog + - ``lowercase(creator)/model_id`` + - ``{ data: [ { model_id, pricing: { input_price_per_million, output_price_per_million } } ] }`` + * - ``models.dev`` + - ``https://models.dev/api.json`` + - ``creator/model`` (e.g. ``anthropic/claude-sonnet-4-5``) + - ``{ : { models: { : { cost: { input, output } } } } }`` + +Because the source is selected per ``provider``, switching is a one-line change. To stay +on the default DigitalOcean catalog you can omit ``model_metrics_sources`` entirely for +``planoai obs``, or declare it explicitly for routing: + +.. code-block:: yaml + :caption: Default cost source (DigitalOcean) + + model_metrics_sources: + - type: cost + provider: digitalocean # default; uses the public DO GenAI catalog + +To switch to models.dev — an open, community-maintained catalog covering a broad range of +providers and models — change the ``provider`` (and optionally ``url``): + +.. code-block:: yaml + :caption: Cost source backed by models.dev + + model_metrics_sources: + - type: cost + provider: models.dev # models.dev | digitalocean + url: https://models.dev/api.json # optional; defaults per provider + refresh_interval: 3600 # optional, seconds; refetch on this interval + model_aliases: # optional; see below + openai/gpt-oss-120b: openai/gpt-4o + +To use your own endpoint, pick the ``provider`` whose structure your endpoint matches and +override ``url`` — Plano parses the response with that provider's schema: + +.. code-block:: yaml + :caption: Custom endpoint exposing the DigitalOcean catalog structure + + model_metrics_sources: + - type: cost + provider: digitalocean # selects the DO response schema + url: https://catalog.internal.example.com/pricing + +.. note:: + The cost metric used for ranking is the sum of the input and output per-million-token + rates — a relative signal for ordering candidates, not a per-request bill. For actual + per-request cost, see the observability console below. + +Matching catalog keys to your models +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The router looks up each candidate model by the exact name you use in +``routing_preferences`` (e.g. ``anthropic/claude-sonnet-4-5``). models.dev keys models as +``creator/model``, which lines up with Plano's ``provider/model`` naming, so most models +match automatically. + +When a catalog key does not match your model name — for example a version skew, or an +open-weight model you serve under a different provider — use ``model_aliases`` to map the +**catalog key** to the **Plano model name** used in your routing preferences: + +.. code-block:: yaml + + model_metrics_sources: + - type: cost + provider: models.dev + model_aliases: + # catalog key : plano model name + openai/gpt-oss-120b: openai/gpt-4o + +Latency source +~~~~~~~~~~~~~~~ + +``fastest`` routing reads observed latency from a Prometheus instance. Provide the query +that returns a per-model latency value (lower is faster), labelled by ``model_name``: + +.. code-block:: yaml + :caption: Latency source backed by Prometheus + + model_metrics_sources: + - type: latency + provider: prometheus + url: http://prometheus:9090 + query: avg by (model_name) (rate(plano_llm_latency_seconds_sum[5m])) + refresh_interval: 60 + +You can declare both a ``cost`` and a ``latency`` source at the same time; each route +picks whichever it needs based on its ``selection_policy``. + +Cost in the observability console +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``planoai obs`` displays a per-request USD cost column derived from the same pricing +catalog. By default it reads the ``cost`` source from your config (the first +``type: cost`` entry under ``model_metrics_sources``); you can also override it on the +command line: + +.. code-block:: bash + + # Use the cost source from ./config.yaml (default) + planoai obs + + # Or override the provider / endpoint explicitly + planoai obs --pricing-provider models.dev + planoai obs --pricing-url https://models.dev/api.json + +If no source is configured and no override is given, ``planoai obs`` falls back to the +DigitalOcean catalog so the cost column still populates out of the box. + + Plano-Orchestrator ------------------- Plano-Orchestrator is a **preference-based routing model** specifically designed to address the limitations of traditional LLM routing. It delivers production-ready performance with low latency and high accuracy while solving key routing challenges. diff --git a/docs/source/guides/observability/tracing.rst b/docs/source/guides/observability/tracing.rst index b3660168..6e0ee23b 100644 --- a/docs/source/guides/observability/tracing.rst +++ b/docs/source/guides/observability/tracing.rst @@ -153,7 +153,7 @@ In your observability platform (Jaeger, Grafana Tempo, Datadog, etc.), filter tr - Find external issues: ``signals.environment.exhaustion.count > 0`` - Find inefficient flows: ``signals.efficiency_score < 0.5`` -For complete details on all 20 leaf signal types, severity scheme, legacy attribute deprecation, and best practices, see the :doc:`../../concepts/signals` guide. +For complete details on all 20 leaf signal types, severity scheme, and best practices, see the :doc:`../../concepts/signals` guide. Custom Span Attributes @@ -259,6 +259,86 @@ Request headers:: Result: no attributes are captured from ``X-Other-User-Id``. +Exporting Telemetry Anywhere +---------------------------- + +Beyond the OTLP/gRPC collector, Plano can stream LLM telemetry directly to +third-party observability backends through ``tracing.exporters``. The list is +provider-agnostic: each entry is tagged by its ``type`` and points at a URL, so +new destinations can be added without changing anything else. Exporters run in +addition to ``opentracing_grpc_endpoint`` — you can use one, the other, or both. + +PostHog +~~~~~~~ + +PostHog is supported as a first-class integration. Every LLM call is captured as +a PostHog `$ai_generation `_ +event and POSTed to PostHog's capture API. Setup is intentionally minimal — +point at your PostHog URL and project token:: + + tracing: + random_sampling: 100 + exporters: + - type: posthog + url: https://us.i.posthog.com # /batch/ is appended automatically + api_key: $POSTHOG_API_KEY # PostHog project token (env expansion supported) + distinct_id_header: x-user-id # optional; omit for anonymous capture + capture_messages: false # optional; send user message as $ai_input + +That's all that's required. When ``random_sampling`` is greater than ``0`` and at +least one exporter (or ``opentracing_grpc_endpoint``) is configured, tracing is +enabled and ``$ai_generation`` events begin flowing. They appear under PostHog's +**AI Observability** in the Traces and Generations tabs. + +**Captured properties** + +Plano maps span data onto PostHog ``$ai_*`` properties: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - PostHog property + - Source + * - ``$ai_model`` + - Resolved upstream model (``llm.model``) + * - ``$ai_provider`` + - Provider derived from the resolved model (``llm.provider``) + * - ``$ai_latency`` + - Total call duration in seconds (``llm.duration_ms``) + * - ``$ai_time_to_first_token`` + - Time to first token in seconds, streaming only + * - ``$ai_input_tokens`` / ``$ai_output_tokens`` + - Prompt / completion token usage + * - ``$ai_http_status`` / ``$ai_is_error`` + - Upstream HTTP status and error flag + * - ``$ai_trace_id`` / ``$ai_parent_id`` + - Trace and parent span identifiers + * - ``distinct_id`` + - Value of ``distinct_id_header`` (else anonymous) + +**Identifying users** + +Set ``distinct_id_header`` to the request header carrying your user identity +(for example ``x-user-id``). When present, Plano stamps the value as the PostHog +``distinct_id``. When the header is missing — or ``distinct_id_header`` is not +configured — the event is captured anonymously (``$process_person_profile`` is +set to ``false``), matching PostHog's anonymous vs. identified semantics. + +**Capturing message content** + +By default Plano does not send prompt content off-box. Set +``capture_messages: true`` to include the (truncated) user message preview as +``$ai_input``. Leave it ``false`` when prompt content must not leave your data +plane. + +**Multiple destinations** + +``exporters`` is a list, so you can fan out to several backends (and combine +with an OTLP collector). A common use is shipping to multiple PostHog instances +(for example separate EU and US projects for data-residency). + + Benefits of Using ``Traceparent`` Headers ----------------------------------------- diff --git a/docs/source/resources/cli_reference.rst b/docs/source/resources/cli_reference.rst index 585f29b9..811c5e29 100644 --- a/docs/source/resources/cli_reference.rst +++ b/docs/source/resources/cli_reference.rst @@ -16,7 +16,6 @@ Quick Navigation - :ref:`cli_reference_logs` - :ref:`cli_reference_init` - :ref:`cli_reference_trace` -- :ref:`cli_reference_prompt_targets` - :ref:`cli_reference_cli_agent` @@ -260,24 +259,6 @@ Inspect request traces from the local OTLP listener. - ``--list`` cannot be combined with a specific trace-id target. -.. _cli_reference_prompt_targets: - -planoai prompt_targets ----------------------- - -Generate prompt-target metadata from Python methods. - -**Synopsis** - -.. code-block:: console - - $ planoai prompt_targets --file - -**Options** - -- ``--file, --f ``: required path to a ``.py`` source file. - - .. _cli_reference_cli_agent: planoai cli_agent diff --git a/docs/source/resources/configuration_reference.rst b/docs/source/resources/configuration_reference.rst index 8e040f75..298f143d 100644 --- a/docs/source/resources/configuration_reference.rst +++ b/docs/source/resources/configuration_reference.rst @@ -7,6 +7,29 @@ The following is a complete reference of the ``plano_config.yml`` that controls the Plano gateway. This where you enable capabilities like routing to upstream LLm providers, defining prompt_targets where prompts get routed to, apply guardrails, and enable critical agent observability features. +Model provider headers +---------------------- + +Each entry under ``model_providers`` (or the legacy ``llm_providers`` alias) may include a ``headers`` map of extra +HTTP headers that Plano adds to upstream LLM requests. Plano applies these headers after it sets authentication from +``access_key`` or ``passthrough_auth``, so you can supply provider-specific metadata without replacing the configured +credentials. + +- **Type:** map of strings (header name → value) +- **Optional:** yes +- **Common uses:** required ``User-Agent`` values, organization or account identifiers, or other headers some APIs expect + +.. code-block:: yaml + + model_providers: + - model: moonshotai/kimi-for-coding + access_key: $MOONSHOTAI_API_KEY + base_url: https://api.kimi.com/coding/v1 + headers: + User-Agent: "KimiCLI/1.3" + +The example below includes this and other provider options in context. + .. literalinclude:: includes/plano_config_full_reference.yaml :language: yaml :linenos: diff --git a/docs/source/resources/deployment.rst b/docs/source/resources/deployment.rst index c8246f8c..afc46755 100644 --- a/docs/source/resources/deployment.rst +++ b/docs/source/resources/deployment.rst @@ -65,7 +65,7 @@ Create a ``docker-compose.yml`` file with the following configuration: # docker-compose.yml services: plano: - image: katanemo/plano:0.4.22 + image: katanemo/plano:0.4.27 container_name: plano ports: - "10000:10000" # ingress (client -> plano) @@ -153,7 +153,7 @@ Create a ``plano-deployment.yaml``: spec: containers: - name: plano - image: katanemo/plano:0.4.22 + image: katanemo/plano:0.4.27 ports: - containerPort: 12000 # LLM gateway (chat completions, model routing) name: llm-gateway diff --git a/docs/source/resources/includes/plano_config_full_reference.yaml b/docs/source/resources/includes/plano_config_full_reference.yaml index 99eb4510..e370aeda 100644 --- a/docs/source/resources/includes/plano_config_full_reference.yaml +++ b/docs/source/resources/includes/plano_config_full_reference.yaml @@ -47,6 +47,14 @@ model_providers: http_host: api.custom-provider.com access_key: $CUSTOM_API_KEY + # headers: optional map of extra HTTP headers sent on upstream requests (after auth). + # Use for provider-specific requirements such as User-Agent, org IDs, or account headers. + - model: moonshotai/kimi-for-coding + access_key: $MOONSHOTAI_API_KEY + base_url: https://api.kimi.com/coding/v1 + headers: + User-Agent: "KimiCLI/1.3" + # Model aliases - use friendly names instead of full provider model names model_aliases: fast-llm: @@ -78,6 +86,24 @@ routing_preferences: selection_policy: prefer: cheapest +# model_metrics_sources: external catalogs the router reads to reorder candidate +# models for selection_policy.prefer. A `cost` source ranks `prefer: cheapest`; +# a `latency` source ranks `prefer: fastest`. Both are optional. +model_metrics_sources: + # Cost catalog. provider: models.dev | digitalocean (default url per provider). + - type: cost + provider: models.dev + url: https://models.dev/api.json # optional; omit to use the provider default + refresh_interval: 3600 # optional, seconds + model_aliases: # optional: catalog key -> Plano model name + openai/gpt-oss-120b: openai/gpt-4o + # Latency catalog (Prometheus). Used for selection_policy.prefer: fastest. + - type: latency + provider: prometheus + url: http://prometheus:9090 + query: avg by (model_name) (rate(plano_llm_latency_seconds_sum[5m])) + refresh_interval: 60 + # HTTP listeners - entry points for agent routing, prompt targets, and direct LLM access listeners: # Agent listener for routing requests to multiple agents @@ -235,3 +261,16 @@ tracing: static: environment: production service.team: platform + # Provider-agnostic export destinations. LLM spans are streamed to each of + # these in addition to any opentracing_grpc_endpoint above. + exporters: + # PostHog AI observability: each LLM call is captured as an $ai_generation event. + - type: posthog + # PostHog host. The /batch/ capture path is appended automatically. + url: https://us.i.posthog.com + # PostHog project API key (token). Supports $ENV_VAR expansion. + api_key: $POSTHOG_API_KEY + # Optional: request header used as the PostHog distinct_id. Omit for anonymous capture. + distinct_id_header: x-user-id + # Optional: include the (truncated) user message as $ai_input. Defaults to false. + capture_messages: false diff --git a/docs/source/resources/includes/plano_config_full_reference_rendered.yaml b/docs/source/resources/includes/plano_config_full_reference_rendered.yaml index e2ab9110..9cabf183 100644 --- a/docs/source/resources/includes/plano_config_full_reference_rendered.yaml +++ b/docs/source/resources/includes/plano_config_full_reference_rendered.yaml @@ -88,6 +88,18 @@ listeners: port: 443 protocol: https provider_interface: openai + - access_key: $MOONSHOTAI_API_KEY + base_url: https://api.kimi.com/coding/v1 + base_url_path_prefix: /coding/v1 + cluster_name: moonshotai_api.kimi.com + endpoint: api.kimi.com + headers: + User-Agent: KimiCLI/1.3 + model: kimi-for-coding + name: moonshotai/kimi-for-coding + port: 443 + protocol: https + provider_interface: moonshotai name: model_1 output_filters: - input_guards @@ -103,6 +115,18 @@ model_aliases: target: gpt-4o-mini smart-llm: target: gpt-4o +model_metrics_sources: +- model_aliases: + openai/gpt-oss-120b: openai/gpt-4o + provider: models.dev + refresh_interval: 3600 + type: cost + url: https://models.dev/api.json +- provider: prometheus + query: avg by (model_name) (rate(plano_llm_latency_seconds_sum[5m])) + refresh_interval: 60 + type: latency + url: http://prometheus:9090 model_providers: - access_key: $OPENAI_API_KEY default: true @@ -144,6 +168,18 @@ model_providers: port: 443 protocol: https provider_interface: openai +- access_key: $MOONSHOTAI_API_KEY + base_url: https://api.kimi.com/coding/v1 + base_url_path_prefix: /coding/v1 + cluster_name: moonshotai_api.kimi.com + endpoint: api.kimi.com + headers: + User-Agent: KimiCLI/1.3 + model: kimi-for-coding + name: moonshotai/kimi-for-coding + port: 443 + protocol: https + provider_interface: moonshotai - internal: true model: Plano-Orchestrator name: plano-orchestrator @@ -230,6 +266,12 @@ system_prompt: 'You are a helpful assistant. Always respond concisely and accura ' tracing: + exporters: + - api_key: $POSTHOG_API_KEY + capture_messages: false + distinct_id_header: x-user-id + type: posthog + url: https://us.i.posthog.com opentracing_grpc_endpoint: http://localhost:4317 random_sampling: 100 span_attributes: diff --git a/docs/source/resources/includes/plano_config_state_storage_example.yaml b/docs/source/resources/includes/plano_config_state_storage_example.yaml index 81a8d3a9..70297447 100644 --- a/docs/source/resources/includes/plano_config_state_storage_example.yaml +++ b/docs/source/resources/includes/plano_config_state_storage_example.yaml @@ -13,7 +13,7 @@ model_providers: default: true # Anthropic Models - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY # State storage configuration for v1/responses API diff --git a/skills/AGENTS.md b/skills/AGENTS.md index dab3144b..6aa08c5f 100644 --- a/skills/AGENTS.md +++ b/skills/AGENTS.md @@ -31,9 +31,8 @@ - [5.3 Use `planoai trace` to Inspect Routing Decisions](#use-planoai-trace-to-inspect-routing-decisions) - [Section 6: CLI Operations](#section-6) - [6.1 Follow the `planoai up` Validation Workflow Before Debugging Runtime Issues](#follow-the-planoai-up-validation-workflow-before-debugging-runtime-issues) - - [6.2 Generate Prompt Targets from Python Functions with `planoai generate_prompt_targets`](#generate-prompt-targets-from-python-functions-with-planoai-generateprompttargets) - - [6.3 Use `planoai cli_agent` to Connect Claude Code Through Plano](#use-planoai-cliagent-to-connect-claude-code-through-plano) - - [6.4 Use `planoai init` Templates to Bootstrap New Projects Correctly](#use-planoai-init-templates-to-bootstrap-new-projects-correctly) + - [6.2 Use `planoai cli_agent` to Connect Claude Code Through Plano](#use-planoai-cliagent-to-connect-claude-code-through-plano) + - [6.3 Use `planoai init` Templates to Bootstrap New Projects Correctly](#use-planoai-init-templates-to-bootstrap-new-projects-correctly) - [Section 7: Deployment & Security](#section-7) - [7.1 Understand Plano's Docker Network Topology for Agent URL Configuration](#understand-planos-docker-network-topology-for-agent-url-configuration) - [7.2 Use PostgreSQL State Storage for Multi-Turn Conversations in Production](#use-postgresql-state-storage-for-multi-turn-conversations-in-production) @@ -172,7 +171,7 @@ Plano translates requests between its internal format and each provider's API. T | Model prefix | Wire format | Example | |---|---|---| | `openai/*` | OpenAI | `openai/gpt-4o` | -| `anthropic/*` | Anthropic | `anthropic/claude-sonnet-4-20250514` | +| `anthropic/*` | Anthropic | `anthropic/claude-sonnet-4-6` | | `gemini/*` | Google Gemini | `gemini/gemini-2.0-flash` | | `mistral/*` | Mistral | `mistral/mistral-large-latest` | | `groq/*` | Groq | `groq/llama-3.3-70b-versatile` | @@ -200,7 +199,7 @@ model_providers: access_key: $OPENAI_API_KEY default: true - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY - model: gemini/gemini-2.0-flash @@ -263,7 +262,7 @@ model_providers: access_key: $OPENAI_API_KEY default: true - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY state_storage: @@ -432,7 +431,7 @@ model_providers: default: true - 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 model_aliases: @@ -443,7 +442,7 @@ model_aliases: target: gpt-4o # High capability — for complex reasoning plano.creative.v1: - target: claude-sonnet-4-20250514 # Strong creative writing and analysis + target: claude-sonnet-4-6 # Strong creative writing and analysis plano.v1: target: gpt-4o # Default production alias @@ -1377,99 +1376,7 @@ Reference: https://github.com/katanemo/archgw --- -### 6.2 Generate Prompt Targets from Python Functions with `planoai generate_prompt_targets` - -**Impact:** `MEDIUM` — Manually writing prompt_targets YAML for existing Python APIs is error-prone — the generator introspects function signatures and produces correct YAML automatically -**Tags:** `cli`, `generate`, `prompt-targets`, `python`, `code-generation` - -## Generate Prompt Targets from Python Functions with `planoai generate_prompt_targets` - -`planoai generate_prompt_targets` introspects Python function signatures and docstrings to generate `prompt_targets` YAML for your Plano config. This is the fastest way to expose existing Python APIs as LLM-callable functions without manually writing the YAML schema. - -**Python function requirements for generation:** -- Use simple type annotations: `int`, `float`, `bool`, `str`, `list`, `tuple`, `set`, `dict` -- Include a docstring describing what the function does (becomes the `description`) -- Complex Pydantic models must be flattened into primitive typed parameters first - -**Example Python file:** - -```python -# api.py - -def get_stock_quote(symbol: str, exchange: str = "NYSE") -> dict: - """Get the current stock price and trading data for a given stock symbol. - - Returns price, volume, market cap, and 24h change percentage. - """ - # Implementation calls stock API - pass - -def get_weather_forecast(city: str, days: int = 3, units: str = "celsius") -> dict: - """Get the weather forecast for a city. - - Returns temperature, precipitation, and conditions for the specified number of days. - """ - pass - -def search_flights(origin: str, destination: str, date: str, passengers: int = 1) -> list: - """Search for available flights between two airports on a given date. - - Date format: YYYY-MM-DD. Returns list of flight options with prices. - """ - pass -``` - -**Running the generator:** - -```bash -planoai generate_prompt_targets --file api.py -``` - -**Generated output (add to your config.yaml):** - -```yaml -prompt_targets: - - name: get_stock_quote - description: Get the current stock price and trading data for a given stock symbol. - parameters: - - name: symbol - type: str - required: true - - name: exchange - type: str - required: false - default: NYSE - # Add endpoint manually: - endpoint: - name: stock_api - path: /quote?symbol={symbol}&exchange={exchange} - - - name: get_weather_forecast - description: Get the weather forecast for a city. - parameters: - - name: city - type: str - required: true - - name: days - type: int - required: false - default: 3 - - name: units - type: str - required: false - default: celsius - endpoint: - name: weather_api - path: /forecast?city={city}&days={days}&units={units} -``` - -After generation, manually add the `endpoint` blocks pointing to your actual API. The generator produces the schema; you wire in the connectivity. - -Reference: https://github.com/katanemo/archgw - ---- - -### 6.3 Use `planoai cli_agent` to Connect Claude Code Through Plano +### 6.2 Use `planoai cli_agent` to Connect Claude Code Through Plano **Impact:** `MEDIUM-HIGH` — Running Claude Code directly against provider APIs bypasses Plano's routing, observability, and guardrails — cli_agent routes all Claude Code traffic through your configured Plano instance **Tags:** `cli`, `cli-agent`, `claude`, `coding-agent`, `integration` @@ -1512,7 +1419,7 @@ listeners: port: 12000 model_providers: - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY default: true @@ -1525,7 +1432,7 @@ routing_preferences: Writing code, debugging, code review, explaining concepts, answering programming questions, general development tasks. models: - - anthropic/claude-sonnet-4-20250514 + - anthropic/claude-sonnet-4-6 - anthropic/claude-opus-4-6 - name: complex architecture description: > @@ -1533,11 +1440,11 @@ routing_preferences: architectural decisions, performance optimization, security audits. models: - anthropic/claude-opus-4-6 - - anthropic/claude-sonnet-4-20250514 + - anthropic/claude-sonnet-4-6 model_aliases: claude.fast.v1: - target: claude-sonnet-4-20250514 + target: claude-sonnet-4-6 claude.smart.v1: target: claude-opus-4-6 @@ -1562,7 +1469,7 @@ Reference: [https://github.com/katanemo/archgw](https://github.com/katanemo/arch --- -### 6.4 Use `planoai init` Templates to Bootstrap New Projects Correctly +### 6.3 Use `planoai init` Templates to Bootstrap New Projects Correctly **Impact:** `MEDIUM` — Starting from a blank config.yaml leads to missing required fields and common structural mistakes — templates provide validated, idiomatic starting points **Tags:** `cli`, `init`, `templates`, `getting-started`, `project-setup` @@ -1931,7 +1838,7 @@ 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 # --- Shared routing_preferences (top-level, v0.4.0+) --- @@ -1944,11 +1851,11 @@ routing_preferences: description: Multi-step analysis, code generation, research synthesis models: - openai/gpt-4o - - anthropic/claude-sonnet-4-20250514 + - anthropic/claude-sonnet-4-6 - name: long documents description: Summarizing or analyzing very long documents, PDFs, transcripts models: - - anthropic/claude-sonnet-4-20250514 + - anthropic/claude-sonnet-4-6 - openai/gpt-4o # --- Listener 1: OpenAI-compatible API gateway --- diff --git a/skills/README.md b/skills/README.md index d941fb93..d2519882 100644 --- a/skills/README.md +++ b/skills/README.md @@ -63,7 +63,7 @@ After installation, these skills are available to your coding agent and can be i - `plano-agent-orchestration` - Agent registration and routing descriptions - `plano-filter-guardrails` - MCP filters, guardrail messaging, filter ordering - `plano-observability-debugging` - Tracing setup, span attributes, trace analysis -- `plano-cli-operations` - `planoai up`, `cli_agent`, init, prompt target generation +- `plano-cli-operations` - `planoai up`, `cli_agent`, init - `plano-deployment-security` - Docker networking, health checks, state storage - `plano-advanced-patterns` - Multi-listener architecture and prompt target schema design @@ -110,7 +110,7 @@ skills/ | 3 | `agent-` | Agent Orchestration | Descriptions, agent registration | | 4 | `filter-` | Filter Chains & Guardrails | Ordering, MCP integration, guardrails | | 5 | `observe-` | Observability & Debugging | Tracing, trace inspection, span attributes | -| 6 | `cli-` | CLI Operations | Startup, CLI agent, init, code generation | +| 6 | `cli-` | CLI Operations | Startup, CLI agent, init | | 7 | `deploy-` | Deployment & Security | Docker networking, state storage, health checks | | 8 | `advanced-` | Advanced Patterns | Prompt targets, rate limits, multi-listener | diff --git a/skills/plano-cli-operations/SKILL.md b/skills/plano-cli-operations/SKILL.md index da25db58..f9c37498 100644 --- a/skills/plano-cli-operations/SKILL.md +++ b/skills/plano-cli-operations/SKILL.md @@ -1,6 +1,6 @@ --- name: plano-cli-operations -description: Apply Plano CLI best practices. Use for startup troubleshooting, cli_agent workflows, prompt target generation, and template-based project bootstrapping. +description: Apply Plano CLI best practices. Use for startup troubleshooting, cli_agent workflows, and template-based project bootstrapping. license: Apache-2.0 metadata: author: katanemo @@ -15,20 +15,17 @@ Use this skill when the task is primarily operational and CLI-driven. - "Fix `planoai up` failures" - "Use `planoai cli_agent` with coding agents" -- "Generate prompt targets from Python functions" - "Bootstrap a project with `planoai init` templates" ## Apply These Rules - `cli-startup` - `cli-agent` -- `cli-generate` - `cli-init` ## Execution Checklist 1. Follow startup validation order before deep debugging. 2. Use `cli_agent` to route coding-agent traffic through Plano. -3. Generate prompt target schema, then wire endpoint details explicitly. -4. Start from templates for reliable first-time setup. -5. Provide a compact runbook with exact CLI commands. +3. Start from templates for reliable first-time setup. +4. Provide a compact runbook with exact CLI commands. diff --git a/skills/rules/advanced-multi-listener.md b/skills/rules/advanced-multi-listener.md index 81c8d4d9..764f3462 100644 --- a/skills/rules/advanced-multi-listener.md +++ b/skills/rules/advanced-multi-listener.md @@ -42,7 +42,7 @@ model_providers: - name: complex reasoning description: Multi-step analysis, code generation, research synthesis - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY routing_preferences: - name: long documents diff --git a/skills/rules/cli-agent.md b/skills/rules/cli-agent.md index e311e99e..e123765f 100644 --- a/skills/rules/cli-agent.md +++ b/skills/rules/cli-agent.md @@ -43,7 +43,7 @@ listeners: port: 12000 model_providers: - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY default: true routing_preferences: @@ -62,7 +62,7 @@ model_providers: model_aliases: claude.fast.v1: - target: claude-sonnet-4-20250514 + target: claude-sonnet-4-6 claude.smart.v1: target: claude-opus-4-6 diff --git a/skills/rules/cli-generate.md b/skills/rules/cli-generate.md deleted file mode 100644 index 75ae8e4f..00000000 --- a/skills/rules/cli-generate.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: Generate Prompt Targets from Python Functions with `planoai generate_prompt_targets` -impact: MEDIUM -impactDescription: Manually writing prompt_targets YAML for existing Python APIs is error-prone — the generator introspects function signatures and produces correct YAML automatically -tags: cli, generate, prompt-targets, python, code-generation ---- - -## Generate Prompt Targets from Python Functions with `planoai generate_prompt_targets` - -`planoai generate_prompt_targets` introspects Python function signatures and docstrings to generate `prompt_targets` YAML for your Plano config. This is the fastest way to expose existing Python APIs as LLM-callable functions without manually writing the YAML schema. - -**Python function requirements for generation:** -- Use simple type annotations: `int`, `float`, `bool`, `str`, `list`, `tuple`, `set`, `dict` -- Include a docstring describing what the function does (becomes the `description`) -- Complex Pydantic models must be flattened into primitive typed parameters first - -**Example Python file:** - -```python -# api.py - -def get_stock_quote(symbol: str, exchange: str = "NYSE") -> dict: - """Get the current stock price and trading data for a given stock symbol. - - Returns price, volume, market cap, and 24h change percentage. - """ - # Implementation calls stock API - pass - -def get_weather_forecast(city: str, days: int = 3, units: str = "celsius") -> dict: - """Get the weather forecast for a city. - - Returns temperature, precipitation, and conditions for the specified number of days. - """ - pass - -def search_flights(origin: str, destination: str, date: str, passengers: int = 1) -> list: - """Search for available flights between two airports on a given date. - - Date format: YYYY-MM-DD. Returns list of flight options with prices. - """ - pass -``` - -**Running the generator:** - -```bash -planoai generate_prompt_targets --file api.py -``` - -**Generated output (add to your config.yaml):** - -```yaml -prompt_targets: - - name: get_stock_quote - description: Get the current stock price and trading data for a given stock symbol. - parameters: - - name: symbol - type: str - required: true - - name: exchange - type: str - required: false - default: NYSE - # Add endpoint manually: - endpoint: - name: stock_api - path: /quote?symbol={symbol}&exchange={exchange} - - - name: get_weather_forecast - description: Get the weather forecast for a city. - parameters: - - name: city - type: str - required: true - - name: days - type: int - required: false - default: 3 - - name: units - type: str - required: false - default: celsius - endpoint: - name: weather_api - path: /forecast?city={city}&days={days}&units={units} -``` - -After generation, manually add the `endpoint` blocks pointing to your actual API. The generator produces the schema; you wire in the connectivity. - -Reference: https://github.com/katanemo/archgw diff --git a/skills/rules/config-providers.md b/skills/rules/config-providers.md index 30476cd5..a8f62df2 100644 --- a/skills/rules/config-providers.md +++ b/skills/rules/config-providers.md @@ -14,7 +14,7 @@ Plano translates requests between its internal format and each provider's API. T | Model prefix | Wire format | Example | |---|---|---| | `openai/*` | OpenAI | `openai/gpt-4o` | -| `anthropic/*` | Anthropic | `anthropic/claude-sonnet-4-20250514` | +| `anthropic/*` | Anthropic | `anthropic/claude-sonnet-4-6` | | `gemini/*` | Google Gemini | `gemini/gemini-2.0-flash` | | `mistral/*` | Mistral | `mistral/mistral-large-latest` | | `groq/*` | Groq | `groq/llama-3.3-70b-versatile` | @@ -42,7 +42,7 @@ model_providers: access_key: $OPENAI_API_KEY default: true - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY - model: gemini/gemini-2.0-flash diff --git a/skills/rules/config-secrets.md b/skills/rules/config-secrets.md index 5f585c87..bb20c855 100644 --- a/skills/rules/config-secrets.md +++ b/skills/rules/config-secrets.md @@ -40,7 +40,7 @@ model_providers: access_key: $OPENAI_API_KEY default: true - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY state_storage: diff --git a/skills/rules/routing-aliases.md b/skills/rules/routing-aliases.md index 91f0b31a..2630c12b 100644 --- a/skills/rules/routing-aliases.md +++ b/skills/rules/routing-aliases.md @@ -47,7 +47,7 @@ model_providers: default: true - 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 model_aliases: @@ -58,7 +58,7 @@ model_aliases: target: gpt-4o # High capability — for complex reasoning plano.creative.v1: - target: claude-sonnet-4-20250514 # Strong creative writing and analysis + target: claude-sonnet-4-6 # Strong creative writing and analysis plano.v1: target: gpt-4o # Default production alias diff --git a/skills/test-cases.json b/skills/test-cases.json index c8bcfe33..eec7e010 100644 --- a/skills/test-cases.json +++ b/skills/test-cases.json @@ -92,7 +92,7 @@ "testCase": { "description": "Detect and fix: \"Register Model Providers with Correct Format Identifiers\"", "input": "model_providers:\n - model: gpt-4o # Missing openai/ prefix — Plano cannot route this\n access_key: $OPENAI_API_KEY\n\n - model: claude-3-5-sonnet # Missing anthropic/ prefix\n access_key: $ANTHROPIC_API_KEY", - "expected": "model_providers:\n - model: openai/gpt-4o\n access_key: $OPENAI_API_KEY\n default: true\n\n - model: anthropic/claude-sonnet-4-20250514\n access_key: $ANTHROPIC_API_KEY\n\n - model: gemini/gemini-2.0-flash\n access_key: $GOOGLE_API_KEY\n\nmodel_providers:\n - model: custom/llama3\n base_url: http://host.docker.internal:11434/v1 # Ollama endpoint\n provider_interface: openai # Ollama speaks OpenAI format\n default: true", + "expected": "model_providers:\n - model: openai/gpt-4o\n access_key: $OPENAI_API_KEY\n default: true\n\n - model: anthropic/claude-sonnet-4-6\n access_key: $ANTHROPIC_API_KEY\n\n - model: gemini/gemini-2.0-flash\n access_key: $GOOGLE_API_KEY\n\nmodel_providers:\n - model: custom/llama3\n base_url: http://host.docker.internal:11434/v1 # Ollama endpoint\n provider_interface: openai # Ollama speaks OpenAI format\n default: true", "evaluationPrompt": "Given the following Plano config or CLI usage, identify if it violates the rule \"Register Model Providers with Correct Format Identifiers\" and explain how to fix it." } }, @@ -112,7 +112,7 @@ "testCase": { "description": "Detect and fix: \"Use Environment Variable Substitution for All Secrets\"", "input": "version: v0.3.0\n\nmodel_providers:\n - model: openai/gpt-4o\n access_key: abcdefghijklmnopqrstuvwxyz... # Hardcoded — never do this\n\nstate_storage:\n type: postgres\n connection_string: \"postgresql://admin:mysecretpassword@prod-db:5432/plano\"\n\nprompt_targets:\n - name: get_data\n endpoint:\n name: my_api\n http_headers:\n Authorization: \"Bearer abcdefghijklmnopqrstuvwxyz\" # Hardcoded token", - "expected": "version: v0.3.0\n\nmodel_providers:\n - model: openai/gpt-4o\n access_key: $OPENAI_API_KEY\n default: true\n\n - model: anthropic/claude-sonnet-4-20250514\n access_key: $ANTHROPIC_API_KEY\n\nstate_storage:\n type: postgres\n connection_string: \"postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:5432/${DB_NAME}\"\n\nprompt_targets:\n - name: get_data\n endpoint:\n name: my_api\n http_headers:\n Authorization: \"Bearer $MY_API_TOKEN\"\n\n# .env — add to .gitignore\nOPENAI_API_KEY=abcdefghijklmnopqrstuvwxyz...\nANTHROPIC_API_KEY=abcdefghijklmnopqrstuvwxyz...\nDB_USER=plano\nDB_PASS=secure-password\nDB_HOST=localhost\nMY_API_TOKEN=abcdefghijklmnopqrstuvwxyz...", + "expected": "version: v0.3.0\n\nmodel_providers:\n - model: openai/gpt-4o\n access_key: $OPENAI_API_KEY\n default: true\n\n - model: anthropic/claude-sonnet-4-6\n access_key: $ANTHROPIC_API_KEY\n\nstate_storage:\n type: postgres\n connection_string: \"postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:5432/${DB_NAME}\"\n\nprompt_targets:\n - name: get_data\n endpoint:\n name: my_api\n http_headers:\n Authorization: \"Bearer $MY_API_TOKEN\"\n\n# .env — add to .gitignore\nOPENAI_API_KEY=abcdefghijklmnopqrstuvwxyz...\nANTHROPIC_API_KEY=abcdefghijklmnopqrstuvwxyz...\nDB_USER=plano\nDB_PASS=secure-password\nDB_HOST=localhost\nMY_API_TOKEN=abcdefghijklmnopqrstuvwxyz...", "evaluationPrompt": "Given the following Plano config or CLI usage, identify if it violates the rule \"Use Environment Variable Substitution for All Secrets\" and explain how to fix it." } }, @@ -288,7 +288,7 @@ "testCase": { "description": "Detect and fix: \"Use Model Aliases for Semantic, Stable Model References\"", "input": "# config.yaml — no aliases defined\nversion: v0.3.0\n\nlisteners:\n - type: model\n name: model_listener\n port: 12000\n\nmodel_providers:\n - model: openai/gpt-4o\n access_key: $OPENAI_API_KEY\n default: true\n\n# Client code — brittle, must be updated when model changes\nclient.chat.completions.create(model=\"gpt-4o\", ...)", - "expected": "version: v0.3.0\n\nlisteners:\n - type: model\n name: model_listener\n port: 12000\n\nmodel_providers:\n - model: openai/gpt-4o-mini\n access_key: $OPENAI_API_KEY\n default: true\n - model: openai/gpt-4o\n access_key: $OPENAI_API_KEY\n - model: anthropic/claude-sonnet-4-20250514\n access_key: $ANTHROPIC_API_KEY\n\nmodel_aliases:\n plano.fast.v1:\n target: gpt-4o-mini # Cheap, fast — for high-volume tasks\n\n plano.smart.v1:\n target: gpt-4o # High capability — for complex reasoning\n\n plano.creative.v1:\n target: claude-sonnet-4-20250514 # Strong creative writing and analysis\n\n plano.v1:\n target: gpt-4o # Default production alias\n\n# Client code — stable, alias is the contract\nclient.chat.completions.create(model=\"plano.smart.v1\", ...)", + "expected": "version: v0.3.0\n\nlisteners:\n - type: model\n name: model_listener\n port: 12000\n\nmodel_providers:\n - model: openai/gpt-4o-mini\n access_key: $OPENAI_API_KEY\n default: true\n - model: openai/gpt-4o\n access_key: $OPENAI_API_KEY\n - model: anthropic/claude-sonnet-4-6\n access_key: $ANTHROPIC_API_KEY\n\nmodel_aliases:\n plano.fast.v1:\n target: gpt-4o-mini # Cheap, fast — for high-volume tasks\n\n plano.smart.v1:\n target: gpt-4o # High capability — for complex reasoning\n\n plano.creative.v1:\n target: claude-sonnet-4-6 # Strong creative writing and analysis\n\n plano.v1:\n target: gpt-4o # Default production alias\n\n# Client code — stable, alias is the contract\nclient.chat.completions.create(model=\"plano.smart.v1\", ...)", "evaluationPrompt": "Given the following Plano config or CLI usage, identify if it violates the rule \"Use Model Aliases for Semantic, Stable Model References\" and explain how to fix it." } }, diff --git a/tests/e2e/config_memory_state_v1_responses.yaml b/tests/e2e/config_memory_state_v1_responses.yaml index afc40910..b0977062 100644 --- a/tests/e2e/config_memory_state_v1_responses.yaml +++ b/tests/e2e/config_memory_state_v1_responses.yaml @@ -15,7 +15,7 @@ llm_providers: default: true # Anthropic Models - - model: anthropic/claude-sonnet-4-20250514 + - model: anthropic/claude-sonnet-4-6 access_key: $ANTHROPIC_API_KEY # State storage configuration for v1/responses API diff --git a/tests/e2e/test_model_alias_routing.py b/tests/e2e/test_model_alias_routing.py index f9e695a5..deb86003 100644 --- a/tests/e2e/test_model_alias_routing.py +++ b/tests/e2e/test_model_alias_routing.py @@ -440,7 +440,7 @@ def test_anthropic_thinking_mode_streaming(): text_delta_seen = False with client.messages.stream( - model="claude-sonnet-4-20250514", + model="claude-sonnet-4-6", max_tokens=2048, thinking={"type": "enabled", "budget_tokens": 1024}, # <- idiomatic messages=[{"role": "user", "content": "Explain briefly what 2+2 equals"}], diff --git a/tests/e2e/test_openai_responses_api_client.py b/tests/e2e/test_openai_responses_api_client.py index 6e110e0d..d4fafb5b 100644 --- a/tests/e2e/test_openai_responses_api_client.py +++ b/tests/e2e/test_openai_responses_api_client.py @@ -489,7 +489,7 @@ def test_openai_responses_api_non_streaming_upstream_anthropic(): client = openai.OpenAI(api_key="test-key", base_url=f"{base_url}/v1") resp = client.responses.create( - model="claude-sonnet-4-20250514", input="Hello, translate this via grok alias" + model="claude-sonnet-4-6", input="Hello, translate this via grok alias" ) # Print the response content - handle both responses format and chat completions format @@ -509,7 +509,7 @@ def test_openai_responses_api_with_streaming_upstream_anthropic(): # Simple streaming responses API request using a direct model (pass-through) stream = client.responses.create( - model="claude-sonnet-4-20250514", + model="claude-sonnet-4-6", input="Write a short haiku about coding", stream=True, ) @@ -566,7 +566,7 @@ def test_openai_responses_api_non_streaming_with_tools_upstream_anthropic(): ] resp = client.responses.create( - model="claude-sonnet-4-20250514", + model="claude-sonnet-4-6", input="Call the echo tool", tools=tools, ) @@ -598,7 +598,7 @@ def test_openai_responses_api_streaming_with_tools_upstream_anthropic(): ] stream = client.responses.create( - model="claude-sonnet-4-20250514", + model="claude-sonnet-4-6", input="Call the echo tool with hello_world", tools=tools, stream=True, diff --git a/tests/e2e/test_openai_responses_api_client_with_state.py b/tests/e2e/test_openai_responses_api_client_with_state.py index c23307e6..6fead76b 100644 --- a/tests/e2e/test_openai_responses_api_client_with_state.py +++ b/tests/e2e/test_openai_responses_api_client_with_state.py @@ -35,7 +35,7 @@ def test_conversation_state_management_two_turn(): # Turn 1: Send initial message to Anthropic (non-OpenAI model) logger.info("\n[TURN 1] Sending initial message...") resp1 = client.responses.create( - model="claude-sonnet-4-20250514", + model="claude-sonnet-4-6", input="My name is Alice and I like pizza.", ) @@ -53,7 +53,7 @@ def test_conversation_state_management_two_turn(): f"\n[TURN 2] Sending follow-up with previous_response_id={response_id_1}" ) resp2 = client.responses.create( - model="claude-sonnet-4-20250514", + model="claude-sonnet-4-6", input="Please list all the messages you have received in our conversation, numbering each one.", previous_response_id=response_id_1, ) @@ -121,7 +121,7 @@ def test_conversation_state_management_two_turn_streaming(): # Turn 1: Send initial streaming message to Anthropic (non-OpenAI model) logger.info("\n[TURN 1] Sending initial streaming message...") stream1 = client.responses.create( - model="claude-sonnet-4-20250514", + model="claude-sonnet-4-6", input="My name is Alice and I like pizza.", stream=True, ) @@ -154,7 +154,7 @@ def test_conversation_state_management_two_turn_streaming(): f"\n[TURN 2] Sending follow-up streaming request with previous_response_id={response_id_1}" ) stream2 = client.responses.create( - model="claude-sonnet-4-20250514", + model="claude-sonnet-4-6", input="Please list all the messages you have received in our conversation, numbering each one.", previous_response_id=response_id_1, stream=True, diff --git a/tests/e2e/test_prompt_gateway.py b/tests/e2e/test_prompt_gateway.py index 9c89059c..d91483af 100644 --- a/tests/e2e/test_prompt_gateway.py +++ b/tests/e2e/test_prompt_gateway.py @@ -395,7 +395,7 @@ def test_claude_v1_messages_api(): ) message = client.messages.create( - model="claude-sonnet-4-20250514", # Use working model from smoke test + model="claude-sonnet-4-6", # Use working model from smoke test max_tokens=50, messages=[ { @@ -414,7 +414,7 @@ def test_claude_v1_messages_api_streaming(): client = anthropic.Anthropic(api_key="test-key", base_url=base_url) with client.messages.stream( - model="claude-sonnet-4-20250514", + model="claude-sonnet-4-6", max_tokens=50, messages=[ { @@ -525,7 +525,7 @@ def test_openai_gpt4o_mini_v1_messages_api_streaming(): def test_openai_client_with_claude_model_streaming(): - """Test OpenAI client using /v1/chat/completions API with Claude model (claude-sonnet-4-20250514) + """Test OpenAI client using /v1/chat/completions API with Claude model (claude-sonnet-4-6) This tests the transformation: Anthropic upstream -> OpenAI client format with proper chunk handling """ # Get the base URL from the LLM gateway endpoint @@ -537,7 +537,7 @@ def test_openai_client_with_claude_model_streaming(): ) stream = client.chat.completions.create( - model="claude-sonnet-4-20250514", # Claude model via OpenAI client + model="claude-sonnet-4-6", # Claude model via OpenAI client max_tokens=50, messages=[ {