Introduce brand new CLI experience with tracing and quickstart (#724)

Release hardens tracing and routing: clearer CLI, modular internals, updated demos/docs/tests, and improved multi-agent reliability.

Co-authored-by: Adil Hafeez <adil.hafeez@gmail.com>
This commit is contained in:
Musa 2026-02-10 13:17:43 -08:00 committed by GitHub
parent 5394ef5770
commit e3bf2b7f71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2429 additions and 83 deletions

View file

@ -1,5 +1,8 @@
import os
# Brand color - Plano purple
PLANO_COLOR = "#969FF4"
SERVICE_NAME_ARCHGW = "plano"
PLANO_DOCKER_NAME = "plano"
PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.4")

View file

@ -1,8 +1,8 @@
import json
import subprocess
import os
import time
import sys
import time
import yaml
from planoai.utils import convert_legacy_listeners, getLogger

View file

@ -35,7 +35,7 @@ def docker_stop_container(container: str) -> str:
def docker_remove_container(container: str) -> str:
result = subprocess.run(
["docker", "rm", container], capture_output=True, text=True, check=False
["docker", "rm", "-f", container], capture_output=True, text=True, check=False
)
return result.returncode
@ -48,7 +48,7 @@ def docker_start_plano_detached(
env_args = [item for key, value in env.items() for item in ["-e", f"{key}={value}"]]
port_mappings = [
f"{12001}:{12001}",
"12001:12001",
"19901:9901",
]

303
cli/planoai/init_cmd.py Normal file
View file

@ -0,0 +1,303 @@
import os
from importlib import resources
from dataclasses import dataclass
from pathlib import Path
import rich_click as click
from rich.console import Console
from rich.panel import Panel
from planoai.consts import PLANO_COLOR
@dataclass(frozen=True)
class Template:
"""
A Plano config template.
- id: stable identifier used by --template
- title/description: UI strings
- yaml_text: embedded template contents (works in PyPI installs)
"""
id: str
title: str
description: str
yaml_text: str
_TEMPLATE_PACKAGE = "planoai.templates"
def _load_template_yaml(filename: str) -> str:
return resources.files(_TEMPLATE_PACKAGE).joinpath(filename).read_text("utf-8")
BUILTIN_TEMPLATES: list[Template] = [
Template(
id="sub_agent_orchestration",
title="Sub Agent Orchestration",
description="multi-agent routing across specialized agents",
yaml_text=_load_template_yaml("sub_agent_orchestration.yaml"),
),
Template(
id="coding_agent_routing",
title="Coding Agent Routing",
description="routing preferences + model aliases for coding tasks",
yaml_text=_load_template_yaml("coding_agent_routing.yaml"),
),
Template(
id="preference_aware_routing",
title="Preference-aware LLM routing",
description="automatic LLM routing based on preferences",
yaml_text=_load_template_yaml("preference_aware_routing.yaml"),
),
Template(
id="filter_chain_guardrails",
title="Guardrails via Filter Chains",
description="input guards, query rewrite, and context building",
yaml_text=_load_template_yaml("filter_chain_guardrails.yaml"),
),
Template(
id="conversational_state_v1_responses",
title="Conversational State via v1/responses",
description="stateful responses with memory-backed storage",
yaml_text=_load_template_yaml("conversational_state_v1_responses.yaml"),
),
]
def _get_templates() -> list[Template]:
return list(BUILTIN_TEMPLATES)
def _resolve_template(template_id: str | None) -> Template | None:
if not template_id:
return None
templates = _get_templates()
for t in templates:
if t.id == template_id:
return t
return None
def _ensure_parent_dir(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def _write_clean_config(path: Path, force: bool) -> None:
_ensure_parent_dir(path)
if path.exists() and not force:
raise FileExistsError(str(path))
# user asked for NOTHING in it: empty file, with just a newline for POSIX friendliness
path.write_text("\n", encoding="utf-8")
def _write_template_config(path: Path, template: Template, force: bool) -> str:
_ensure_parent_dir(path)
if path.exists() and not force:
raise FileExistsError(str(path))
path.write_text(template.yaml_text, encoding="utf-8")
return "builtin"
def _print_config_preview(console: Console, text: str, max_lines: int = 28) -> None:
lines = text.strip("\n").splitlines()
preview_lines = lines[:max_lines]
if len(lines) > max_lines:
preview_lines.append("... (truncated)")
preview = "\n".join(preview_lines).strip("\n")
if not preview:
preview = "(empty)"
console.print(
Panel(
preview,
title="Config preview",
border_style="dim",
title_align="left",
)
)
def _questionary_style():
# prompt_toolkit style string format
from prompt_toolkit.styles import Style
return Style.from_dict(
{
"qmark": f"fg:{PLANO_COLOR} bold",
"question": "bold",
"answer": f"fg:{PLANO_COLOR} bold",
"pointer": f"fg:{PLANO_COLOR} bold",
"highlighted": f"fg:{PLANO_COLOR} bold",
"selected": f"fg:{PLANO_COLOR}",
"instruction": "fg:#888888",
"text": "",
"disabled": "fg:#666666",
}
)
def _force_truecolor_for_prompt_toolkit() -> None:
"""
Ensure prompt_toolkit uses truecolor so our brand hex (#969FF4) renders correctly.
Without this, some terminals or environments downgrade to 8-bit and the color
can look like a generic blue.
"""
# Only set if user hasn't explicitly chosen a depth.
os.environ.setdefault("PROMPT_TOOLKIT_COLOR_DEPTH", "DEPTH_24_BIT")
@click.command()
@click.option(
"--template",
"template_id_or_path",
default=None,
help="Create config.yaml from a built-in template id.",
)
@click.option(
"--clean",
is_flag=True,
help="Create an empty config.yaml with no contents.",
)
@click.option(
"--output",
"-o",
"output_path",
default="config.yaml",
show_default=True,
help="Where to write the generated config.",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing config file if it already exists.",
)
@click.option(
"--list-templates",
is_flag=True,
help="List available template ids and exit.",
)
@click.pass_context
def init(ctx, template_id_or_path, clean, output_path, force, list_templates):
"""Initialize a Plano config quickly (arrow-key interactive wizard by default)."""
import sys
console = Console()
if clean and template_id_or_path:
raise click.UsageError("Use either --clean or --template, not both.")
templates = _get_templates()
if list_templates:
console.print(f"[bold {PLANO_COLOR}]Available templates[/bold {PLANO_COLOR}]\n")
for t in templates:
console.print(f" [bold]{t.id}[/bold] - {t.description}")
return
out_path = Path(output_path).expanduser()
# Non-interactive fast paths
if clean or template_id_or_path:
if clean:
try:
_write_clean_config(out_path, force=force)
except FileExistsError:
raise click.ClickException(
f"Refusing to overwrite existing file: {out_path} (use --force)"
)
console.print(f"[green]✓[/green] Wrote [bold]{out_path}[/bold]")
_print_config_preview(console, out_path.read_text(encoding="utf-8"))
return
template = _resolve_template(template_id_or_path)
if not template:
raise click.ClickException(
f"Unknown template: {template_id_or_path}\n"
f"Run: planoai init --list-templates"
)
try:
_write_template_config(out_path, template, force=force)
except FileExistsError:
raise click.ClickException(
f"Refusing to overwrite existing file: {out_path} (use --force)"
)
console.print(
f"[green]✓[/green] Wrote [bold]{out_path}[/bold] [dim]({template.id})[/dim]"
)
_print_config_preview(console, template.yaml_text)
return
# Interactive wizard
if not (sys.stdin.isatty() and sys.stdout.isatty()):
raise click.ClickException(
"Interactive mode requires a TTY.\n"
"Use one of:\n"
" planoai init --template <id>\n"
" planoai init --clean\n"
" planoai init --list-templates"
)
_force_truecolor_for_prompt_toolkit()
# Lazy import so non-interactive users don't pay the import/compat cost
import questionary
from questionary import Choice
# Step 1: choose template (or clean)
template_choices: list[Choice] = [
Choice("Create a clean config.yaml (empty)", value="clean"),
]
for t in templates:
label = f"{t.title}{t.description}"
template_choices.append(Choice(label, value=t))
selected = questionary.select(
"Choose a template",
choices=template_choices,
style=_questionary_style(),
pointer="",
use_indicator=True,
).ask()
if not selected:
console.print("[dim]Cancelled.[/dim]")
return
# Step 2: output path (default: config.yaml)
out_answer = questionary.text(
"Where should I write the config?",
default=str(out_path),
style=_questionary_style(),
).ask()
if not out_answer:
console.print("[dim]Cancelled.[/dim]")
return
out_path = Path(out_answer).expanduser()
if out_path.exists() and not force:
overwrite = questionary.confirm(
f"{out_path} already exists. Overwrite?",
default=False,
style=_questionary_style(),
).ask()
if not overwrite:
console.print("[dim]Cancelled.[/dim]")
return
force = True
if selected == "clean":
_write_clean_config(out_path, force=True)
console.print(f"[green]✓[/green] Wrote [bold]{out_path}[/bold]")
_print_config_preview(console, out_path.read_text(encoding="utf-8"))
return
template = selected
_write_template_config(out_path, template, force=True)
console.print(
f"[green]✓[/green] Wrote [bold]{out_path}[/bold] [dim]({template.id})[/dim]"
)
_print_config_preview(console, template.yaml_text)

View file

@ -1,11 +1,12 @@
import click
import os
import sys
import subprocess
import multiprocessing
import importlib.metadata
import json
import subprocess
import sys
import rich_click as click
from planoai import targets
# Brand color - Plano purple
PLANO_COLOR = "#969FF4"
from planoai.docker_cli import (
docker_validate_plano_schema,
stream_gateway_logs,
@ -14,7 +15,6 @@ from planoai.docker_cli import (
from planoai.utils import (
getLogger,
get_llm_provider_access_keys,
has_ingress_listener,
load_env_file_to_dict,
set_log_level,
stream_access_logs,
@ -26,60 +26,106 @@ from planoai.core import (
stop_docker_container,
start_cli_agent,
)
from planoai.init_cmd import init as init_cmd
from planoai.trace_cmd import trace as trace_cmd, start_trace_listener_background
from planoai.consts import (
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT,
PLANO_DOCKER_IMAGE,
PLANO_DOCKER_NAME,
SERVICE_NAME_ARCHGW,
)
from planoai.rich_click_config import configure_rich_click
from planoai.versioning import check_version_status, get_latest_version, get_version
log = getLogger(__name__)
# ref https://patorjk.com/software/taag/#p=display&f=Doom&t=Plano&x=none&v=4&h=4&w=80&we=false
logo = r"""
______ _
| ___ \ |
| |_/ / | __ _ _ __ ___
| __/| |/ _` | '_ \ / _ \
| | | | (_| | | | | (_) |
\_| |_|\__,_|_| |_|\___/
"""
def _is_port_in_use(port: int) -> bool:
"""Check if a TCP port is already bound on localhost."""
import socket
# Command to build plano Docker images
ARCHGW_DOCKERFILE = "./Dockerfile"
def get_version():
try:
# First try to get version from package metadata (for installed packages)
version = importlib.metadata.version("planoai")
return version
except importlib.metadata.PackageNotFoundError:
# Fallback to version defined in __init__.py (for development)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
from planoai import __version__
s.bind(("0.0.0.0", port))
return False
except OSError:
return True
return __version__
except ImportError:
return "version not found"
# ref https://patorjk.com/software/taag/#p=display&f=Doom&t=Plano&x=none&v=4&h=4&w=80&we=false
LOGO = f"""[bold {PLANO_COLOR}]
______ _
| ___ \\ |
| |_/ / | __ _ _ __ ___
| __/| |/ _` | '_ \\ / _ \\
| | | | (_| | | | | (_) |
\\_| |_|\\__,_|_| |_|\\___/
[/bold {PLANO_COLOR}]"""
def _console():
from rich.console import Console
return Console()
def _print_cli_header(console) -> None:
console.print(
f"\n[bold {PLANO_COLOR}]Plano CLI[/bold {PLANO_COLOR}] [dim]v{get_version()}[/dim]\n"
)
def _print_missing_keys(console, missing_keys: list[str]) -> None:
console.print(f"\n[red]✗[/red] [red]Missing API keys![/red]\n")
for key in missing_keys:
console.print(f" [red]•[/red] [bold]{key}[/bold] not found")
console.print(f"\n[dim]Set the environment variable(s):[/dim]")
for key in missing_keys:
console.print(f' [cyan]export {key}="your-api-key"[/cyan]')
console.print(f"\n[dim]Or create a .env file in the config directory.[/dim]\n")
def _print_version(console, current_version: str) -> None:
console.print(
f"[bold {PLANO_COLOR}]plano[/bold {PLANO_COLOR}] version [cyan]{current_version}[/cyan]"
)
def _maybe_check_updates(console, current_version: str) -> None:
if os.environ.get("PLANO_SKIP_VERSION_CHECK"):
return
latest_version = get_latest_version()
status = check_version_status(current_version, latest_version)
if status["is_outdated"]:
console.print(
f"\n[yellow]⚠ Update available:[/yellow] [bold]{status['latest']}[/bold]"
)
console.print("[dim]Run: uv pip install --upgrade planoai[/dim]")
elif latest_version:
console.print(f"[dim]✓ You're up to date[/dim]")
configure_rich_click(PLANO_COLOR)
@click.group(invoke_without_command=True)
@click.option("--version", is_flag=True, help="Show the plano cli version and exit.")
@click.option("--version", is_flag=True, help="Show the Plano CLI version and exit.")
@click.pass_context
def main(ctx, version):
# Set log level from LOG_LEVEL env var only
set_log_level(os.environ.get("LOG_LEVEL", "info"))
console = _console()
if version:
click.echo(f"plano cli version: {get_version()}")
current_version = get_version()
_print_version(console, current_version)
_maybe_check_updates(console, current_version)
ctx.exit()
log.info(f"Starting plano cli version: {get_version()}")
if ctx.invoked_subcommand is None:
click.echo("""Plano (AI-native proxy and dataplane for agentic apps) CLI""")
click.echo(logo)
console.print(LOGO)
console.print("[dim]The Delivery Infrastructure for Agentic Apps[/dim]\n")
click.echo(ctx.get_help())
@ -133,81 +179,156 @@ def build():
help="Run Plano in the foreground. Default is False",
is_flag=True,
)
def up(file, path, foreground):
@click.option(
"--with-tracing",
default=False,
help="Start a local OTLP trace collector on port 4317.",
is_flag=True,
)
@click.option(
"--tracing-port",
default=4317,
type=int,
help="Port for the OTLP trace collector (default: 4317).",
show_default=True,
)
def up(file, path, foreground, with_tracing, tracing_port):
"""Starts Plano."""
from rich.status import Status
console = _console()
_print_cli_header(console)
# Use the utility function to find config file
arch_config_file = find_config_file(path, file)
# Check if the file exists
if not os.path.exists(arch_config_file):
log.info(f"Error: {arch_config_file} does not exist.")
return
log.info(f"Validating {arch_config_file}")
(
validation_return_code,
validation_stdout,
validation_stderr,
) = docker_validate_plano_schema(arch_config_file)
if validation_return_code != 0:
log.info(f"Error: Validation failed. Exiting")
log.info(f"Validation stdout: {validation_stdout}")
log.info(f"Validation stderr: {validation_stderr}")
console.print(
f"[red]✗[/red] Config file not found: [dim]{arch_config_file}[/dim]"
)
sys.exit(1)
# Set the ARCH_CONFIG_FILE environment variable
with Status(
"[dim]Validating configuration[/dim]", spinner="dots", spinner_style="dim"
):
(
validation_return_code,
_,
validation_stderr,
) = docker_validate_plano_schema(arch_config_file)
if validation_return_code != 0:
console.print(f"[red]✗[/red] Validation failed")
if validation_stderr:
console.print(f" [dim]{validation_stderr.strip()}[/dim]")
sys.exit(1)
console.print(f"[green]✓[/green] Configuration valid")
# Set up environment
env_stage = {
"OTEL_TRACING_GRPC_ENDPOINT": DEFAULT_OTEL_TRACING_GRPC_ENDPOINT,
}
env = os.environ.copy()
# Remove PATH variable if present
env.pop("PATH", None)
# check if access_keys are preesnt in the config file
access_keys = get_llm_provider_access_keys(arch_config_file=arch_config_file)
# remove duplicates
# Check access keys
access_keys = get_llm_provider_access_keys(arch_config_file=arch_config_file)
access_keys = set(access_keys)
# remove the $ from the access_keys
access_keys = [item[1:] if item.startswith("$") else item for item in access_keys]
missing_keys = []
if access_keys:
if file:
app_env_file = os.path.join(
os.path.dirname(os.path.abspath(file)), ".env"
) # check the .env file in the path
app_env_file = os.path.join(os.path.dirname(os.path.abspath(file)), ".env")
else:
app_env_file = os.path.abspath(os.path.join(path, ".env"))
if not os.path.exists(
app_env_file
): # check to see if the environment variables in the current environment or not
if not os.path.exists(app_env_file):
for access_key in access_keys:
if env.get(access_key) is None:
log.info(f"Access Key: {access_key} not found. Exiting Start")
sys.exit(1)
missing_keys.append(access_key)
else:
env_stage[access_key] = env.get(access_key)
else: # .env file exists, use that to send parameters to Arch
else:
env_file_dict = load_env_file_to_dict(app_env_file)
for access_key in access_keys:
if env_file_dict.get(access_key) is None:
log.info(f"Access Key: {access_key} not found. Exiting Start")
sys.exit(1)
missing_keys.append(access_key)
else:
env_stage[access_key] = env_file_dict[access_key]
if missing_keys:
_print_missing_keys(console, missing_keys)
sys.exit(1)
# Pass log level to the Docker container — supervisord uses LOG_LEVEL
# to set RUST_LOG (brightstaff) and envoy component log levels
env_stage["LOG_LEVEL"] = os.environ.get("LOG_LEVEL", "info")
# Start the local OTLP trace collector if --with-tracing is set
trace_server = None
if with_tracing:
if _is_port_in_use(tracing_port):
# A listener is already running (e.g. `planoai trace listen`)
console.print(
f"[green]✓[/green] Trace collector already running on port [cyan]{tracing_port}[/cyan]"
)
else:
try:
trace_server = start_trace_listener_background(grpc_port=tracing_port)
console.print(
f"[green]✓[/green] Trace collector listening on [cyan]0.0.0.0:{tracing_port}[/cyan]"
)
except Exception as e:
console.print(
f"[red]✗[/red] Failed to start trace collector on port {tracing_port}: {e}"
)
console.print(
f"\n[dim]Check if another process is using port {tracing_port}:[/dim]"
)
console.print(f" [cyan]lsof -i :{tracing_port}[/cyan]")
console.print(f"\n[dim]Or use a different port:[/dim]")
console.print(
f" [cyan]planoai up --with-tracing --tracing-port 4318[/cyan]\n"
)
sys.exit(1)
# Update the OTEL endpoint so the gateway sends traces to the right port
env_stage[
"OTEL_TRACING_GRPC_ENDPOINT"
] = f"http://host.docker.internal:{tracing_port}"
env.update(env_stage)
start_arch(arch_config_file, env, foreground=foreground)
try:
start_arch(arch_config_file, env, foreground=foreground)
# When tracing is enabled but --foreground is not, keep the process
# alive so the OTLP collector continues to receive spans.
if trace_server is not None and not foreground:
console.print(
f"[dim]Plano is running. Trace collector active on port {tracing_port}. Press Ctrl+C to stop.[/dim]"
)
trace_server.wait_for_termination()
except KeyboardInterrupt:
if trace_server is not None:
console.print(f"\n[dim]Stopping trace collector...[/dim]")
finally:
if trace_server is not None:
trace_server.stop(grace=2)
@click.command()
def down():
"""Stops Arch."""
stop_docker_container()
"""Stops Plano."""
console = _console()
_print_cli_header(console)
with console.status(
f"[{PLANO_COLOR}]Shutting down Plano...[/{PLANO_COLOR}]", spinner="dots"
):
stop_docker_container()
@click.command()
@ -306,12 +427,15 @@ def cli_agent(type, file, path, settings):
sys.exit(1)
# add commands to the main group
main.add_command(up)
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")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,71 @@
import rich_click as click
def configure_rich_click(plano_color: str) -> None:
click.rich_click.USE_RICH_MARKUP = True
click.rich_click.USE_MARKDOWN = False
click.rich_click.SHOW_ARGUMENTS = True
click.rich_click.GROUP_ARGUMENTS_OPTIONS = True
click.rich_click.STYLE_ERRORS_SUGGESTION = "dim italic"
click.rich_click.ERRORS_SUGGESTION = (
"Try running the '--help' flag for more information."
)
click.rich_click.ERRORS_EPILOGUE = ""
# Custom colors matching Plano brand.
click.rich_click.STYLE_OPTION = f"dim {plano_color}"
click.rich_click.STYLE_ARGUMENT = f"dim {plano_color}"
click.rich_click.STYLE_COMMAND = f"bold {plano_color}"
click.rich_click.STYLE_SWITCH = "bold green"
click.rich_click.STYLE_METAVAR = "bold yellow"
click.rich_click.STYLE_USAGE = "bold"
click.rich_click.STYLE_USAGE_COMMAND = f"bold dim {plano_color}"
click.rich_click.STYLE_HELPTEXT_FIRST_LINE = "white italic"
click.rich_click.STYLE_HELPTEXT = ""
click.rich_click.STYLE_HEADER_TEXT = "bold"
click.rich_click.STYLE_FOOTER_TEXT = "dim"
click.rich_click.STYLE_OPTIONS_PANEL_BORDER = "dim"
click.rich_click.ALIGN_OPTIONS_PANEL = "left"
click.rich_click.MAX_WIDTH = 100
# Option groups for better organization.
click.rich_click.OPTION_GROUPS = {
"planoai up": [
{
"name": "Configuration",
"options": ["--path", "file"],
},
{
"name": "Runtime Options",
"options": ["--foreground", "--with-tracing", "--tracing-port"],
},
],
"planoai logs": [
{
"name": "Log Options",
"options": ["--debug", "--follow"],
},
],
}
# Command groups for main help.
click.rich_click.COMMAND_GROUPS = {
"planoai": [
{
"name": "Gateway Commands",
"commands": ["up", "down", "build", "logs"],
},
{
"name": "Agent Commands",
"commands": ["cli-agent"],
},
{
"name": "Observability",
"commands": ["trace"],
},
{
"name": "Utilities",
"commands": ["generate-prompt-targets"],
},
],
}

View file

@ -0,0 +1,43 @@
version: v0.1
listeners:
egress_traffic:
address: 0.0.0.0
port: 12000
message_format: openai
timeout: 30s
llm_providers:
# OpenAI Models
- model: openai/gpt-5-2025-08-07
access_key: $OPENAI_API_KEY
routing_preferences:
- name: code generation
description: generating new code snippets, functions, or boilerplate based on user prompts or requirements
- model: openai/gpt-4.1-2025-04-14
access_key: $OPENAI_API_KEY
routing_preferences:
- name: code understanding
description: understand and explain existing code snippets, functions, or libraries
# Anthropic Models
- model: anthropic/claude-sonnet-4-5
default: true
access_key: $ANTHROPIC_API_KEY
- model: anthropic/claude-haiku-4-5
access_key: $ANTHROPIC_API_KEY
# Ollama Models
- model: ollama/llama3.1
base_url: http://host.docker.internal:11434
# Model aliases - friendly names that map to actual provider names
model_aliases:
# Alias for a small faster Claude model
arch.claude.code.small.fast:
target: claude-haiku-4-5
tracing:
random_sampling: 100

View file

@ -0,0 +1,25 @@
version: v0.1
listeners:
egress_traffic:
address: 0.0.0.0
port: 12000
message_format: openai
timeout: 30s
llm_providers:
# OpenAI Models
- model: openai/gpt-5-mini-2025-08-07
access_key: $OPENAI_API_KEY
default: true
# Anthropic Models
- model: anthropic/claude-sonnet-4-20250514
access_key: $ANTHROPIC_API_KEY
# State storage configuration for v1/responses API
# Manages conversation state for multi-turn conversations
state_storage:
# Type: memory | postgres
type: memory

View file

@ -0,0 +1,50 @@
version: v0.3.0
agents:
- id: rag_agent
url: http://rag-agents:10505
filters:
- id: input_guards
url: http://rag-agents:10500
type: http
# type: mcp (default)
# transport: streamable-http (default)
# tool: input_guards (default - same as filter id)
- id: query_rewriter
url: http://rag-agents:10501
type: http
# type: mcp (default)
# transport: streamable-http (default)
# tool: query_rewriter (default - same as filter id)
- id: context_builder
url: http://rag-agents:10502
type: http
model_providers:
- model: openai/gpt-4o-mini
access_key: $OPENAI_API_KEY
default: true
- model: openai/gpt-4o
access_key: $OPENAI_API_KEY
model_aliases:
fast-llm:
target: gpt-4o-mini
smart-llm:
target: gpt-4o
listeners:
- type: agent
name: agent_1
port: 8001
router: plano_orchestrator_v1
agents:
- id: rag_agent
description: virtual assistant for retrieval augmented generation tasks
filter_chain:
- input_guards
- query_rewriter
- context_builder
tracing:
random_sampling: 100

View file

@ -0,0 +1,29 @@
version: v0.1.0
listeners:
egress_traffic:
address: 0.0.0.0
port: 12000
message_format: openai
timeout: 30s
llm_providers:
- model: openai/gpt-4o-mini
access_key: $OPENAI_API_KEY
default: true
- model: openai/gpt-4o
access_key: $OPENAI_API_KEY
routing_preferences:
- name: code understanding
description: understand and explain existing code snippets, functions, or libraries
- model: anthropic/claude-sonnet-4-20250514
access_key: $ANTHROPIC_API_KEY
routing_preferences:
- name: code generation
description: generating new code snippets, functions, or boilerplate based on user prompts or requirements
tracing:
random_sampling: 100

View file

@ -0,0 +1,57 @@
version: v0.3.0
agents:
- id: weather_agent
url: http://langchain-weather-agent:10510
- id: flight_agent
url: http://crewai-flight-agent:10520
model_providers:
- model: openai/gpt-4o
access_key: $OPENAI_API_KEY
default: true
- model: openai/gpt-4o-mini
access_key: $OPENAI_API_KEY # smaller, faster, cheaper model for extracting entities like location
listeners:
- type: agent
name: travel_booking_service
port: 8001
router: plano_orchestrator_v1
agents:
- id: weather_agent
description: |
WeatherAgent is a specialized AI assistant for real-time weather information and forecasts. It provides accurate weather data for any city worldwide using the Open-Meteo API, helping travelers plan their trips with up-to-date weather conditions.
Capabilities:
* Get real-time weather conditions and multi-day forecasts for any city worldwide using Open-Meteo API (free, no API key needed)
* Provides current temperature
* Provides multi-day forecasts
* Provides weather conditions
* Provides sunrise/sunset times
* Provides detailed weather information
* Understands conversation context to resolve location references from previous messages
* Handles weather-related questions including "What's the weather in [city]?", "What's the forecast for [city]?", "How's the weather in [city]?"
* When queries include both weather and other travel questions (e.g., flights, currency), this agent answers ONLY the weather part
- id: flight_agent
description: |
FlightAgent is an AI-powered tool specialized in providing live flight information between airports. It leverages the FlightAware AeroAPI to deliver real-time flight status, gate information, and delay updates.
Capabilities:
* Get live flight information between airports using FlightAware AeroAPI
* Shows real-time flight status
* Shows scheduled/estimated/actual departure and arrival times
* Shows gate and terminal information
* Shows delays
* Shows aircraft type
* Shows flight status
* Automatically resolves city names to airport codes (IATA/ICAO)
* Understands conversation context to infer origin/destination from follow-up questions
* Handles flight-related questions including "What flights go from [city] to [city]?", "Do flights go to [city]?", "Are there direct flights from [city]?"
* When queries include both flight and other travel questions (e.g., weather, currency), this agent answers ONLY the flight part
tracing:
random_sampling: 100

971
cli/planoai/trace_cmd.py Normal file
View file

@ -0,0 +1,971 @@
import json
import os
import re
import string
import threading
import time
from collections import OrderedDict
from concurrent import futures
from dataclasses import dataclass
from datetime import datetime, timezone
from fnmatch import fnmatch
from typing import Any
import grpc
import rich_click as click
from opentelemetry.proto.collector.trace.v1 import (
trace_service_pb2,
trace_service_pb2_grpc,
)
from rich.console import Console
from rich.text import Text
from rich.tree import Tree
from planoai.consts import PLANO_COLOR
DEFAULT_GRPC_PORT = 4317
MAX_TRACES = 50
MAX_SPANS_PER_TRACE = 500
@dataclass
class TraceSummary:
trace_id: str
start_ns: int
end_ns: int
@property
def total_ms(self) -> float:
return max(0, (self.end_ns - self.start_ns) / 1_000_000)
@property
def timestamp(self) -> str:
if self.start_ns <= 0:
return "unknown"
dt = datetime.fromtimestamp(self.start_ns / 1_000_000_000, tz=timezone.utc)
return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
def _parse_filter_patterns(filter_patterns: tuple[str, ...]) -> list[str]:
parts: list[str] = []
for raw in filter_patterns:
for token in raw.split(","):
part = token.strip()
if not part:
raise ValueError("Filter contains empty tokens.")
parts.append(part)
return parts
def _is_hex(value: str, length: int) -> bool:
if len(value) != length:
return False
return all(char in string.hexdigits for char in value)
def _parse_where_filters(where_filters: tuple[str, ...]) -> list[tuple[str, str]]:
parsed: list[tuple[str, str]] = []
invalid: list[str] = []
key_pattern = re.compile(r"^[A-Za-z0-9_.:-]+$")
for raw in where_filters:
if raw.count("=") != 1:
invalid.append(raw)
continue
key, value = raw.split("=", 1)
key = key.strip()
value = value.strip()
if not key or not value or not key_pattern.match(key):
invalid.append(raw)
continue
parsed.append((key, value))
if invalid:
invalid_list = ", ".join(invalid)
raise click.ClickException(
f"Invalid --where filter(s): {invalid_list}. Use key=value."
)
return parsed
def _collect_attr_keys(traces: list[dict[str, Any]]) -> set[str]:
keys: set[str] = set()
for trace in traces:
for span in trace.get("spans", []):
for item in span.get("attributes", []):
key = item.get("key")
if key:
keys.add(str(key))
return keys
def _fetch_traces_raw() -> list[dict[str, Any]]:
port = os.environ.get("PLANO_TRACE_PORT", str(DEFAULT_GRPC_PORT))
target = f"127.0.0.1:{port}"
try:
channel = grpc.insecure_channel(target)
stub = channel.unary_unary(
"/plano.TraceQuery/GetTraces",
request_serializer=lambda x: x,
response_deserializer=lambda x: x,
)
response = stub(b"", timeout=3)
channel.close()
data = json.loads(response)
traces = data.get("traces", [])
if isinstance(traces, list):
return traces
except Exception:
pass
return []
def _attrs(span: dict[str, Any]) -> dict[str, str]:
attrs = {}
for item in span.get("attributes", []):
key = item.get("key")
value_obj = item.get("value", {})
value = value_obj.get("stringValue")
if value is None and "intValue" in value_obj:
value = value_obj.get("intValue")
if value is None and "doubleValue" in value_obj:
value = value_obj.get("doubleValue")
if value is None and "boolValue" in value_obj:
value = value_obj.get("boolValue")
if key is not None and value is not None:
attrs[str(key)] = str(value)
return attrs
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _parse_since_seconds(value: str | None) -> int | None:
if not value:
return None
value = value.strip()
if not value:
return None
if len(value) < 2:
return None
number, unit = value[:-1], value[-1]
try:
qty = int(number)
except ValueError:
return None
multiplier = {"m": 60, "h": 60 * 60, "d": 60 * 60 * 24}.get(unit)
if multiplier is None:
return None
return qty * multiplier
def _matches_pattern(value: str, pattern: str) -> bool:
if pattern == "*":
return True
if "*" not in pattern:
return value == pattern
parts = [part for part in pattern.split("*") if part]
if not parts:
return True
remaining = value
for idx, part in enumerate(parts):
pos = remaining.find(part)
if pos == -1:
return False
if idx == 0 and not pattern.startswith("*") and pos != 0:
return False
remaining = remaining[pos + len(part) :]
if not pattern.endswith("*") and remaining:
return False
return True
def _attribute_map(span: dict[str, Any]) -> dict[str, str]:
attrs = {}
for item in span.get("attributes", []):
key = item.get("key")
value_obj = item.get("value", {})
value = value_obj.get("stringValue")
if value is None and "intValue" in value_obj:
value = value_obj.get("intValue")
if value is None and "doubleValue" in value_obj:
value = value_obj.get("doubleValue")
if value is None and "boolValue" in value_obj:
value = value_obj.get("boolValue")
if key is not None and value is not None:
attrs[str(key)] = str(value)
return attrs
def _filter_attributes(span: dict[str, Any], patterns: list[str]) -> dict[str, Any]:
if not patterns:
return span
attributes = span.get("attributes", [])
filtered = [
item
for item in attributes
if any(
_matches_pattern(str(item.get("key", "")), pattern) for pattern in patterns
)
]
cloned = dict(span)
cloned["attributes"] = filtered
return cloned
def _filter_traces(
traces: list[dict[str, Any]],
filter_patterns: list[str],
where_filters: list[tuple[str, str]],
since_seconds: int | None,
) -> tuple[list[dict[str, Any]], list[str]]:
now_nanos = int(time.time() * 1_000_000_000)
since_nanos = now_nanos - (since_seconds * 1_000_000_000) if since_seconds else None
filtered_traces: list[dict[str, Any]] = []
for trace in traces:
spans = trace.get("spans", []) or []
if since_nanos is not None:
spans = [
span
for span in spans
if _safe_int(span.get("startTimeUnixNano", 0)) >= since_nanos
]
if filter_patterns:
spans = [_filter_attributes(span, filter_patterns) for span in spans]
if not spans:
continue
candidate = dict(trace)
candidate["spans"] = spans
filtered_traces.append(candidate)
if where_filters:
def matches_where(trace: dict[str, Any]) -> bool:
for key, value in where_filters:
if not any(
_attribute_map(span).get(key) == value
for span in trace.get("spans", [])
):
return False
return True
filtered_traces = [trace for trace in filtered_traces if matches_where(trace)]
trace_ids = [trace.get("trace_id", "") for trace in filtered_traces]
return filtered_traces, trace_ids
class _TraceStore:
"""Thread-safe in-memory store backed by a fixed-length deque.
Spans may arrive with **different** ``traceId`` values but are
linked via ``parentSpanId``. This store groups them into logical
traces by following parent-child span relationships, so all
connected spans end up under a single trace group regardless of
the ``traceId`` they were emitted with.
"""
def __init__(self, max_traces: int = MAX_TRACES) -> None:
self._traces: OrderedDict[str, dict[str, Any]] = OrderedDict()
self._seen_spans: dict[str, set[str]] = {}
# span_id → group key (the trace_id used as the dict key)
self._span_to_group: dict[str, str] = {}
# parent_span_id → group key for spans whose parent arrived first
self._parent_to_group: dict[str, str] = {}
self._max_traces = max_traces
self._lock = threading.Lock()
def _evict_oldest(self) -> None:
"""Remove the oldest trace group (caller must hold *_lock*)."""
if not self._traces:
return
oldest_id, oldest = self._traces.popitem(last=False)
self._seen_spans.pop(oldest_id, None)
for span in oldest.get("spans", []):
sid = span.get("spanId", "")
self._span_to_group.pop(sid, None)
self._parent_to_group.pop(sid, None)
def _merge_groups(self, src_key: str, dst_key: str) -> None:
"""Move all spans from *src_key* group into *dst_key* (caller holds lock)."""
if src_key == dst_key or src_key not in self._traces:
return
src = self._traces.pop(src_key)
dst = self._traces[dst_key]
dst_seen = self._seen_spans[dst_key]
src_seen = self._seen_spans.pop(src_key, set())
for span in src.get("spans", []):
sid = span.get("spanId", "")
if sid and sid not in dst_seen:
dst["spans"].append(span)
dst_seen.add(sid)
self._span_to_group[sid] = dst_key
for sid in src_seen:
self._span_to_group[sid] = dst_key
# Update parent→group mappings that pointed to src.
for pid, gid in list(self._parent_to_group.items()):
if gid == src_key:
self._parent_to_group[pid] = dst_key
def merge_spans(self, trace_id: str, spans: list[dict[str, Any]]) -> None:
"""Merge *spans* into the correct trace group.
The group is determined by following ``parentSpanId`` /
``spanId`` links, falling back to *trace_id* when no link
exists.
"""
with self._lock:
for span in spans:
span_id = span.get("spanId", "")
parent_id = span.get("parentSpanId", "")
# Determine which group this span belongs to.
group_key: str | None = None
# 1. Does the parent already live in a group?
if parent_id and parent_id in self._span_to_group:
group_key = self._span_to_group[parent_id]
# 2. Is this span already known as a parent of another group?
if group_key is None and span_id and span_id in self._parent_to_group:
group_key = self._parent_to_group.pop(span_id)
# 3. Fall back to the wire trace_id.
if group_key is None:
group_key = trace_id
# Create the group if needed.
if group_key not in self._traces:
if len(self._traces) >= self._max_traces:
self._evict_oldest()
self._traces[group_key] = {"trace_id": group_key, "spans": []}
self._seen_spans[group_key] = set()
else:
self._traces.move_to_end(group_key)
# Insert span (deduplicate).
seen = self._seen_spans[group_key]
if span_id and span_id in seen:
continue
if span_id:
seen.add(span_id)
self._span_to_group[span_id] = group_key
if len(self._traces[group_key]["spans"]) < MAX_SPANS_PER_TRACE:
self._traces[group_key]["spans"].append(span)
# Record parent link so future spans can find this group.
if parent_id and parent_id not in self._span_to_group:
self._parent_to_group[parent_id] = group_key
# If this span's span_id is the parent of an existing
# *different* group, merge that group into this one.
if span_id and span_id in self._parent_to_group:
other = self._parent_to_group.pop(span_id)
if other != group_key and other in self._traces:
self._merge_groups(other, group_key)
def snapshot(self) -> list[dict[str, Any]]:
"""Return traces ordered newest-first."""
with self._lock:
traces = list(self._traces.values())
traces.reverse()
return traces
_TRACE_STORE = _TraceStore()
def _anyvalue_to_python(value_obj: Any) -> Any:
"""Convert an opentelemetry AnyValue protobuf to a Python primitive."""
if hasattr(value_obj, "string_value") and value_obj.HasField("value"):
kind = value_obj.WhichOneof("value")
if kind == "string_value":
return value_obj.string_value
if kind == "int_value":
return value_obj.int_value
if kind == "double_value":
return value_obj.double_value
if kind == "bool_value":
return value_obj.bool_value
return None
def _proto_span_to_dict(span: Any, service_name: str) -> dict[str, Any]:
"""Convert a protobuf Span message to the dict format used internally."""
span_dict: dict[str, Any] = {
"traceId": span.trace_id.hex(),
"spanId": span.span_id.hex(),
"parentSpanId": span.parent_span_id.hex() if span.parent_span_id else "",
"name": span.name,
"startTimeUnixNano": str(span.start_time_unix_nano),
"endTimeUnixNano": str(span.end_time_unix_nano),
"service": service_name,
"attributes": [],
}
for kv in span.attributes:
py_val = _anyvalue_to_python(kv.value)
if py_val is not None:
value_dict: dict[str, Any] = {}
if isinstance(py_val, str):
value_dict["stringValue"] = py_val
elif isinstance(py_val, bool):
value_dict["boolValue"] = py_val
elif isinstance(py_val, int):
value_dict["intValue"] = str(py_val)
elif isinstance(py_val, float):
value_dict["doubleValue"] = py_val
span_dict["attributes"].append({"key": kv.key, "value": value_dict})
return span_dict
class _OTLPTraceServicer(trace_service_pb2_grpc.TraceServiceServicer):
"""gRPC servicer that receives OTLP ExportTraceServiceRequest and
merges incoming spans into the global _TRACE_STORE by trace_id."""
_console = Console(stderr=True)
def Export(self, request, context): # noqa: N802
for resource_spans in request.resource_spans:
service_name = "unknown"
for attr in resource_spans.resource.attributes:
if attr.key == "service.name":
val = _anyvalue_to_python(attr.value)
if val is not None:
service_name = str(val)
break
for scope_spans in resource_spans.scope_spans:
for span in scope_spans.spans:
trace_id = span.trace_id.hex()
if not trace_id:
continue
span_dict = _proto_span_to_dict(span, service_name)
_TRACE_STORE.merge_spans(trace_id, [span_dict])
short_id = trace_id[:8]
short_span = span.span_id.hex()[:8]
span_start = (
datetime.fromtimestamp(
span.start_time_unix_nano / 1_000_000_000, tz=timezone.utc
)
.astimezone()
.strftime("%H:%M:%S.%f")[:-3]
)
dur_ns = span.end_time_unix_nano - span.start_time_unix_nano
dur_s = dur_ns / 1_000_000_000
dur_str = f"{dur_s:.3f}".rstrip("0").rstrip(".")
dur_str = f"{dur_str}s"
self._console.print(
f"[dim]{span_start}[/dim], "
f"trace=[yellow]{short_id}[/yellow], "
f"span=[yellow]{short_span}[/yellow], "
f"[bold {_service_color(service_name)}]{service_name}[/bold {_service_color(service_name)}] "
f"[cyan]{span.name}[/cyan] "
f"[dim]({dur_str})[/dim]"
)
return trace_service_pb2.ExportTraceServiceResponse()
class _TraceQueryHandler(grpc.GenericRpcHandler):
"""gRPC handler that serves stored traces to the CLI show command."""
def service(self, handler_call_details):
if handler_call_details.method == "/plano.TraceQuery/GetTraces":
return grpc.unary_unary_rpc_method_handler(
self._get_traces,
request_deserializer=lambda x: x,
response_serializer=lambda x: x,
)
return None
@staticmethod
def _get_traces(_request, _context):
traces = _TRACE_STORE.snapshot()
return json.dumps({"traces": traces}, separators=(",", ":")).encode("utf-8")
def _create_trace_server(host: str, grpc_port: int) -> grpc.Server:
"""Create, bind, and start an OTLP/gRPC trace-collection server.
Returns the running ``grpc.Server``. The caller is responsible
for calling ``server.stop()`` when done.
"""
grpc_server = grpc.server(
futures.ThreadPoolExecutor(max_workers=4),
handlers=[_TraceQueryHandler()],
)
trace_service_pb2_grpc.add_TraceServiceServicer_to_server(
_OTLPTraceServicer(), grpc_server
)
grpc_server.add_insecure_port(f"{host}:{grpc_port}")
grpc_server.start()
return grpc_server
def _start_trace_listener(host: str, grpc_port: int) -> None:
"""Start the OTLP/gRPC listener and block until interrupted."""
console = Console()
grpc_server = _create_trace_server(host, grpc_port)
console.print()
console.print(f"[bold {PLANO_COLOR}]Listening for traces...[/bold {PLANO_COLOR}]")
console.print(
f"[green]●[/green] gRPC (OTLP receiver) on [cyan]{host}:{grpc_port}[/cyan]"
)
console.print("[dim]Press Ctrl+C to stop.[/dim]")
console.print()
try:
grpc_server.wait_for_termination()
except KeyboardInterrupt:
pass
finally:
grpc_server.stop(grace=2)
def start_trace_listener_background(
host: str = "0.0.0.0", grpc_port: int = DEFAULT_GRPC_PORT
) -> grpc.Server:
"""Start the trace listener in the background (non-blocking).
Returns the running ``grpc.Server`` so the caller can call
``server.stop()`` later.
"""
return _create_trace_server(host, grpc_port)
def _span_time_ns(span: dict[str, Any], key: str) -> int:
try:
return int(span.get(key, 0))
except (TypeError, ValueError):
return 0
def _trace_id_short(trace_id: str) -> str:
return trace_id[:8] if trace_id else "unknown"
def _trace_summary(trace: dict[str, Any]) -> TraceSummary:
spans = trace.get("spans", [])
start_ns = min((_span_time_ns(s, "startTimeUnixNano") for s in spans), default=0)
end_ns = max((_span_time_ns(s, "endTimeUnixNano") for s in spans), default=0)
return TraceSummary(
trace_id=trace.get("trace_id", "unknown"),
start_ns=start_ns,
end_ns=end_ns,
)
def _service_color(service: str) -> str:
service = service.lower()
if "inbound" in service:
return "white"
if "outbound" in service:
return "white"
if "orchestrator" in service:
return PLANO_COLOR
if "routing" in service:
return "magenta"
if "agent" in service:
return "cyan"
if "llm" in service:
return "green"
return "white"
# Attributes to show for inbound/outbound spans when not verbose (trimmed view).
_INBOUND_OUTBOUND_ATTR_KEYS = (
"http.method",
"http.target",
"http.status_code",
"url.scheme",
"guid:x-request-id",
"request_size",
"response_size",
)
def _trim_attrs_for_display(
attrs: dict[str, str], service: str, verbose: bool
) -> dict[str, str]:
if verbose:
return attrs
if "inbound" in service.lower() or "outbound" in service.lower():
attrs = {k: v for k, v in attrs.items() if k in _INBOUND_OUTBOUND_ATTR_KEYS}
return {k: v for k, v in attrs.items() if k != "service.name.override"}
def _sorted_attr_items(attrs: dict[str, str]) -> list[tuple[str, str]]:
priority = [
"http.method",
"http.target",
"http.status_code",
"guid:x-request-id",
"request_size",
"response_size",
"routing.determination_ms",
"route.selected_model",
"selection.agents",
"selection.agent_count",
"agent.name",
"agent.sequence",
"duration_ms",
"llm.model",
"llm.is_streaming",
"llm.time_to_first_token",
"llm.duration_ms",
"llm.response_bytes",
]
prioritized = [(k, attrs[k]) for k in priority if k in attrs]
prioritized_keys = {k for k, _ in prioritized}
remaining = [(k, v) for k, v in attrs.items() if k not in prioritized_keys]
remaining.sort(key=lambda item: item[0])
return prioritized + remaining
def _display_attr_value(key: str, value: str) -> str:
if key == "http.status_code" and value != "200":
return f"{value} ⚠️"
return value
def _build_tree(trace: dict[str, Any], console: Console, verbose: bool = False) -> None:
spans = trace.get("spans", [])
if not spans:
console.print("[yellow]No spans found for this trace.[/yellow]")
return
start_ns = min((_span_time_ns(s, "startTimeUnixNano") for s in spans), default=0)
end_ns = max((_span_time_ns(s, "endTimeUnixNano") for s in spans), default=0)
total_ms = max(0, (end_ns - start_ns) / 1_000_000)
trace_id = trace.get("trace_id", "unknown")
console.print(
f"\n[bold]Trace:[/bold] {trace_id} [dim]({total_ms:.0f}ms total)[/dim]\n"
)
spans.sort(key=lambda s: _span_time_ns(s, "startTimeUnixNano"))
tree = Tree("", guide_style="dim")
for span in spans:
service = span.get("service", "plano(unknown)")
name = span.get("name", "")
offset_ms = max(
0, (_span_time_ns(span, "startTimeUnixNano") - start_ns) / 1_000_000
)
color = _service_color(service)
label = Text(f"{offset_ms:.0f}ms ", style="yellow")
label.append(service, style=f"bold {color}")
if name:
label.append(f" {name}", style="dim white")
node = tree.add(label)
attrs = _trim_attrs_for_display(_attrs(span), service, verbose)
sorted_items = list(_sorted_attr_items(attrs))
for idx, (key, value) in enumerate(sorted_items):
attr_line = Text()
attr_line.append(f"{key}: ", style="white")
attr_line.append(
_display_attr_value(key, str(value)),
style=f"{PLANO_COLOR}",
)
if idx == len(sorted_items) - 1:
attr_line.append("\n")
node.add(attr_line)
console.print(tree)
console.print()
def _select_request(
console: Console, traces: list[dict[str, Any]]
) -> dict[str, Any] | None:
try:
import questionary
from questionary import Choice
from prompt_toolkit.styles import Style
except ImportError as exc:
raise click.ClickException(
"Interactive selection requires 'questionary'. "
"Install it or rerun with --json."
) from exc
if not traces:
return None
style = Style.from_dict(
{
"qmark": f"fg:{PLANO_COLOR} bold",
"question": "bold",
"answer": f"fg:{PLANO_COLOR} bold",
"pointer": f"fg:{PLANO_COLOR} bold",
"highlighted": f"fg:{PLANO_COLOR} bold",
"selected": f"fg:{PLANO_COLOR}",
"instruction": "fg:#888888",
"text": "",
"disabled": "fg:#666666",
}
)
choices = []
for trace in traces:
summary = _trace_summary(trace)
label = f"{_trace_id_short(summary.trace_id)} ({summary.total_ms:.0f}ms total • {summary.timestamp})"
choices.append(Choice(label, value=trace))
selected = questionary.select(
"Select a trace to view:",
choices=choices,
style=style,
pointer="",
).ask()
if not selected:
console.print("[dim]Cancelled.[/dim]")
return None
return selected
@click.argument("target", required=False)
@click.option(
"--filter",
"filter_patterns",
multiple=True,
help=(
"Limit displayed attributes to matching keys "
"(wildcards supported). Repeatable."
),
)
@click.option(
"--where",
"where_filters",
multiple=True,
help="Match traces that contain key=value. Repeatable (AND semantics).",
)
@click.option("--list", "list_only", is_flag=True, help="List trace IDs only.")
@click.option(
"--no-interactive",
is_flag=True,
help="Disable interactive prompts and selections.",
)
@click.option("--limit", type=int, default=None, help="Limit results.")
@click.option("--since", default=None, help="Look back window (e.g. 5m, 2h, 1d).")
@click.option("--json", "json_out", is_flag=True, help="Output raw JSON.")
@click.option(
"--verbose",
"-v",
is_flag=True,
help="Show all span attributes; default trims inbound/outbound to a few keys.",
)
def _run_trace_show(
target,
filter_patterns,
where_filters,
list_only,
no_interactive,
limit,
since,
json_out,
verbose,
):
"""Trace requests from the local OTLP listener."""
console = Console()
try:
patterns = _parse_filter_patterns(filter_patterns)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
parsed_where = _parse_where_filters(where_filters)
if limit is not None and limit < 0:
raise click.ClickException("Limit must be greater than or equal to 0.")
since_seconds = _parse_since_seconds(since)
if target is None:
target = "any" if list_only or since or limit else "last"
if list_only and target not in (None, "last", "any"):
raise click.ClickException("Target and --list cannot be used together.")
short_target = None
if isinstance(target, str) and target not in ("last", "any"):
target_lower = target.lower()
if len(target_lower) == 8:
if not _is_hex(target_lower, 8) or target_lower == "00000000":
raise click.ClickException("Short trace ID must be 8 hex characters.")
short_target = target_lower
elif len(target_lower) == 32:
if not _is_hex(target_lower, 32) or target_lower == "0" * 32:
raise click.ClickException("Trace ID must be 32 hex characters.")
else:
raise click.ClickException("Trace ID must be 8 or 32 hex characters.")
traces_raw = _fetch_traces_raw()
if traces_raw:
available_keys = _collect_attr_keys(traces_raw)
if parsed_where:
missing_keys = [key for key, _ in parsed_where if key not in available_keys]
if missing_keys:
missing_list = ", ".join(missing_keys)
raise click.ClickException(f"Unknown --where key(s): {missing_list}")
if patterns:
unmatched = [
pattern
for pattern in patterns
if not any(fnmatch(key, pattern) for key in available_keys)
]
if unmatched:
unmatched_list = ", ".join(unmatched)
console.print(
f"[yellow]Warning:[/yellow] Filter key(s) not found: {unmatched_list}. "
"Returning unfiltered traces."
)
traces, trace_ids = _filter_traces(
traces_raw, patterns, parsed_where, since_seconds
)
if target == "last":
traces = traces[:1]
trace_ids = trace_ids[:1]
elif target not in (None, "any") and short_target is None:
traces = [trace for trace in traces if trace.get("trace_id") == target]
trace_ids = [trace.get("trace_id") for trace in traces]
if short_target:
traces = [
trace
for trace in traces
if trace.get("trace_id", "").lower().startswith(short_target)
]
trace_ids = [trace.get("trace_id") for trace in traces]
if limit is not None:
if list_only:
trace_ids = trace_ids[:limit]
else:
traces = traces[:limit]
if json_out:
if list_only:
console.print_json(data={"trace_ids": trace_ids})
else:
console.print_json(data={"traces": traces})
return
if list_only:
if traces and console.is_terminal and not no_interactive:
selected = _select_request(console, traces)
if selected:
_build_tree(selected, console, verbose=verbose)
return
if traces:
trace_ids = [_trace_id_short(_trace_summary(t).trace_id) for t in traces]
if not trace_ids:
console.print("[yellow]No trace IDs found.[/yellow]")
return
console.print("\n[bold]Trace IDs:[/bold]")
for trace_id in trace_ids:
console.print(f" [dim]-[/dim] {trace_id}")
return
if not traces:
console.print("[yellow]No traces found.[/yellow]")
return
trace_obj = traces[0]
_build_tree(trace_obj, console, verbose=verbose)
@click.group(invoke_without_command=True)
@click.argument("target", required=False)
@click.option(
"--filter",
"filter_patterns",
multiple=True,
help=(
"Limit displayed attributes to matching keys "
"(wildcards supported). Repeatable."
),
)
@click.option(
"--where",
"where_filters",
multiple=True,
help="Match traces that contain key=value. Repeatable (AND semantics).",
)
@click.option("--list", "list_only", is_flag=True, help="List trace IDs only.")
@click.option(
"--no-interactive",
is_flag=True,
help="Disable interactive prompts and selections.",
)
@click.option("--limit", type=int, default=None, help="Limit results.")
@click.option("--since", default=None, help="Look back window (e.g. 5m, 2h, 1d).")
@click.option("--json", "json_out", is_flag=True, help="Output raw JSON.")
@click.option(
"--verbose",
"-v",
is_flag=True,
help="Show all span attributes; default trims inbound/outbound to a few keys.",
)
@click.pass_context
def trace(
ctx,
target,
filter_patterns,
where_filters,
list_only,
no_interactive,
limit,
since,
json_out,
verbose,
):
"""Trace requests from the local OTLP listener."""
if ctx.invoked_subcommand:
return
if target == "listen" and not any(
[
filter_patterns,
where_filters,
list_only,
no_interactive,
limit,
since,
json_out,
verbose,
]
):
_start_trace_listener("0.0.0.0", DEFAULT_GRPC_PORT)
return
_run_trace_show(
target,
filter_patterns,
where_filters,
list_only,
no_interactive,
limit,
since,
json_out,
verbose,
)
@trace.command("listen")
@click.option("--host", default="0.0.0.0", show_default=True)
@click.option(
"--port",
type=int,
default=DEFAULT_GRPC_PORT,
show_default=True,
help="gRPC port for receiving OTLP traces.",
)
def trace_listen(host: str, port: int) -> None:
"""Listen for OTLP/gRPC traces."""
_start_trace_listener(host, port)

70
cli/planoai/versioning.py Normal file
View file

@ -0,0 +1,70 @@
import importlib.metadata
import re
PYPI_PACKAGE_NAME = "planoai"
PYPI_URL = f"https://pypi.org/pypi/{PYPI_PACKAGE_NAME}/json"
def get_version() -> str:
try:
# First try package metadata (installed package).
return importlib.metadata.version(PYPI_PACKAGE_NAME)
except importlib.metadata.PackageNotFoundError:
# Fallback to local development version.
try:
from planoai import __version__
return __version__
except ImportError:
return "version not found"
def get_latest_version(timeout: float = 2.0) -> str | None:
"""Fetch the latest version from PyPI."""
import requests
try:
response = requests.get(PYPI_URL, timeout=timeout)
if response.status_code == 200:
data = response.json()
return data.get("info", {}).get("version")
except (requests.RequestException, ValueError):
# Network error or invalid JSON - fail silently.
return None
return None
def parse_version(version_str: str) -> tuple[int, ...]:
"""Parse version string into a comparable tuple."""
clean_version = re.split(r"[a-zA-Z]", version_str)[0]
parts = clean_version.split(".")
return tuple(int(p) for p in parts if p.isdigit())
def check_version_status(
current: str, latest: str | None
) -> dict[str, str | bool | None]:
"""Compare current version with latest and return status metadata."""
if latest is None:
return {
"is_outdated": False,
"current": current,
"latest": None,
"message": None,
}
try:
is_outdated = parse_version(current) < parse_version(latest)
return {
"is_outdated": is_outdated,
"current": current,
"latest": latest,
"message": f"Update available: {latest}" if is_outdated else None,
}
except (ValueError, TypeError):
return {
"is_outdated": False,
"current": current,
"latest": latest,
"message": None,
}

View file

@ -7,10 +7,15 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"click>=8.1.7,<9.0.0",
"grpcio>=1.60.0",
"jinja2>=3.1.4,<4.0.0",
"jsonschema>=4.23.0,<5.0.0",
"opentelemetry-proto>=1.20.0",
"questionary>=2.1.1,<3.0.0",
"pyyaml>=6.0.2,<7.0.0",
"requests>=2.31.0,<3.0.0",
"rich>=14.2.0",
"rich-click>=1.9.5",
]
[project.optional-dependencies]
@ -30,6 +35,10 @@ path = "planoai/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["planoai"]
include = ["planoai/templates/*.yaml"]
[tool.hatch.build.targets.sdist]
include = ["planoai/templates/*.yaml"]
[tool.pytest.ini_options]
addopts = ["-v"]

51
cli/test/test_init.py Normal file
View file

@ -0,0 +1,51 @@
from click.testing import CliRunner
from planoai.init_cmd import init
def test_init_clean_writes_empty_config(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(init, ["--clean"])
assert result.exit_code == 0, result.output
config_path = tmp_path / "config.yaml"
assert config_path.exists()
assert config_path.read_text(encoding="utf-8") == "\n"
def test_init_template_builtin_writes_config(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(init, ["--template", "coding_agent_routing"])
assert result.exit_code == 0, result.output
config_path = tmp_path / "config.yaml"
assert config_path.exists()
config_text = config_path.read_text(encoding="utf-8")
assert "llm_providers:" in config_text
def test_init_refuses_overwrite_without_force(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "config.yaml").write_text("hello", encoding="utf-8")
runner = CliRunner()
result = runner.invoke(init, ["--clean"])
assert result.exit_code != 0
assert "Refusing to overwrite" in result.output
def test_init_force_overwrites(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "config.yaml").write_text("hello", encoding="utf-8")
runner = CliRunner()
result = runner.invoke(init, ["--clean", "--force"])
assert result.exit_code == 0, result.output
assert (tmp_path / "config.yaml").read_text(encoding="utf-8") == "\n"

View file

@ -0,0 +1,163 @@
import pytest
from unittest import mock
from planoai.versioning import (
get_version,
get_latest_version,
parse_version,
check_version_status,
PYPI_URL,
)
class TestParseVersion:
"""Tests for version string parsing."""
def test_parse_simple_version(self):
assert parse_version("1.0.0") == (1, 0, 0)
assert parse_version("0.4.1") == (0, 4, 1)
assert parse_version("10.20.30") == (10, 20, 30)
def test_parse_two_part_version(self):
assert parse_version("1.0") == (1, 0)
assert parse_version("2.5") == (2, 5)
def test_parse_version_with_prerelease(self):
# Pre-release suffixes should be stripped
assert parse_version("0.4.1a1") == (0, 4, 1)
assert parse_version("1.0.0beta2") == (1, 0, 0)
assert parse_version("2.0.0rc1") == (2, 0, 0)
class TestCheckVersionStatus:
"""Tests for version comparison logic."""
def test_current_equals_latest(self):
status = check_version_status("0.4.1", "0.4.1")
assert status["is_outdated"] is False
assert status["current"] == "0.4.1"
assert status["latest"] == "0.4.1"
assert status["message"] is None
def test_current_is_outdated(self):
status = check_version_status("0.4.1", "0.5.0")
assert status["is_outdated"] is True
assert status["current"] == "0.4.1"
assert status["latest"] == "0.5.0"
assert "Update available" in status["message"]
assert "0.5.0" in status["message"]
def test_current_is_newer(self):
# Dev version might be newer than PyPI
status = check_version_status("0.5.0", "0.4.1")
assert status["is_outdated"] is False
assert status["message"] is None
def test_major_version_outdated(self):
status = check_version_status("0.4.1", "1.0.0")
assert status["is_outdated"] is True
def test_minor_version_outdated(self):
status = check_version_status("0.4.1", "0.5.0")
assert status["is_outdated"] is True
def test_patch_version_outdated(self):
status = check_version_status("0.4.1", "0.4.2")
assert status["is_outdated"] is True
def test_latest_is_none(self):
# When PyPI check fails
status = check_version_status("0.4.1", None)
assert status["is_outdated"] is False
assert status["latest"] is None
assert status["message"] is None
class TestGetLatestVersion:
"""Tests for PyPI version fetching."""
def test_successful_fetch(self):
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"info": {"version": "0.5.0"}}
with mock.patch("requests.get", return_value=mock_response):
version = get_latest_version()
assert version == "0.5.0"
def test_network_error(self):
import requests
with mock.patch(
"requests.get", side_effect=requests.RequestException("Network error")
):
version = get_latest_version()
assert version is None
def test_timeout(self):
import requests
with mock.patch("requests.get", side_effect=requests.Timeout("Timeout")):
version = get_latest_version()
assert version is None
def test_invalid_json(self):
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.side_effect = ValueError("Invalid JSON")
with mock.patch("requests.get", return_value=mock_response):
version = get_latest_version()
assert version is None
def test_404_response(self):
mock_response = mock.Mock()
mock_response.status_code = 404
with mock.patch("requests.get", return_value=mock_response):
version = get_latest_version()
assert version is None
class TestVersionCheckIntegration:
"""Integration tests simulating version check scenarios."""
def test_outdated_version_message(self, capsys):
"""Simulate an outdated version scenario."""
from rich.console import Console
console = Console(force_terminal=True)
current_version = "0.4.1"
# Mock PyPI returning a newer version
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"info": {"version": "0.5.0"}}
with mock.patch("requests.get", return_value=mock_response):
latest = get_latest_version()
status = check_version_status(current_version, latest)
assert status["is_outdated"] is True
assert status["latest"] == "0.5.0"
def test_up_to_date_version(self):
"""Simulate an up-to-date version scenario."""
current_version = "0.4.1"
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"info": {"version": "0.4.1"}}
with mock.patch("requests.get", return_value=mock_response):
latest = get_latest_version()
status = check_version_status(current_version, latest)
assert status["is_outdated"] is False
def test_skip_version_check_env_var(self, monkeypatch):
"""Test that PLANO_SKIP_VERSION_CHECK skips the check."""
monkeypatch.setenv("PLANO_SKIP_VERSION_CHECK", "1")
import os
assert os.environ.get("PLANO_SKIP_VERSION_CHECK") == "1"

182
cli/uv.lock generated
View file

@ -117,6 +117,67 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
[[package]]
name = "grpcio"
version = "1.78.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" },
{ url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" },
{ url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" },
{ url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" },
{ url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" },
{ url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" },
{ url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" },
{ url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" },
{ url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" },
{ url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" },
{ url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" },
{ url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" },
{ url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" },
{ url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" },
{ url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" },
{ url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" },
{ url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" },
{ url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" },
{ url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" },
{ url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" },
{ url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" },
{ url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" },
{ url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" },
{ url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" },
{ url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" },
{ url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" },
{ url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" },
{ url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" },
{ url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" },
{ url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" },
{ url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" },
{ url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" },
{ url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" },
{ url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" },
{ url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" },
{ url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" },
{ url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" },
{ url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" },
{ url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" },
{ url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" },
{ url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" },
{ url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" },
{ url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" },
{ url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" },
]
[[package]]
name = "idna"
version = "3.10"
@ -174,6 +235,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
@ -232,6 +305,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" },
]
[[package]]
name = "packaging"
version = "25.0"
@ -243,14 +337,19 @@ wheels = [
[[package]]
name = "planoai"
version = "0.4.3"
version = "0.4.4"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "grpcio" },
{ name = "jinja2" },
{ name = "jsonschema" },
{ name = "opentelemetry-proto" },
{ name = "pyyaml" },
{ name = "questionary" },
{ name = "requests" },
{ name = "rich" },
{ name = "rich-click" },
]
[package.optional-dependencies]
@ -266,11 +365,16 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.7,<9.0.0" },
{ name = "grpcio", specifier = ">=1.60.0" },
{ name = "jinja2", specifier = ">=3.1.4,<4.0.0" },
{ name = "jsonschema", specifier = ">=4.23.0,<5.0.0" },
{ name = "opentelemetry-proto", specifier = ">=1.20.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1,<9.0.0" },
{ name = "pyyaml", specifier = ">=6.0.2,<7.0.0" },
{ name = "questionary", specifier = ">=2.1.1,<3.0.0" },
{ name = "requests", specifier = ">=2.31.0,<3.0.0" },
{ name = "rich", specifier = ">=14.2.0" },
{ name = "rich-click", specifier = ">=1.9.5" },
]
provides-extras = ["dev"]
@ -286,6 +390,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "protobuf"
version = "6.33.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
{ url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
{ url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
{ url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
{ url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@ -357,6 +488,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
]
[[package]]
name = "questionary"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "prompt-toolkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
]
[[package]]
name = "referencing"
version = "0.36.2"
@ -386,6 +529,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rich"
version = "14.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]
[[package]]
name = "rich-click"
version = "1.9.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/d1/b60ca6a8745e76800b50c7ee246fd73f08a3be5d8e0b551fc93c19fa1203/rich_click-1.9.5.tar.gz", hash = "sha256:48120531493f1533828da80e13e839d471979ec8d7d0ca7b35f86a1379cc74b6", size = 73927, upload-time = "2025-12-21T14:49:44.167Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/0a/d865895e1e5d88a60baee0fc3703eb111c502ee10c8c107516bc7623abf8/rich_click-1.9.5-py3-none-any.whl", hash = "sha256:9b195721a773b1acf0e16ff9ec68cef1e7d237e53471e6e3f7ade462f86c403a", size = 70580, upload-time = "2025-12-21T14:49:42.905Z" },
]
[[package]]
name = "rpds-py"
version = "0.27.1"
@ -577,3 +748,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "wcwidth"
version = "0.2.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
]

View file

@ -28,7 +28,7 @@ use crate::state::response_state_processor::ResponsesStateProcessor;
use crate::state::{
extract_input_items, retrieve_and_combine_input, StateStorage, StateStorageError,
};
use crate::tracing::{operation_component, set_service_name};
use crate::tracing::{llm as tracing_llm, operation_component, set_service_name};
fn full<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, hyper::Error> {
Full::new(chunk.into())
@ -62,6 +62,10 @@ pub async fn llm_chat(
request_id = %request_id,
http.method = %request.method(),
http.path = %request_path,
llm.model = tracing::field::Empty,
llm.tools = tracing::field::Empty,
llm.user_message_preview = tracing::field::Empty,
llm.temperature = tracing::field::Empty,
);
// Execute the rest of the handler inside the span
@ -149,7 +153,7 @@ async fn llm_chat_inner(
// Model alias resolution: update model field in client_request immediately
// This ensures all downstream objects use the resolved model
let model_from_request = client_request.model().to_string();
let _temperature = client_request.get_temperature();
let temperature = client_request.get_temperature();
let is_streaming_request = client_request.is_streaming();
let alias_resolved_model = resolve_model_alias(&model_from_request, &model_aliases);
@ -180,10 +184,25 @@ async fn llm_chat_inner(
};
// Extract tool names and user message preview for span attributes
let _tool_names = client_request.get_tool_names();
let _user_message_preview = client_request
let tool_names = client_request.get_tool_names();
let user_message_preview = client_request
.get_recent_user_message()
.map(|msg| truncate_message(&msg, 50));
let span = tracing::Span::current();
if let Some(temp) = temperature {
span.record(tracing_llm::TEMPERATURE, tracing::field::display(temp));
}
if let Some(tools) = &tool_names {
let formatted_tools = tools
.iter()
.map(|name| format!("{}(...)", name))
.collect::<Vec<_>>()
.join("\n");
span.record(tracing_llm::TOOLS, formatted_tools.as_str());
}
if let Some(preview) = &user_message_preview {
span.record(tracing_llm::USER_MESSAGE_PREVIEW, preview.as_str());
}
// Extract messages for signal analysis (clone before moving client_request)
let messages_for_signals = Some(client_request.get_messages());
@ -321,6 +340,7 @@ async fn llm_chat_inner(
// Router returned "none" sentinel, use validated resolved_model from request
alias_resolved_model.clone()
};
tracing::Span::current().record(tracing_llm::MODEL_NAME, resolved_model.as_str());
let span_name = if model_from_request == resolved_model {
format!("POST {} {}", request_path, resolved_model)

View file

@ -55,4 +55,3 @@ listeners:
tracing:
random_sampling: 100
opentracing_grpc_endpoint: http://jaeger:4317

View file

@ -6,6 +6,7 @@ services:
dockerfile: Dockerfile
ports:
- "8001:8001"
- "12000:12000"
environment:
- ARCH_CONFIG_PATH=/app/arch_config.yaml
- OPENAI_API_KEY=${OPENAI_API_KEY:?OPENAI_API_KEY environment variable is required but not set}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 MiB

After

Width:  |  Height:  |  Size: 4 MiB

Before After
Before After

View file

@ -146,6 +146,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
[[package]]
name = "jiter"
version = "0.12.0"
@ -262,6 +274,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/d5/eb52edff49d3d5ea116e225538c118699ddeb7c29fa17ec28af14bc10033/openai-2.13.0-py3-none-any.whl", hash = "sha256:746521065fed68df2f9c2d85613bb50844343ea81f60009b60e6a600c9352c79", size = 1066837, upload-time = "2025-12-16T18:19:43.124Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
@ -438,6 +463,7 @@ dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "openai" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "uvicorn" },
]
@ -445,11 +471,12 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.2.1" },
{ name = "fastapi", specifier = ">=0.104.1" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.24.0" },
{ name = "openai", specifier = ">=2.13.0" },
{ name = "openai", specifier = ">=1.0.0" },
{ name = "opentelemetry-api", specifier = ">=1.20.0" },
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "uvicorn", specifier = ">=0.24.0" },
{ name = "uvicorn", specifier = ">=0.30.0" },
]
[[package]]
@ -486,3 +513,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef468
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]

View file

@ -168,6 +168,94 @@ How to Initiate A Trace
You can adjust this value from 0-100.
Tracing with the CLI
--------------------
The Plano CLI ships with a local OTLP/gRPC listener and a trace viewer so you can inspect spans without wiring a full observability backend. This is ideal for development, debugging, and quick QA.
Quick Start
~~~~~~~~~~~
You can enable tracing in either of these ways:
1. Start the local listener explicitly:
.. code-block:: console
$ planoai trace listen
2. Or start Plano with tracing enabled (auto-starts the local OTLP listener):
.. code-block:: console
$ planoai up --with-tracing
# Optional: choose a different listener port
$ planoai up --with-tracing --tracing-port 4318
3. Send requests through Plano as usual. The listener accepts OTLP/gRPC on:
- ``0.0.0.0:4317`` (default)
4. View the most recent trace:
.. code-block:: console
$ planoai trace
Inspect and Filter Traces
~~~~~~~~~~~~~~~~~~~~~~~~~
List available trace IDs:
.. code-block:: console
$ planoai trace --list
Open a specific trace (full or short trace ID):
.. code-block:: console
$ planoai trace 7f4e9a1c
$ planoai trace 7f4e9a1c0d9d4a0bb9bf5a8a7d13f62a
Filter by attributes and time window:
.. code-block:: console
$ planoai trace --where llm.model=gpt-4o-mini --since 30m
$ planoai trace --filter "http.*" --limit 5
Return JSON for automation:
.. code-block:: console
$ planoai trace --json
$ planoai trace --list --json
Show full span attributes (disable default compact view):
.. code-block:: console
$ planoai trace --verbose
$ planoai trace -v
Point the CLI at a different local listener port:
.. code-block:: console
$ export PLANO_TRACE_PORT=50051
$ planoai trace --list
Notes
~~~~~
- ``--where`` accepts repeatable ``key=value`` filters and uses AND semantics.
- ``--filter`` supports wildcards (``*``) to limit displayed attributes.
- ``--no-interactive`` disables prompts when listing traces.
- By default, inbound/outbound spans use a compact attribute view.
Trace Propagation
-----------------
@ -409,6 +497,59 @@ tools like AWS X-Ray and Datadog, enhancing observability and facilitating faste
Additional Resources
--------------------
CLI Reference
~~~~~~~~~~~~~
``planoai trace``
Trace requests captured by the local OTLP listener.
**Synopsis**
.. code-block:: console
$ planoai trace [TARGET] [OPTIONS]
**Targets**
- ``last`` (default): show the most recent trace.
- ``any``: allow interactive selection when available.
- ``<trace-id>``: full 32-hex trace ID.
- ``<short-id>``: first 8 hex characters.
**Options**
- ``--filter <pattern>``: limit displayed attributes to matching keys (supports ``*``).
- ``--where <key=value>``: match traces containing a specific attribute (repeatable, AND).
- ``--list``: list trace IDs only.
- ``--no-interactive``: disable interactive prompts/selections.
- ``--limit <n>``: limit the number of traces returned.
- ``--since <window>``: look back window (``5m``, ``2h``, ``1d``).
- ``--json``: output raw JSON instead of formatted output.
- ``--verbose, -v``: show all span attributes. By default, inbound/outbound
spans are displayed in a compact view.
**Environment**
- ``PLANO_TRACE_PORT``: gRPC port used by ``planoai trace`` to query traces
(defaults to ``4317``).
``planoai trace listen``
Start a local OTLP/gRPC listener.
**Synopsis**
.. code-block:: console
$ planoai trace listen [OPTIONS]
**Options**
- ``--host <host>``: bind address (default: ``0.0.0.0``).
- ``--port <port>``: gRPC listener port (default: ``4317``).
External References
~~~~~~~~~~~~~~~~~~~
- `OpenTelemetry Documentation <https://opentelemetry.io/docs/>`_
- `W3C Trace Context Specification <https://www.w3.org/TR/trace-context/>`_
- `AWS X-Ray Exporter <https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/awsxrayexporter>`_