mirror of
https://github.com/katanemo/plano.git
synced 2026-07-14 16:22:12 +02:00
Compare commits
27 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2127b83ff | ||
|
|
e96025b117 | ||
|
|
9c2a56e042 | ||
|
|
dc522d8bfc | ||
|
|
474b74aa18 | ||
|
|
bb4008f737 | ||
|
|
07e025001f | ||
|
|
cdde1adf0f | ||
|
|
ff4f2b95d6 | ||
|
|
558df0307c | ||
|
|
5cc4c4ee77 | ||
|
|
5d990d9609 | ||
|
|
440ee1e1ef | ||
|
|
ecf864df25 | ||
|
|
2e38f7fa09 | ||
|
|
7906e5d455 | ||
|
|
374966c06e | ||
|
|
dbe6632d5f | ||
|
|
fb794ae7fe | ||
|
|
1d869641ff | ||
|
|
b5ebb1beea | ||
|
|
f3d6ea41ad | ||
|
|
554a3d1f6a | ||
|
|
241a181d3a | ||
|
|
5a4487fc6e | ||
|
|
b71a555f19 | ||
|
|
938f9c4bdf |
101 changed files with 3326 additions and 1367 deletions
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
125
.github/workflows/update-providers.yml
vendored
Normal file
125
.github/workflows/update-providers.yml
vendored
Normal file
|
|
@ -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"
|
||||
|
|
@ -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/)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ Plano solves this by moving core delivery concerns into a unified, out-of-proces
|
|||
Plano pulls rote plumbing out of your framework so you can stay focused on what matters most: the core product logic of your agentic applications. Plano is backed by [industry-leading LLM research](https://planoai.dev/research) and built on [Envoy](https://envoyproxy.io) by its core contributors, who built critical infrastructure at scale for modern worklaods.
|
||||
|
||||
**High-Level Network Sequence Diagram**:
|
||||

|
||||

|
||||
|
||||
**Jump to our [docs](https://docs.planoai.dev)** to learn how you can use Plano to improve the speed, safety and obervability of your agentic applications.
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ curl http://localhost:8001/v1/chat/completions \
|
|||
|
||||
Every request is traced end-to-end with OpenTelemetry - no instrumentation code needed.
|
||||
|
||||

|
||||

|
||||
|
||||
### What You Didn't Have to Build
|
||||
|
||||
|
|
@ -183,7 +183,6 @@ Ready to try Plano? Check out our comprehensive documentation:
|
|||
- **[LLM Routing](https://docs.planoai.dev/guides/llm_router.html)** - Route by model name, alias, or intelligent preferences
|
||||
- **[Agent Orchestration](https://docs.planoai.dev/guides/orchestration.html)** - Build multi-agent workflows
|
||||
- **[Filter Chains](https://docs.planoai.dev/concepts/filter_chain.html)** - Add guardrails, moderation, and memory hooks
|
||||
- **[Prompt Targets](https://docs.planoai.dev/concepts/prompt_target.html)** - Turn prompts into deterministic API calls
|
||||
- **[Observability](https://docs.planoai.dev/guides/observability/observability.html)** - Traces, metrics, and logs
|
||||
|
||||
## Contribution
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function Hero() {
|
|||
>
|
||||
<div className="inline-flex flex-wrap items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-1 rounded-full bg-[rgba(185,191,255,0.4)] border border-[var(--secondary)] shadow backdrop-blur hover:bg-[rgba(185,191,255,0.6)] transition-colors cursor-pointer">
|
||||
<span className="text-xs sm:text-sm font-medium text-black/65">
|
||||
v0.4.22
|
||||
v0.4.27
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm font-medium text-black ">
|
||||
—
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Plano CLI - Intelligent Prompt Gateway."""
|
||||
|
||||
__version__ = "0.4.22"
|
||||
__version__ = "0.4.27"
|
||||
|
|
|
|||
|
|
@ -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,13 +600,13 @@ 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:
|
||||
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 input_filters id '{fc_id}' "
|
||||
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))}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
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
|
||||
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -63,9 +63,5 @@ def configure_rich_click(plano_color: str) -> None:
|
|||
"name": "Observability",
|
||||
"commands": ["trace", "obs"],
|
||||
},
|
||||
{
|
||||
"name": "Utilities",
|
||||
"commands": ["generate-prompt-targets"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <input_file>")
|
||||
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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) == {}
|
||||
|
|
|
|||
2
cli/uv.lock
generated
2
cli/uv.lock
generated
|
|
@ -337,7 +337,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "planoai"
|
||||
version = "0.4.22"
|
||||
version = "0.4.27"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
45
crates/Cargo.lock
generated
45
crates/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ pub struct AppState {
|
|||
pub state_storage: Option<Arc<dyn StateStorage>>,
|
||||
pub llm_provider_url: String,
|
||||
pub span_attributes: Option<SpanAttributes>,
|
||||
/// 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<String>,
|
||||
/// Shared HTTP client for upstream LLM requests (connection pooling / keep-alive).
|
||||
pub http_client: reqwest::Client,
|
||||
pub filter_pipeline: Arc<FilterPipeline>,
|
||||
|
|
|
|||
|
|
@ -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<String> = 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,
|
||||
|
|
|
|||
|
|
@ -142,25 +142,19 @@ async fn init_app_state(
|
|||
.listeners
|
||||
.iter()
|
||||
.find(|l| l.listener_type == ListenerType::Model);
|
||||
let resolve_chain = |filter_ids: Option<Vec<String>>| -> Option<ResolvedFilterChain> {
|
||||
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<Vec<String>>,
|
||||
global_agent_map: &HashMap<String, Agent>,
|
||||
) -> Result<Option<ResolvedFilterChain>, 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<dyn std::error::Error + Send + Sync>> {
|
|||
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'"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RwLock<HashMap<String, f64>>>,
|
||||
|
|
@ -22,11 +23,17 @@ impl ModelMetricsService {
|
|||
|
||||
for source in sources {
|
||||
match source {
|
||||
MetricsSource::Cost(cfg) => match cfg.provider {
|
||||
CostProvider::Digitalocean => {
|
||||
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 data = fetch_do_pricing(&client, &aliases).await;
|
||||
info!(models = data.len(), "fetched digitalocean pricing");
|
||||
let provider_name = cost_provider_name(&provider);
|
||||
|
||||
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 {
|
||||
|
|
@ -36,14 +43,15 @@ impl ModelMetricsService {
|
|||
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");
|
||||
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<f64>,
|
||||
}
|
||||
|
||||
async fn fetch_do_pricing(
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModelsDevProvider {
|
||||
#[serde(default)]
|
||||
models: HashMap<String, ModelsDevModel>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModelsDevModel {
|
||||
cost: Option<ModelsDevCost>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModelsDevCost {
|
||||
input: Option<f64>,
|
||||
output: Option<f64>,
|
||||
}
|
||||
|
||||
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<String, String>,
|
||||
) -> HashMap<String, f64> {
|
||||
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<String, String>,
|
||||
) -> HashMap<String, f64> {
|
||||
match client.get(url).send().await {
|
||||
Ok(resp) => match resp.json::<DoModelList>().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<String, String>,
|
||||
) -> HashMap<String, f64> {
|
||||
match client.get(url).send().await {
|
||||
Ok(resp) => match resp.json::<HashMap<String, ModelsDevProvider>>().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<String, ModelsDevProvider>,
|
||||
aliases: &HashMap<String, String>,
|
||||
) -> HashMap<String, f64> {
|
||||
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<String, ModelsDevProvider> = 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<String, ModelsDevProvider> = 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![
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
//! 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.<dotted_signal_type>`.
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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<PosthogExporter> = 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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
402
crates/brightstaff/src/tracing/posthog_exporter.rs
Normal file
402
crates/brightstaff/src/tracing/posthog_exporter.rs
Normal file
|
|
@ -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<JsonValue> {
|
||||
let events: Vec<JsonValue> = 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<JsonValue> {
|
||||
// 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<SpanData>,
|
||||
) -> impl std::future::Future<Output = OTelSdkResult> + 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<i64> {
|
||||
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::<Vec<_>>()),
|
||||
_ => 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<KeyValue>) -> 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<String, JsonValue> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub refresh_interval: Option<u64>,
|
||||
/// 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<HashMap<String, String>>,
|
||||
}
|
||||
|
|
@ -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<u32>,
|
||||
pub opentracing_grpc_endpoint: Option<String>,
|
||||
pub span_attributes: Option<SpanAttributes>,
|
||||
/// 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<Vec<Exporter>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
|
|
@ -253,6 +265,36 @@ pub struct SpanAttributes {
|
|||
pub static_attributes: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
#[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<super::MetricsSource> = 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<super::MetricsSource> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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<BTreeMap<String, Vec<String>>, Box<dyn std::error::Error>> {
|
||||
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<GoogleModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ProviderModels {
|
||||
#[serde(default = "default_version")]
|
||||
version: String,
|
||||
#[serde(default = "default_source")]
|
||||
source: String,
|
||||
providers: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
providers: BTreeMap<String, Vec<String>>,
|
||||
#[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<Vec<String>, Box<dyn std::error::Erro
|
|||
Ok(amazon_models)
|
||||
}
|
||||
|
||||
fn fetch_all_models() -> Result<ProviderModels, Box<dyn std::error::Error>> {
|
||||
let mut providers: HashMap<String, Vec<String>> = HashMap::new();
|
||||
fn fetch_all_models(
|
||||
existing: BTreeMap<String, Vec<String>>,
|
||||
) -> Result<ProviderModels, Box<dyn std::error::Error>> {
|
||||
let mut providers = existing;
|
||||
let mut updated: Vec<String> = Vec::new();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
// Configuration: provider name, env var, API URL, prefix for model IDs
|
||||
|
|
@ -322,92 +381,139 @@ fn fetch_all_models() -> Result<ProviderModels, Box<dyn std::error::Error>> {
|
|||
"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<Result<Vec<String>, Box<dyn std::error::Error>>>,
|
||||
providers: &mut BTreeMap<String, Vec<String>>| 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
|
||||
);
|
||||
if !errors.is_empty() {
|
||||
println!("⚠️ {} providers failed", errors.len());
|
||||
" updated: {} ({})",
|
||||
updated.len(),
|
||||
if updated.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
updated.join(", ")
|
||||
}
|
||||
);
|
||||
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<ProviderModels, Box<dyn std::error::Error>> {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ impl From<MessagesRole> for Role {
|
|||
match val {
|
||||
MessagesRole::User => Role::User,
|
||||
MessagesRole::Assistant => Role::Assistant,
|
||||
MessagesRole::System => Role::System,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -340,6 +341,11 @@ impl TryFrom<MessagesMessage> 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();
|
||||
|
|
|
|||
|
|
@ -112,16 +112,19 @@ impl TryFrom<ResponsesInputConverter> for Vec<Message> {
|
|||
) => {
|
||||
// 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]
|
||||
{
|
||||
match &content_items[0] {
|
||||
InputContent::InputText { text }
|
||||
| InputContent::OutputText { text } => {
|
||||
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 } => {
|
||||
InputContent::InputText { text }
|
||||
| InputContent::OutputText { text } => {
|
||||
Some(crate::apis::openai::ContentPart::Text {
|
||||
text: text.clone(),
|
||||
})
|
||||
|
|
@ -140,13 +143,15 @@ impl TryFrom<ResponsesInputConverter> for Vec<Message> {
|
|||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multiple content items - convert to parts
|
||||
MessageContent::Parts(
|
||||
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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ pub fn convert_responses_output_to_input_items(output: &OutputItem) -> Option<In
|
|||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
OutputContent::OutputText { text, .. } => {
|
||||
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<In
|
|||
|
||||
Some(InputItem::Message(InputMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::Items(vec![InputContent::InputText {
|
||||
content: MessageContent::Items(vec![InputContent::OutputText {
|
||||
text: tool_call_text,
|
||||
}]),
|
||||
}))
|
||||
|
|
@ -104,8 +106,8 @@ mod tests {
|
|||
MessageContent::Items(items) => {
|
||||
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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
26
demos/filter_chains/model_listener_filter/config.openai.yaml
Normal file
26
demos/filter_chains/model_listener_filter/config.openai.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
81
demos/filter_chains/model_listener_filter/fake_provider.py
Normal file
81
demos/filter_chains/model_listener_filter/fake_provider.py
Normal file
|
|
@ -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"}
|
||||
57
demos/filter_chains/model_listener_filter/output_filter.py
Normal file
57
demos/filter_chains/model_listener_filter/output_filter.py
Normal file
|
|
@ -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"}
|
||||
|
|
@ -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 ""
|
||||
|
|
|
|||
159
demos/filter_chains/model_listener_filter/test_services.py
Normal file
159
demos/filter_chains/model_listener_filter/test_services.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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?"}]
|
||||
}'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
sphinx_copybutton==0.5.2
|
||||
sphinxawesome-theme
|
||||
sphinxawesome-theme<6.0.0
|
||||
sphinx_sitemap
|
||||
sphinx_design
|
||||
sphinxawesome_theme
|
||||
|
|
|
|||
|
|
@ -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-...",
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <agents>`
|
||||
together with :ref:`Function Calling <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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <prompt_target>` are deprecated and no longer actively
|
||||
maintained. The walkthrough below is preserved for users on existing configs;
|
||||
new applications should use :ref:`Agents <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
|
||||
|
|
|
|||
|
|
@ -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 <function_calling>`)
|
||||
is deprecated. :ref:`Prompt Targets <prompt_target>` are no longer actively
|
||||
maintained and may be removed in a future release. For new function-calling
|
||||
workloads, prefer :ref:`Agents <agents>` with tool definitions.
|
||||
|
||||
|
||||
What is Function Calling?
|
||||
-------------------------
|
||||
|
|
|
|||
|
|
@ -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 <https://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``)
|
||||
- ``{ <provider>: { models: { <model>: { 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.
|
||||
|
|
|
|||
|
|
@ -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 <https://posthog.com/docs/ai-observability/generations>`_
|
||||
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
|
||||
-----------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <python-file>
|
||||
|
||||
**Options**
|
||||
|
||||
- ``--file, --f <python-file>``: required path to a ``.py`` source file.
|
||||
|
||||
|
||||
.. _cli_reference_cli_agent:
|
||||
|
||||
planoai cli_agent
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
125
skills/AGENTS.md
125
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 ---
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue