diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ebda64a6..0e8722ce 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -107,56 +107,6 @@ 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
# ──────────────────────────────────────────────
@@ -183,13 +133,13 @@ jobs:
load: true
tags: |
${{ env.PLANO_DOCKER_IMAGE }}
- ${{ env.DOCKER_IMAGE }}:0.4.25
+ ${{ env.DOCKER_IMAGE }}:0.4.22
${{ 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.25 ${{ env.DOCKER_IMAGE }}:latest -o /tmp/plano-image.tar
+ run: docker save ${{ env.PLANO_DOCKER_IMAGE }} ${{ env.DOCKER_IMAGE }}:0.4.22 ${{ 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
deleted file mode 100644
index 0add8a98..00000000
--- a/.github/workflows/update-providers.yml
+++ /dev/null
@@ -1,124 +0,0 @@
-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 975b9ea0..58b2191f 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`.
+Entry point: `main.py`. Built with `rich-click`. Commands: `up`, `down`, `build`, `logs`, `trace`, `init`, `cli_agent`, `generate_prompt_targets`.
### Config (config/)
diff --git a/README.md b/README.md
index 177bf8e3..b7ff7efc 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,6 +183,7 @@ 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 566bee49..b45c6873 100644
--- a/apps/www/src/components/Hero.tsx
+++ b/apps/www/src/components/Hero.tsx
@@ -24,7 +24,7 @@ export function Hero() {
>
- v0.4.25
+ v0.4.22
—
diff --git a/build_filter_image.sh b/build_filter_image.sh
index c60d8d0b..64708056 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.25
+docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.22
diff --git a/cli/planoai/__init__.py b/cli/planoai/__init__.py
index 689f32df..ec2c63da 100644
--- a/cli/planoai/__init__.py
+++ b/cli/planoai/__init__.py
@@ -1,3 +1,3 @@
"""Plano CLI - Intelligent Prompt Gateway."""
-__version__ = "0.4.25"
+__version__ = "0.4.22"
diff --git a/cli/planoai/config_generator.py b/cli/planoai/config_generator.py
index f754a183..cb07767e 100644
--- a/cli/planoai/config_generator.py
+++ b/cli/planoai/config_generator.py
@@ -39,42 +39,6 @@ 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
)
@@ -499,8 +463,6 @@ 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):
@@ -600,15 +562,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 listener-level filter IDs reference valid agent/filter IDs.
+ # Validate input_filters IDs on listeners reference valid agent/filter IDs
for listener in listeners:
- 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))}"
- )
+ 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))}"
+ )
# Validate model aliases if present
if "model_aliases" in config_yaml:
diff --git a/cli/planoai/consts.py b/cli/planoai/consts.py
index 1b7f4cd3..5cafb817 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.25")
+PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.22")
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT = "http://localhost:4317"
# Native mode constants
diff --git a/cli/planoai/main.py b/cli/planoai/main.py
index 491e2912..ea43a1a8 100644
--- a/cli/planoai/main.py
+++ b/cli/planoai/main.py
@@ -7,6 +7,7 @@ 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,
@@ -621,6 +622,28 @@ 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",
@@ -718,6 +741,7 @@ 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 0ae83844..fe90dcf1 100644
--- a/cli/planoai/rich_click_config.py
+++ b/cli/planoai/rich_click_config.py
@@ -63,5 +63,9 @@ 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
new file mode 100644
index 00000000..7c56f2b7
--- /dev/null
+++ b/cli/planoai/targets.py
@@ -0,0 +1,365 @@
+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 9ee00403..f7ac640e 100644
--- a/cli/pyproject.toml
+++ b/cli/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "planoai"
-version = "0.4.25"
+version = "0.4.22"
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 9aade29e..77b5b480 100644
--- a/cli/test/test_config_generator.py
+++ b/cli/test/test_config_generator.py
@@ -3,10 +3,8 @@ import pytest
import yaml
from unittest import mock
from planoai.config_generator import (
- apply_kimi_code_provider_defaults,
- migrate_inline_routing_preferences,
- normalize_kimi_code_base_url,
validate_and_render_schema,
+ migrate_inline_routing_preferences,
)
@@ -329,63 +327,6 @@ 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
-
""",
},
]
@@ -797,29 +738,3 @@ 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 727d3a2a..d63fab73 100644
--- a/cli/uv.lock
+++ b/cli/uv.lock
@@ -337,7 +337,7 @@ wheels = [
[[package]]
name = "planoai"
-version = "0.4.25"
+version = "0.4.22"
source = { editable = "." }
dependencies = [
{ name = "click" },
diff --git a/config/plano_config_schema.yaml b/config/plano_config_schema.yaml
index 2ecf3892..9560b437 100644
--- a/config/plano_config_schema.yaml
+++ b/config/plano_config_schema.yaml
@@ -194,7 +194,6 @@ properties:
- digitalocean
- vercel
- openrouter
- - moonshotai
headers:
type: object
additionalProperties:
@@ -253,7 +252,6 @@ properties:
- digitalocean
- vercel
- openrouter
- - moonshotai
headers:
type: object
additionalProperties:
diff --git a/crates/Cargo.lock b/crates/Cargo.lock
index fd13d4c5..39261d67 100644
--- a/crates/Cargo.lock
+++ b/crates/Cargo.lock
@@ -2552,9 +2552,9 @@ dependencies = [
[[package]]
name = "proxy-wasm"
-version = "0.2.5"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "de8f6564bd52c2f4ff79fa5d1bd3bc10d8f822162af8d527e121e46703496aa0"
+checksum = "f8d35d9e2bc5104e2e954b149aa1d5f9fa3bb27f73b45b2706020fed101db685"
dependencies = [
"hashbrown 0.16.1",
"log",
@@ -2752,18 +2752,12 @@ 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]]
@@ -2971,20 +2965,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
dependencies = [
"openssl-probe 0.1.6",
- "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",
+ "rustls-pemfile",
"schannel",
"security-framework 2.11.1",
]
@@ -3010,15 +2991,6 @@ 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"
@@ -4052,7 +4024,7 @@ dependencies = [
"serde_json",
"ureq-proto",
"utf8-zero",
- "webpki-roots 1.0.6",
+ "webpki-roots",
]
[[package]]
@@ -4306,15 +4278,6 @@ 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 0b62c313..d2635963 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", "tokio-rustls-comp", "tls-rustls-webpki-roots"] }
+redis = { version = "0.27", features = ["tokio-comp"] }
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 90ed84c3..b1e17e42 100644
--- a/crates/brightstaff/src/main.rs
+++ b/crates/brightstaff/src/main.rs
@@ -142,19 +142,25 @@ 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_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}"))?,
+ input: resolve_chain(model_listener.and_then(|l| l.input_filters.clone())),
+ output: resolve_chain(model_listener.and_then(|l| l.output_filters.clone())),
});
let overrides = config.overrides.clone().unwrap_or_default();
@@ -344,29 +350,6 @@ async fn init_app_state(
})
}
-fn resolve_filter_chain(
- field_name: &str,
- filter_ids: Option>,
- global_agent_map: &HashMap,
-) -> Result