diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0e8722ce..ebda64a6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -107,6 +107,56 @@ jobs:
if: always()
run: planoai down || true
+ # ── Zero-config path: `planoai up` with no args, no plano.yaml in cwd.
+ # Exercises the synthesize_default_config branch in cli/planoai/main.py
+ # which is otherwise never hit by the smoke test above.
+ #
+ # Pre-seed ~/.plano/ from the freshly-built artifacts so the CLI's
+ # cached-download path hits in step (2) of ensure_wasm_plugins /
+ # ensure_brightstaff_binary. Without this, running from outside the
+ # repo means find_repo_root() returns None, the local-build short-
+ # circuit is skipped, and the CLI tries to download from a GitHub
+ # release that does not yet exist for the in-flight version on
+ # release-bump PRs (e.g. 0.4.25 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.25
${{ 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.25 ${{ env.DOCKER_IMAGE }}:latest -o /tmp/plano-image.tar
- name: Upload image artifact
uses: actions/upload-artifact@v6
diff --git a/.github/workflows/update-providers.yml b/.github/workflows/update-providers.yml
new file mode 100644
index 00000000..0add8a98
--- /dev/null
+++ b/.github/workflows/update-providers.yml
@@ -0,0 +1,124 @@
+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 }}
+ run: cargo run --bin fetch_models --features model-fetch
+
+ - name: Create pull request
+ id: cpr
+ uses: peter-evans/create-pull-request@v7
+ with:
+ branch: bot/update-providers-${{ github.run_id }}
+ base: main
+ commit-message: "chore: refresh provider_models.yaml"
+ title: "chore: refresh provider_models.yaml"
+ body: |
+ Automated refresh of `crates/hermesllm/src/bin/provider_models.yaml`
+ via `fetch_models`.
+
+ Requested by ${{ env.SLACK_USER_NAME && format('@{0}', env.SLACK_USER_NAME) || 'workflow_dispatch' }}${{ env.SLACK_USER_ID && format(' (Slack `{0}`)', env.SLACK_USER_ID) || '' }}.
+
+ Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ labels: automated, provider-models
+ add-paths: crates/hermesllm/src/bin/provider_models.yaml
+
+ - name: Notify Slack (success)
+ if: success() && env.RESPONSE_URL != ''
+ env:
+ PR_URL: ${{ steps.cpr.outputs.pull-request-url }}
+ PR_NUMBER: ${{ steps.cpr.outputs.pull-request-number }}
+ PR_OP: ${{ steps.cpr.outputs.pull-request-operation }}
+ run: |
+ if [ -z "$PR_URL" ]; then
+ TEXT=":information_source: No provider model changes detected \u2014 nothing to PR."
+ BLOCKS=$(jq -nc --arg text "$TEXT" '{response_type:"ephemeral", replace_original:true, text:$text, blocks:[{type:"section", text:{type:"mrkdwn", text:$text}}]}')
+ else
+ TEXT=":white_check_mark: provider_models.yaml PR ready: $PR_URL"
+ BLOCKS=$(jq -nc \
+ --arg pr "$PR_URL" \
+ --arg num "$PR_NUMBER" \
+ --arg op "$PR_OP" \
+ '{
+ response_type:"ephemeral",
+ replace_original:true,
+ text:(":white_check_mark: provider_models.yaml PR #" + $num + " " + $op + ": " + $pr),
+ blocks:[
+ {type:"section", text:{type:"mrkdwn", text:(":white_check_mark: *provider_models.yaml* PR <" + $pr + "|#" + $num + "> " + $op + ".")}},
+ {type:"actions", elements:[{type:"button", text:{type:"plain_text", text:"Open PR"}, url:$pr}]}
+ ]
+ }')
+ fi
+ curl -sS -X POST -H 'Content-Type: application/json' -d "$BLOCKS" "$RESPONSE_URL"
+
+ - name: Notify Slack (failure)
+ if: failure() && env.RESPONSE_URL != ''
+ run: |
+ RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ TEXT=":x: provider_models.yaml update failed. Logs: $RUN_URL"
+ jq -nc \
+ --arg text "$TEXT" \
+ --arg run "$RUN_URL" \
+ '{
+ response_type:"ephemeral",
+ replace_original:true,
+ text:$text,
+ blocks:[
+ {type:"section", text:{type:"mrkdwn", text:(":x: *provider_models.yaml update failed.*")}},
+ {type:"actions", elements:[{type:"button", text:{type:"plain_text", text:"View logs"}, url:$run}]}
+ ]
+ }' | curl -sS -X POST -H 'Content-Type: application/json' -d @- "$RESPONSE_URL"
diff --git a/CLAUDE.md b/CLAUDE.md
index 58b2191f..975b9ea0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,7 +49,7 @@ Client → Envoy (prompt_gateway.wasm → llm_gateway.wasm) → Agents/LLM Provi
### Python CLI (cli/planoai/)
-Entry point: `main.py`. Built with `rich-click`. Commands: `up`, `down`, `build`, `logs`, `trace`, `init`, `cli_agent`, `generate_prompt_targets`.
+Entry point: `main.py`. Built with `rich-click`. Commands: `up`, `down`, `build`, `logs`, `trace`, `init`, `cli_agent`.
### Config (config/)
diff --git a/README.md b/README.md
index b7ff7efc..177bf8e3 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Plano solves this by moving core delivery concerns into a unified, out-of-proces
Plano pulls rote plumbing out of your framework so you can stay focused on what matters most: the core product logic of your agentic applications. Plano is backed by [industry-leading LLM research](https://planoai.dev/research) and built on [Envoy](https://envoyproxy.io) by its core contributors, who built critical infrastructure at scale for modern worklaods.
**High-Level Network Sequence Diagram**:
-
+
**Jump to our [docs](https://docs.planoai.dev)** to learn how you can use Plano to improve the speed, safety and obervability of your agentic applications.
@@ -156,7 +156,7 @@ curl http://localhost:8001/v1/chat/completions \
Every request is traced end-to-end with OpenTelemetry - no instrumentation code needed.
-
+
### What You Didn't Have to Build
@@ -183,7 +183,6 @@ Ready to try Plano? Check out our comprehensive documentation:
- **[LLM Routing](https://docs.planoai.dev/guides/llm_router.html)** - Route by model name, alias, or intelligent preferences
- **[Agent Orchestration](https://docs.planoai.dev/guides/orchestration.html)** - Build multi-agent workflows
- **[Filter Chains](https://docs.planoai.dev/concepts/filter_chain.html)** - Add guardrails, moderation, and memory hooks
-- **[Prompt Targets](https://docs.planoai.dev/concepts/prompt_target.html)** - Turn prompts into deterministic API calls
- **[Observability](https://docs.planoai.dev/guides/observability/observability.html)** - Traces, metrics, and logs
## Contribution
diff --git a/apps/www/src/components/Hero.tsx b/apps/www/src/components/Hero.tsx
index b45c6873..566bee49 100644
--- a/apps/www/src/components/Hero.tsx
+++ b/apps/www/src/components/Hero.tsx
@@ -24,7 +24,7 @@ export function Hero() {
>
- v0.4.22
+ v0.4.25
—
diff --git a/build_filter_image.sh b/build_filter_image.sh
index 64708056..c60d8d0b 100644
--- a/build_filter_image.sh
+++ b/build_filter_image.sh
@@ -1 +1 @@
-docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.22
+docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.25
diff --git a/cli/planoai/__init__.py b/cli/planoai/__init__.py
index ec2c63da..689f32df 100644
--- a/cli/planoai/__init__.py
+++ b/cli/planoai/__init__.py
@@ -1,3 +1,3 @@
"""Plano CLI - Intelligent Prompt Gateway."""
-__version__ = "0.4.22"
+__version__ = "0.4.25"
diff --git a/cli/planoai/config_generator.py b/cli/planoai/config_generator.py
index cb07767e..f754a183 100644
--- a/cli/planoai/config_generator.py
+++ b/cli/planoai/config_generator.py
@@ -39,6 +39,42 @@ CHATGPT_API_BASE = "https://chatgpt.com/backend-api/codex"
CHATGPT_DEFAULT_ORIGINATOR = "codex_cli_rs"
CHATGPT_DEFAULT_USER_AGENT = "codex_cli_rs/0.0.0 (Unknown 0; unknown) unknown"
+KIMI_CODE_API_HOST = "api.kimi.com"
+KIMI_CODE_DEFAULT_USER_AGENT = "KimiCLI/1.3"
+
+
+def normalize_kimi_code_base_url(base_url: str) -> str:
+ """Ensure Kimi Code API base URLs include the /v1 suffix."""
+ parsed = urlparse(base_url)
+ if parsed.hostname != KIMI_CODE_API_HOST:
+ return base_url
+ path = parsed.path.rstrip("/")
+ if path.endswith("/coding"):
+ return f"{parsed.scheme}://{parsed.netloc}{path}/v1"
+ return base_url
+
+
+def apply_kimi_code_provider_defaults(model_provider: dict) -> None:
+ """Inject Kimi Code API defaults (User-Agent, normalized base URL)."""
+ base_url = model_provider.get("base_url")
+ if not base_url:
+ return
+ parsed = urlparse(base_url)
+ model_id = model_provider.get("model", "")
+ is_kimi_code = (
+ parsed.hostname == KIMI_CODE_API_HOST or model_id == "kimi-for-coding"
+ )
+ if not is_kimi_code:
+ return
+
+ normalized = normalize_kimi_code_base_url(base_url)
+ if normalized != base_url:
+ model_provider["base_url"] = normalized
+
+ headers = model_provider.setdefault("headers", {})
+ headers.setdefault("User-Agent", KIMI_CODE_DEFAULT_USER_AGENT)
+
+
SUPPORTED_PROVIDERS = (
SUPPORTED_PROVIDERS_WITHOUT_BASE_URL + SUPPORTED_PROVIDERS_WITH_BASE_URL
)
@@ -463,6 +499,8 @@ def validate_and_render_schema():
headers.setdefault("session_id", str(uuid.uuid4()))
model_provider["headers"] = headers
+ apply_kimi_code_provider_defaults(model_provider)
+
updated_model_providers.append(model_provider)
if model_provider.get("base_url", None):
@@ -562,15 +600,15 @@ def validate_and_render_schema():
"Please provide model_providers either under listeners or at root level, not both. Currently we don't support multiple listeners with model_providers"
)
- # Validate input_filters IDs on listeners reference valid agent/filter IDs
+ # Validate listener-level filter IDs reference valid agent/filter IDs.
for listener in listeners:
- listener_input_filters = listener.get("input_filters", [])
- for fc_id in listener_input_filters:
- if fc_id not in agent_id_keys:
- raise Exception(
- f"Listener '{listener.get('name', 'unknown')}' references input_filters id '{fc_id}' "
- f"which is not defined in agents or filters. Available ids: {', '.join(sorted(agent_id_keys))}"
- )
+ for filter_field in ("input_filters", "output_filters"):
+ for fc_id in listener.get(filter_field, []):
+ if fc_id not in agent_id_keys:
+ raise Exception(
+ f"Listener '{listener.get('name', 'unknown')}' references {filter_field} id '{fc_id}' "
+ f"which is not defined in agents or filters. Available ids: {', '.join(sorted(agent_id_keys))}"
+ )
# Validate model aliases if present
if "model_aliases" in config_yaml:
diff --git a/cli/planoai/consts.py b/cli/planoai/consts.py
index 5cafb817..1b7f4cd3 100644
--- a/cli/planoai/consts.py
+++ b/cli/planoai/consts.py
@@ -5,7 +5,7 @@ PLANO_COLOR = "#969FF4"
SERVICE_NAME_ARCHGW = "plano"
PLANO_DOCKER_NAME = "plano"
-PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.22")
+PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.25")
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT = "http://localhost:4317"
# Native mode constants
diff --git a/cli/planoai/main.py b/cli/planoai/main.py
index ea43a1a8..491e2912 100644
--- a/cli/planoai/main.py
+++ b/cli/planoai/main.py
@@ -7,7 +7,6 @@ import contextlib
import logging
import rich_click as click
import yaml
-from planoai import targets
from planoai.defaults import (
DEFAULT_LLM_LISTENER_PORT,
detect_providers,
@@ -622,28 +621,6 @@ def down(docker, verbose):
)
-@click.command()
-@click.option(
- "--f",
- "--file",
- type=click.Path(exists=True),
- required=True,
- help="Path to the Python file",
-)
-def generate_prompt_targets(file):
- """Generats prompt_targets from python methods.
- Note: This works for simple data types like ['int', 'float', 'bool', 'str', 'list', 'tuple', 'set', 'dict']:
- If you have a complex pydantic data type, you will have to flatten those manually until we add support for it.
- """
-
- print(f"Processing file: {file}")
- if not file.endswith(".py"):
- print("Error: Input file must be a .py file")
- sys.exit(1)
-
- targets.generate_prompt_targets(file)
-
-
@click.command()
@click.option(
"--debug",
@@ -741,7 +718,6 @@ main.add_command(down)
main.add_command(build)
main.add_command(logs)
main.add_command(cli_agent)
-main.add_command(generate_prompt_targets)
main.add_command(init_cmd, name="init")
main.add_command(trace_cmd, name="trace")
main.add_command(chatgpt_cmd, name="chatgpt")
diff --git a/cli/planoai/rich_click_config.py b/cli/planoai/rich_click_config.py
index fe90dcf1..0ae83844 100644
--- a/cli/planoai/rich_click_config.py
+++ b/cli/planoai/rich_click_config.py
@@ -63,9 +63,5 @@ def configure_rich_click(plano_color: str) -> None:
"name": "Observability",
"commands": ["trace", "obs"],
},
- {
- "name": "Utilities",
- "commands": ["generate-prompt-targets"],
- },
],
}
diff --git a/cli/planoai/targets.py b/cli/planoai/targets.py
deleted file mode 100644
index 7c56f2b7..00000000
--- a/cli/planoai/targets.py
+++ /dev/null
@@ -1,365 +0,0 @@
-import ast
-import sys
-import yaml
-from typing import Any
-
-FLASK_ROUTE_DECORATORS = ["route", "get", "post", "put", "delete", "patch"]
-FASTAPI_ROUTE_DECORATORS = ["get", "post", "put", "delete", "patch"]
-
-
-def detect_framework(tree: Any) -> str:
- """Detect whether the file is using Flask or FastAPI based on imports."""
- for node in ast.walk(tree):
- if isinstance(node, ast.ImportFrom):
- if node.module == "flask":
- return "flask"
- elif node.module == "fastapi":
- return "fastapi"
- return "unknown"
-
-
-def get_route_decorators(node: Any, framework: str) -> list:
- """Extract route decorators based on the framework."""
- decorators = []
- for decorator in node.decorator_list:
- if isinstance(decorator, ast.Call) and isinstance(
- decorator.func, ast.Attribute
- ):
- if framework == "flask" and decorator.func.attr in FLASK_ROUTE_DECORATORS:
- decorators.append(decorator.func.attr)
- elif (
- framework == "fastapi"
- and decorator.func.attr in FASTAPI_ROUTE_DECORATORS
- ):
- decorators.append(decorator.func.attr)
- return decorators
-
-
-def get_route_path(node: Any, framework: str) -> str:
- """Extract route path based on the framework."""
- for decorator in node.decorator_list:
- if isinstance(decorator, ast.Call) and decorator.args:
- return decorator.args[0].s # Assuming it's a string literal
-
-
-def is_pydantic_model(annotation: ast.expr, tree: ast.AST) -> bool:
- """Check if a given type annotation is a Pydantic model."""
- # We walk through the AST to find class definitions and check if they inherit from Pydantic's BaseModel
- if isinstance(annotation, ast.Name):
- for node in ast.walk(tree):
- if isinstance(node, ast.ClassDef) and node.name == annotation.id:
- for base in node.bases:
- if isinstance(base, ast.Name) and base.id == "BaseModel":
- return True
- return False
-
-
-def get_pydantic_model_fields(model_name: str, tree: ast.AST) -> list:
- """Extract fields from a Pydantic model, handling list, tuple, set, dict types, and direct default values."""
- fields = []
-
- for node in ast.walk(tree):
- if isinstance(node, ast.ClassDef) and node.name == model_name:
- for stmt in node.body:
- if isinstance(stmt, ast.AnnAssign):
- # Initialize the default field description
- field_type = "Unknown: Please Fix This!"
- description = "Field, description not present. Please fix."
- default_value = None
- required = True # Assume the field is required initially
-
- # Check if the field uses Field() with required status and description
- if (
- stmt.value
- and isinstance(stmt.value, ast.Call)
- and isinstance(stmt.value.func, ast.Name)
- and stmt.value.func.id == "Field"
- ):
- # Extract the description argument inside the Field call
- for keyword in stmt.value.keywords:
- if keyword.arg == "description" and isinstance(
- keyword.value, ast.Str
- ):
- description = keyword.value.s
- if keyword.arg == "default":
- default_value = keyword.value
- # If Ellipsis (...) is used, it means the field is required
- if (
- stmt.value.args
- and isinstance(stmt.value.args[0], ast.Constant)
- and stmt.value.args[0].value is Ellipsis
- ):
- required = True
- else:
- required = False
-
- # Handle direct default values (e.g., name: str = "John Doe")
- elif stmt.value is not None:
- if isinstance(stmt.value, ast.Constant):
- # Set the default value from the assignment (e.g., name: str = "John Doe")
- default_value = stmt.value.value
- required = (
- False # Not required since it has a default value
- )
-
- # Always extract the field type, even if there's a default value
- if isinstance(stmt.annotation, ast.Subscript):
- # Get the base type (list, tuple, set, dict)
- base_type = (
- stmt.annotation.value.id
- if isinstance(stmt.annotation.value, ast.Name)
- else "Unknown"
- )
-
- # Handle only list, tuple, set, dict and ignore the inner types
- if base_type.lower() in ["list", "tuple", "set", "dict"]:
- field_type = base_type.lower()
-
- # Handle the ellipsis '...' for required fields if no Field() call
- elif (
- isinstance(stmt.value, ast.Constant)
- and stmt.value.value is Ellipsis
- ):
- required = True
-
- # Handle simple types like str, int, etc.
- if isinstance(stmt.annotation, ast.Name):
- field_type = stmt.annotation.id
-
- field_info = {
- "name": stmt.target.id,
- "type": field_type, # Always set the field type
- "description": description,
- "default": default_value, # Handle direct default values
- "required": required,
- }
- fields.append(field_info)
-
- return fields
-
-
-def get_function_parameters(node: ast.FunctionDef, tree: ast.AST) -> list:
- """Extract the parameters and their types from the function definition."""
- parameters = []
-
- # Extract docstring to find descriptions
- docstring = ast.get_docstring(node)
- arg_descriptions = extract_arg_descriptions_from_docstring(docstring)
-
- # Extract default values
- defaults = [None] * (
- len(node.args.args) - len(node.args.defaults)
- ) + node.args.defaults # Align defaults with args
- for arg, default in zip(node.args.args, defaults):
- if arg.arg != "self": # Skip 'self' or 'cls' in class methods
- param_info = {
- "name": arg.arg,
- "description": arg_descriptions.get(arg.arg, "[ADD DESCRIPTION]"),
- }
-
- # Handle Pydantic model types
- if hasattr(arg, "annotation") and is_pydantic_model(arg.annotation, tree):
- # Extract and flatten Pydantic model fields
- pydantic_fields = get_pydantic_model_fields(arg.annotation.id, tree)
- parameters.extend(
- pydantic_fields
- ) # Flatten the model fields into the parameters list
- continue # Skip adding the current param_info for the model since we expand the fields
-
- # Handle standard Python types (int, float, str, etc.)
- elif hasattr(arg, "annotation") and isinstance(arg.annotation, ast.Name):
- if arg.annotation.id in [
- "int",
- "float",
- "bool",
- "str",
- "list",
- "tuple",
- "set",
- "dict",
- ]:
- param_info["type"] = arg.annotation.id
- else:
- param_info["type"] = "[UNKNOWN - PLEASE FIX]"
-
- # Handle generic subscript types (e.g., Optional, List[Type], etc.)
- elif hasattr(arg, "annotation") and isinstance(
- arg.annotation, ast.Subscript
- ):
- if isinstance(
- arg.annotation.value, ast.Name
- ) and arg.annotation.value.id in ["list", "tuple", "set", "dict"]:
- param_info["type"] = (
- f"{arg.annotation.value.id}" # e.g., "List", "Tuple", etc.
- )
- else:
- param_info["type"] = "[UNKNOWN - PLEASE FIX]"
-
- # Default for unknown types
- else:
- param_info["type"] = (
- "[UNKNOWN - PLEASE FIX]" # If unable to detect type
- )
-
- # Handle default values
- if default is not None:
- if isinstance(default, ast.Constant) or isinstance(
- default, ast.NameConstant
- ):
- param_info["default"] = (
- default.value
- ) # Use the default value directly
- else:
- param_info["default"] = "[UNKNOWN DEFAULT]" # Unknown default type
- param_info["required"] = False # Optional since it has a default value
- else:
- param_info["default"] = None
- param_info["required"] = True # Required if no default value
-
- parameters.append(param_info)
-
- return parameters
-
-
-def get_function_docstring(node: Any) -> str:
- """Extract the function's docstring description if present."""
- # Check if the first node is a docstring
- if isinstance(node.body[0], ast.Expr) and isinstance(node.body[0].value, ast.Str):
- # Get the entire docstring
- full_docstring = node.body[0].value.s.strip()
-
- # Split the docstring by double newlines (to separate description from fields like Args)
- description = full_docstring.split("\n\n")[0].strip()
-
- return description
-
- return "No description provided."
-
-
-def extract_arg_descriptions_from_docstring(docstring: str) -> dict:
- """Extract descriptions for function parameters from the 'Args' section of the docstring."""
- descriptions = {}
- if not docstring:
- return descriptions
-
- in_args_section = False
- current_param = None
- for line in docstring.splitlines():
- line = line.strip()
-
- # Detect the start of the 'Args' section
- if line.startswith("Args:"):
- in_args_section = True
- continue # Proceed to the next line after 'Args:'
-
- # End of 'Args' section if no indentation and no colon
- if in_args_section and not line.startswith(" ") and ":" not in line:
- break # Stop processing if we reach a new section
-
- # Process lines in the 'Args' section
- if in_args_section:
- if ":" in line:
- # Extract parameter name and description
- param_name, description = line.split(":", 1)
- descriptions[param_name.strip()] = description.strip()
- current_param = param_name.strip()
- elif current_param and line.startswith(" "):
- # Handle multiline descriptions (indented lines)
- descriptions[current_param] += f" {line.strip()}"
-
- return descriptions
-
-
-def generate_prompt_targets(input_file_path: str) -> None:
- """Introspect routes and generate YAML for either Flask or FastAPI."""
- with open(input_file_path, "r") as source:
- tree = ast.parse(source.read())
-
- # Detect the framework (Flask or FastAPI)
- framework = detect_framework(tree)
- if framework == "unknown":
- print("Could not detect Flask or FastAPI in the file.")
- return
-
- # Extract routes
- routes = []
- for node in ast.walk(tree):
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
- route_decorators = get_route_decorators(node, framework)
- if route_decorators:
- route_path = get_route_path(node, framework)
- function_params = get_function_parameters(
- node, tree
- ) # Get parameters for the route
- function_docstring = get_function_docstring(node) # Extract docstring
- routes.append(
- {
- "name": node.name,
- "path": route_path,
- "methods": route_decorators,
- "parameters": function_params, # Add parameters to the route
- "description": function_docstring, # Add the docstring as the description
- }
- )
-
- # Generate YAML structure
- output_structure = {"prompt_targets": []}
-
- for route in routes:
- target = {
- "name": route["name"],
- "endpoint": [
- {
- "name": "app_server",
- "path": route["path"],
- }
- ],
- "description": route["description"], # Use extracted docstring
- "parameters": [
- {
- "name": param["name"],
- "type": param["type"],
- "description": f"{param['description']}",
- **(
- {"default": param["default"]}
- if "default" in param and param["default"] is not None
- else {}
- ), # Only add default if it's set
- "required": param["required"],
- }
- for param in route["parameters"]
- ],
- }
-
- if route["name"] == "default":
- # Special case for `information_extraction` based on your YAML format
- target["type"] = "default"
- target["auto-llm-dispatch-on-response"] = True
-
- output_structure["prompt_targets"].append(target)
-
- # Output as YAML
- print(
- yaml.dump(output_structure, sort_keys=False, default_flow_style=False, indent=3)
- )
-
-
-if __name__ == "__main__":
- if len(sys.argv) != 2:
- print("Usage: python targets.py ")
- sys.exit(1)
-
- input_file = sys.argv[1]
-
- # Automatically generate the output file name
- if input_file.endswith(".py"):
- output_file = input_file.replace(".py", "_prompt_targets.yml")
- else:
- print("Error: Input file must be a .py file")
- sys.exit(1)
-
- # Call the function with the input and generated output file names
- generate_prompt_targets(input_file, output_file)
-
-# Example usage:
-# python targets.py api.yaml
diff --git a/cli/pyproject.toml b/cli/pyproject.toml
index f7ac640e..9ee00403 100644
--- a/cli/pyproject.toml
+++ b/cli/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "planoai"
-version = "0.4.22"
+version = "0.4.25"
description = "Python-based CLI tool to manage Plano."
authors = [{name = "Katanemo Labs, Inc."}]
readme = "README.md"
diff --git a/cli/test/test_config_generator.py b/cli/test/test_config_generator.py
index 77b5b480..9aade29e 100644
--- a/cli/test/test_config_generator.py
+++ b/cli/test/test_config_generator.py
@@ -3,8 +3,10 @@ import pytest
import yaml
from unittest import mock
from planoai.config_generator import (
- validate_and_render_schema,
+ apply_kimi_code_provider_defaults,
migrate_inline_routing_preferences,
+ normalize_kimi_code_base_url,
+ validate_and_render_schema,
)
@@ -327,6 +329,63 @@ 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
+
""",
},
]
@@ -738,3 +797,29 @@ model_providers:
migrate_inline_routing_preferences(config_yaml)
assert config_yaml["version"] == "v0.5.0"
+
+
+def test_normalize_kimi_code_base_url_appends_v1_suffix():
+ assert (
+ normalize_kimi_code_base_url("https://api.kimi.com/coding")
+ == "https://api.kimi.com/coding/v1"
+ )
+ assert (
+ normalize_kimi_code_base_url("https://api.kimi.com/coding/")
+ == "https://api.kimi.com/coding/v1"
+ )
+ assert (
+ normalize_kimi_code_base_url("https://api.kimi.com/coding/v1")
+ == "https://api.kimi.com/coding/v1"
+ )
+
+
+def test_apply_kimi_code_provider_defaults_injects_user_agent():
+ provider = {
+ "model": "kimi-for-coding",
+ "base_url": "https://api.kimi.com/coding",
+ "access_key": "$MOONSHOTAI_API_KEY",
+ }
+ apply_kimi_code_provider_defaults(provider)
+ assert provider["base_url"] == "https://api.kimi.com/coding/v1"
+ assert provider["headers"]["User-Agent"] == "KimiCLI/1.3"
diff --git a/cli/uv.lock b/cli/uv.lock
index d63fab73..727d3a2a 100644
--- a/cli/uv.lock
+++ b/cli/uv.lock
@@ -337,7 +337,7 @@ wheels = [
[[package]]
name = "planoai"
-version = "0.4.22"
+version = "0.4.25"
source = { editable = "." }
dependencies = [
{ name = "click" },
diff --git a/config/plano_config_schema.yaml b/config/plano_config_schema.yaml
index 9560b437..2ecf3892 100644
--- a/config/plano_config_schema.yaml
+++ b/config/plano_config_schema.yaml
@@ -194,6 +194,7 @@ properties:
- digitalocean
- vercel
- openrouter
+ - moonshotai
headers:
type: object
additionalProperties:
@@ -252,6 +253,7 @@ properties:
- digitalocean
- vercel
- openrouter
+ - moonshotai
headers:
type: object
additionalProperties:
diff --git a/crates/Cargo.lock b/crates/Cargo.lock
index 39261d67..fd13d4c5 100644
--- a/crates/Cargo.lock
+++ b/crates/Cargo.lock
@@ -2552,9 +2552,9 @@ dependencies = [
[[package]]
name = "proxy-wasm"
-version = "0.2.4"
+version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8d35d9e2bc5104e2e954b149aa1d5f9fa3bb27f73b45b2706020fed101db685"
+checksum = "de8f6564bd52c2f4ff79fa5d1bd3bc10d8f822162af8d527e121e46703496aa0"
dependencies = [
"hashbrown 0.16.1",
"log",
@@ -2752,12 +2752,18 @@ dependencies = [
"num-bigint",
"percent-encoding",
"pin-project-lite",
+ "rustls 0.23.38",
+ "rustls-native-certs 0.7.3",
+ "rustls-pemfile 2.2.0",
+ "rustls-pki-types",
"ryu",
"sha1_smol",
"socket2 0.5.10",
"tokio",
+ "tokio-rustls 0.26.4",
"tokio-util",
"url",
+ "webpki-roots 0.26.11",
]
[[package]]
@@ -2965,7 +2971,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
dependencies = [
"openssl-probe 0.1.6",
- "rustls-pemfile",
+ "rustls-pemfile 1.0.4",
+ "schannel",
+ "security-framework 2.11.1",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
+dependencies = [
+ "openssl-probe 0.1.6",
+ "rustls-pemfile 2.2.0",
+ "rustls-pki-types",
"schannel",
"security-framework 2.11.1",
]
@@ -2991,6 +3010,15 @@ dependencies = [
"base64 0.21.7",
]
+[[package]]
+name = "rustls-pemfile"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@@ -4024,7 +4052,7 @@ dependencies = [
"serde_json",
"ureq-proto",
"utf8-zero",
- "webpki-roots",
+ "webpki-roots 1.0.6",
]
[[package]]
@@ -4278,6 +4306,15 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.6",
+]
+
[[package]]
name = "webpki-roots"
version = "1.0.6"
diff --git a/crates/brightstaff/Cargo.toml b/crates/brightstaff/Cargo.toml
index d2635963..0b62c313 100644
--- a/crates/brightstaff/Cargo.toml
+++ b/crates/brightstaff/Cargo.toml
@@ -43,7 +43,7 @@ lru = "0.12"
metrics = "0.23"
metrics-exporter-prometheus = { version = "0.15", default-features = false, features = ["http-listener"] }
metrics-process = "2.1"
-redis = { version = "0.27", features = ["tokio-comp"] }
+redis = { version = "0.27", features = ["tokio-comp", "tokio-rustls-comp", "tls-rustls-webpki-roots"] }
reqwest = { version = "0.12.15", features = ["stream"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
diff --git a/crates/brightstaff/src/main.rs b/crates/brightstaff/src/main.rs
index b1e17e42..90ed84c3 100644
--- a/crates/brightstaff/src/main.rs
+++ b/crates/brightstaff/src/main.rs
@@ -142,25 +142,19 @@ async fn init_app_state(
.listeners
.iter()
.find(|l| l.listener_type == ListenerType::Model);
- let resolve_chain = |filter_ids: Option>| -> Option {
- filter_ids.map(|ids| {
- let agents = ids
- .iter()
- .filter_map(|id| {
- global_agent_map
- .get(id)
- .map(|a: &Agent| (id.clone(), a.clone()))
- })
- .collect();
- ResolvedFilterChain {
- filter_ids: ids,
- agents,
- }
- })
- };
let filter_pipeline = Arc::new(FilterPipeline {
- input: resolve_chain(model_listener.and_then(|l| l.input_filters.clone())),
- output: resolve_chain(model_listener.and_then(|l| l.output_filters.clone())),
+ input: resolve_filter_chain(
+ "input_filters",
+ model_listener.and_then(|l| l.input_filters.clone()),
+ &global_agent_map,
+ )
+ .map_err(|e| format!("failed to resolve model listener input filters: {e}"))?,
+ output: resolve_filter_chain(
+ "output_filters",
+ model_listener.and_then(|l| l.output_filters.clone()),
+ &global_agent_map,
+ )
+ .map_err(|e| format!("failed to resolve model listener output filters: {e}"))?,
});
let overrides = config.overrides.clone().unwrap_or_default();
@@ -350,6 +344,29 @@ async fn init_app_state(
})
}
+fn resolve_filter_chain(
+ field_name: &str,
+ filter_ids: Option>,
+ global_agent_map: &HashMap,
+) -> Result