From e1dc19e111d1c83e02298408d99ba3cbaf52e1fb Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 09:04:36 +1030 Subject: [PATCH 01/18] feat: agentgateway integration in plit init + Docker - plit init bootstraps agentgateway config structure + ES256 keypair - Generates JWKS public key for JWT validation - Writes config fragments (listeners, rules, backends) from LLM choice - Embeds assemble-config.sh, start.sh, decrypt_keys.py scripts - Appends AGENTGATEWAY_ENABLED/URL/DIR + JWT_PRIVATE_KEY to .env - Dockerfile includes agentgateway binary (v1.0.1) + yq - Entrypoint starts agentgateway before Pipelit stack - Exposes ports 4000 (LLM), 3000 (MCP), 15000 (admin) Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 13 +- docker/entrypoint.sh | 29 ++ src/commands/api.rs | 194 ++++++++ src/commands/init/agentgateway.rs | 422 ++++++++++++++++++ .../agentgateway_scripts/assemble-config.sh | 139 ++++++ .../init/agentgateway_scripts/decrypt_keys.py | 63 +++ .../init/agentgateway_scripts/start.sh | 43 ++ src/commands/init/config.rs | 32 ++ src/commands/init/mod.rs | 9 +- src/commands/mod.rs | 1 + src/main.rs | 7 + 11 files changed, 950 insertions(+), 2 deletions(-) create mode 100644 src/commands/api.rs create mode 100644 src/commands/init/agentgateway.rs create mode 100755 src/commands/init/agentgateway_scripts/assemble-config.sh create mode 100755 src/commands/init/agentgateway_scripts/decrypt_keys.py create mode 100755 src/commands/init/agentgateway_scripts/start.sh diff --git a/Dockerfile b/Dockerfile index 1d37d99..eca5947 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,10 +46,21 @@ RUN ARCH=$(uname -m) && \ mv "/root/.config/plit/dragonfly-${ARCH}" /root/.config/plit/dragonfly && \ chmod +x /root/.config/plit/dragonfly +# Download agentgateway binary +RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') && \ + curl -fSL "https://github.com/agentgateway/agentgateway/releases/download/v1.0.1/agentgateway-linux-${ARCH}" \ + -o /usr/local/bin/agentgateway && \ + chmod +x /usr/local/bin/agentgateway + +# Install yq for config assembly +RUN curl -fSL "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" \ + -o /usr/local/bin/yq && \ + chmod +x /usr/local/bin/yq + COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -EXPOSE 8080 8000 +EXPOSE 8080 8000 4000 3000 15000 VOLUME ["/root/.local/share/plit", "/root/.config/pipelit/workspaces"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 2f23669..b8480ca 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -23,5 +23,34 @@ if [ ! -f "$CONFIG_FILE" ]; then eval plit init $INIT_ARGS fi +# Start agentgateway if configured +AGW_DIR=$(grep AGENTGATEWAY_DIR /root/.config/plit/.env 2>/dev/null | cut -d= -f2) +if [ -n "$AGW_DIR" ] && [ -d "$AGW_DIR" ]; then + # Ensure binary is in place + if [ ! -f "$AGW_DIR/bin/agentgateway" ]; then + mkdir -p "$AGW_DIR/bin" + cp /usr/local/bin/agentgateway "$AGW_DIR/bin/agentgateway" + fi + + # Get encryption key for key decryption + FIELD_ENCRYPTION_KEY=$(grep FIELD_ENCRYPTION_KEY /root/.config/plit/.env 2>/dev/null | head -1 | cut -d= -f2 | tr -d '"') + + # Start agentgateway in background + echo "Starting agentgateway..." + cd "$AGW_DIR" && \ + FIELD_ENCRYPTION_KEY="$FIELD_ENCRYPTION_KEY" \ + PYTHON=/root/.local/share/plit/venv/bin/python3 \ + YQ=yq \ + ./start.sh > /tmp/agw.log 2>&1 & + + # Wait for agentgateway to be ready (up to 10 seconds) + for i in $(seq 1 10); do + curl -s -o /dev/null http://localhost:4000/ 2>/dev/null && break + sleep 1 + done + echo "agentgateway started" + cd / +fi + echo "Starting plit stack..." exec plit start --foreground diff --git a/src/commands/api.rs b/src/commands/api.rs new file mode 100644 index 0000000..edb531c --- /dev/null +++ b/src/commands/api.rs @@ -0,0 +1,194 @@ +use anyhow::{Context, Result}; +use clap::Subcommand; +use pipelit_client::apis::{configuration::Configuration, workflows_api}; +use pipelit_client::models::ValidateDslIn; + +use super::auth::pipelit_config; + +#[derive(Subcommand)] +pub enum ApiCommands { + #[command(subcommand)] + Workflow(WorkflowCommands), + + NodeTypes, +} + +#[derive(Subcommand)] +pub enum WorkflowCommands { + List { + #[arg(long, default_value = "50")] + limit: i32, + }, + + Get { + slug: String, + }, + + Validate { + #[arg(long)] + yaml: String, + }, + + Delete { + slug: String, + + #[arg(long)] + force: bool, + }, +} + +pub async fn run(cmd: ApiCommands, json_output: bool) -> Result<()> { + let config = pipelit_config()?; + + match cmd { + ApiCommands::Workflow(wf_cmd) => run_workflow(wf_cmd, &config, json_output).await, + ApiCommands::NodeTypes => run_node_types(&config, json_output).await, + } +} + +async fn run_workflow( + cmd: WorkflowCommands, + config: &Configuration, + json_output: bool, +) -> Result<()> { + match cmd { + WorkflowCommands::List { limit } => { + let result = + workflows_api::list_workflows_api_v1_workflows_get(config, Some(limit), Some(0)) + .await + .context("Failed to list workflows")?; + + if json_output { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + if let Some(arr) = result.as_array() { + println!("{:<8} {:<30} {:<20}", "ID", "NAME", "SLUG"); + println!("{}", "-".repeat(60)); + for wf in arr { + println!( + "{:<8} {:<30} {:<20}", + wf["id"].as_i64().unwrap_or(0), + truncate(wf["name"].as_str().unwrap_or("-"), 28), + wf["slug"].as_str().unwrap_or("-"), + ); + } + println!("\nTotal: {} workflows", arr.len()); + } + } + Ok(()) + } + + WorkflowCommands::Get { slug } => { + let result = + workflows_api::get_workflow_detail_api_v1_workflows_slug_get(config, &slug) + .await + .context("Failed to get workflow")?; + + if json_output { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!("Workflow: {}", result.name); + println!("Slug: {}", result.slug); + println!("ID: {}", result.id); + println!("Description: {}", result.description); + if let Some(Some(tags)) = &result.tags { + println!("Tags: {}", tags.join(", ")); + } + if let Some(nodes) = &result.nodes { + println!("\nNodes: {}", nodes.len()); + for node in nodes { + println!(" - {} ({:?})", node.node_id, node.component_type); + } + } + if let Some(edges) = &result.edges { + println!("\nEdges: {}", edges.len()); + } + } + Ok(()) + } + + WorkflowCommands::Validate { yaml } => { + let yaml_content = std::fs::read_to_string(&yaml) + .with_context(|| format!("Failed to read {}", yaml))?; + + let req = ValidateDslIn::new(yaml_content); + + let result = workflows_api::validate_dsl_endpoint_api_v1_workflows_validate_dsl_post( + config, req, + ) + .await + .context("Failed to validate DSL")?; + + if json_output { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + let valid = result["valid"].as_bool().unwrap_or(false); + if valid { + println!("Valid DSL"); + println!(" Nodes: {}", result["node_count"].as_i64().unwrap_or(0)); + println!(" Edges: {}", result["edge_count"].as_i64().unwrap_or(0)); + } else { + println!("Invalid DSL"); + if let Some(errors) = result["errors"].as_array() { + for err in errors { + println!(" - {}", err.as_str().unwrap_or("?")); + } + } + } + } + Ok(()) + } + + WorkflowCommands::Delete { slug, force } => { + if !force { + eprint!("Delete workflow '{}'? [y/N] ", slug); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if !input.trim().eq_ignore_ascii_case("y") { + println!("Cancelled"); + return Ok(()); + } + } + + workflows_api::delete_workflow_api_v1_workflows_slug_delete(config, &slug) + .await + .context("Failed to delete workflow")?; + + println!("Deleted workflow: {}", slug); + Ok(()) + } + } +} + +async fn run_node_types(config: &Configuration, json_output: bool) -> Result<()> { + let result = workflows_api::list_node_types_api_v1_workflows_node_types_get(config) + .await + .context("Failed to list node types")?; + + if json_output { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + if let Some(obj) = result.as_object() { + println!("{:<25} {:<15} DESCRIPTION", "TYPE", "CATEGORY"); + println!("{}", "-".repeat(80)); + for (type_name, spec) in obj { + println!( + "{:<25} {:<15} {}", + type_name, + spec["category"].as_str().unwrap_or("-"), + truncate(spec["description"].as_str().unwrap_or("-"), 40), + ); + } + println!("\nTotal: {} node types", obj.len()); + } + } + Ok(()) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}...", &s[..max - 3]) + } +} diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs new file mode 100644 index 0000000..aed35c2 --- /dev/null +++ b/src/commands/init/agentgateway.rs @@ -0,0 +1,422 @@ +//! agentgateway bootstrap for `plit init`. +//! +//! Creates the agentgateway config directory structure, generates an ES256 +//! keypair, writes config fragments, and shell scripts. The agentgateway +//! binary itself is NOT downloaded here — it is bundled in the Docker image +//! or downloaded separately for bare-metal installs. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use tokio::process::Command; + +use super::config; +use super::prompts::UserInputs; +use crate::output; + +/// Result of agentgateway bootstrap — returned to caller for .env writing. +pub struct AgentgatewaySetup { + /// Absolute path to the agentgateway directory + pub agw_dir: PathBuf, + /// PEM-encoded ES256 private key (for JWT signing) + pub jwt_private_key: String, +} + +/// Top-level directory for agentgateway config. +pub fn agentgateway_dir() -> Result { + let base = dirs::config_dir().context("Could not determine config directory")?; + Ok(base.join("agentgateway")) +} + +/// Bootstrap agentgateway config structure + ES256 keypair. +pub async fn bootstrap(inputs: &UserInputs) -> Result { + let agw_dir = agentgateway_dir()?; + + // Create directory structure + let dirs_to_create = [ + "bin", + "config.d/jwt", + "config.d/listeners", + "config.d/backends", + "config.d/rules", + "config.d/mcp_servers", + "keys", + ]; + for d in &dirs_to_create { + let path = agw_dir.join(d); + std::fs::create_dir_all(&path) + .with_context(|| format!("Failed to create directory: {}", path.display()))?; + } + output::status(" * Created directory structure"); + + // Generate ES256 keypair via Python (cryptography is in the venv) + let (private_pem, jwks_json) = generate_es256_keypair().await?; + output::status(" * Generated ES256 keypair"); + + // Write JWKS public key + let jwks_path = agw_dir.join("config.d/jwt/jwks.json"); + std::fs::write(&jwks_path, &jwks_json) + .with_context(|| format!("Failed to write {}", jwks_path.display()))?; + + // Write config fragments + write_base_yaml(&agw_dir)?; + write_llm_listener_yaml(&agw_dir)?; + write_mcp_listener_yaml(&agw_dir)?; + write_rules(&agw_dir)?; + output::status(" * Wrote config fragments"); + + // Write shell scripts + write_assemble_config_sh(&agw_dir)?; + write_start_sh(&agw_dir)?; + write_decrypt_keys_py(&agw_dir)?; + output::status(" * Wrote shell scripts"); + + // Write initial provider + model from LLM choice (if applicable) + write_initial_provider(inputs, &agw_dir)?; + + Ok(AgentgatewaySetup { + agw_dir, + jwt_private_key: private_pem, + }) +} + +/// Generate an ES256 (P-256) keypair using Python's cryptography library. +/// Returns (private_key_pem, jwks_json). +async fn generate_es256_keypair() -> Result<(String, String)> { + let venv_dir = config::venv_dir()?; + let python = venv_dir.join("bin").join("python"); + + let script = r#" +import json +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend +import base64 + +# Generate P-256 key +private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) +public_key = private_key.public_key() + +# PEM private key +private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() +).decode() + +# JWKS public key +public_numbers = public_key.public_numbers() +x_bytes = public_numbers.x.to_bytes(32, 'big') +y_bytes = public_numbers.y.to_bytes(32, 'big') + +def b64url(b): + return base64.urlsafe_b64encode(b).rstrip(b'=').decode() + +jwks = { + "keys": [{ + "kty": "EC", + "crv": "P-256", + "x": b64url(x_bytes), + "y": b64url(y_bytes), + "use": "sig", + "alg": "ES256", + "kid": "pipelit-1" + }] +} + +print(json.dumps({"private_pem": private_pem, "jwks": jwks})) +"#; + + let output = Command::new(&python) + .args(["-c", script]) + .output() + .await + .context("Failed to run Python for ES256 keygen")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("ES256 keygen failed: {}", stderr); + } + + let stdout = String::from_utf8(output.stdout).context("Invalid UTF-8 from keygen")?; + let parsed: serde_json::Value = + serde_json::from_str(&stdout).context("Failed to parse keygen output")?; + + let private_pem = parsed["private_pem"] + .as_str() + .context("Missing private_pem")? + .to_string(); + let jwks = serde_json::to_string_pretty(&parsed["jwks"]).context("Failed to format JWKS")?; + + Ok((private_pem, jwks)) +} + +// --- Config fragment writers --- + +fn write_base_yaml(agw_dir: &Path) -> Result<()> { + let content = "\ +config: + adminAddr: \"0.0.0.0:15000\" +"; + let path = agw_dir.join("config.d/base.yaml"); + std::fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display())) +} + +fn write_llm_listener_yaml(agw_dir: &Path) -> Result<()> { + // The JWKS path must be absolute. In Docker it will be under /root/.config/agentgateway/ + // For bare-metal it will be under ~/.config/agentgateway/ + let jwks_path = agw_dir.join("config.d/jwt/jwks.json"); + let content = format!( + "\ +listeners: + - name: llm + protocol: LLM + address: \"0.0.0.0:4000\" + routes: [] + authentication: + jwt: + localJwks: + filename: \"{jwks_path}\" +", + jwks_path = jwks_path.display() + ); + let path = agw_dir.join("config.d/listeners/llm.yaml"); + std::fs::write(&path, &content).with_context(|| format!("Failed to write {}", path.display())) +} + +fn write_mcp_listener_yaml(agw_dir: &Path) -> Result<()> { + let content = "\ +listeners: + - name: mcp + protocol: MCP + address: \"0.0.0.0:3000\" + routes: + - name: mcp-route + matches: + - path: + pathPrefix: / + backends: [] +"; + let path = agw_dir.join("config.d/listeners/mcp.yaml"); + std::fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display())) +} + +fn write_rules(agw_dir: &Path) -> Result<()> { + let admin = "- 'jwt.role == \"admin\"'\n"; + let user = "- 'jwt.role == \"user\"'\n"; + + let admin_path = agw_dir.join("config.d/rules/admin.yaml"); + std::fs::write(&admin_path, admin) + .with_context(|| format!("Failed to write {}", admin_path.display()))?; + + let user_path = agw_dir.join("config.d/rules/user.yaml"); + std::fs::write(&user_path, user) + .with_context(|| format!("Failed to write {}", user_path.display())) +} + +// --- Shell script writers --- + +fn write_assemble_config_sh(agw_dir: &Path) -> Result<()> { + let content = include_str!("agentgateway_scripts/assemble-config.sh"); + let path = agw_dir.join("assemble-config.sh"); + std::fs::write(&path, content) + .with_context(|| format!("Failed to write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?; + } + Ok(()) +} + +fn write_start_sh(agw_dir: &Path) -> Result<()> { + let content = include_str!("agentgateway_scripts/start.sh"); + let path = agw_dir.join("start.sh"); + std::fs::write(&path, content) + .with_context(|| format!("Failed to write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?; + } + Ok(()) +} + +fn write_decrypt_keys_py(agw_dir: &Path) -> Result<()> { + let content = include_str!("agentgateway_scripts/decrypt_keys.py"); + let path = agw_dir.join("decrypt_keys.py"); + std::fs::write(&path, content) + .with_context(|| format!("Failed to write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?; + } + Ok(()) +} + +// --- Initial provider/model from LLM choice --- + +fn write_initial_provider(inputs: &UserInputs, agw_dir: &Path) -> Result<()> { + // Map LLM provider choice to agentgateway provider config + let (provider_name, provider_type, host, path_override) = match inputs.llm_provider.as_str() { + "openai" => ("openai", "openAI", "https://api.openai.com", "/v1/"), + "anthropic" => ( + "anthropic", + "anthropic", + "https://api.anthropic.com", + "/v1/", + ), + "gemini" => ( + "gemini", + "gemini", + "https://generativelanguage.googleapis.com", + "/", + ), + "ollama" => { + let base = if inputs.llm_base_url.is_empty() { + "http://localhost:11434" + } else { + &inputs.llm_base_url + }; + // Ollama doesn't use API keys, skip encrypted key + write_provider_yaml(agw_dir, "ollama", "openAI", base, "/v1/")?; + write_model_yaml(agw_dir, "ollama", &inputs.llm_model)?; + output::status(&format!( + " * Created initial provider: ollama/{}", + model_slug(&inputs.llm_model) + )); + return Ok(()); + } + "openai-compatible" => { + if inputs.llm_base_url.is_empty() { + bail!("Base URL is required for openai-compatible providers"); + } + // Derive a provider name from the base URL hostname + let name = provider_name_from_url(&inputs.llm_base_url); + write_provider_yaml(agw_dir, &name, "openAI", &inputs.llm_base_url, "/v1/")?; + write_model_yaml(agw_dir, &name, &inputs.llm_model)?; + write_encrypted_key(agw_dir, &name, &inputs.llm_api_key)?; + output::status(&format!( + " * Created initial provider: {}/{}", + name, + model_slug(&inputs.llm_model) + )); + return Ok(()); + } + _ => return Ok(()), // Unknown provider, skip + }; + + write_provider_yaml(agw_dir, provider_name, provider_type, host, path_override)?; + write_model_yaml(agw_dir, provider_name, &inputs.llm_model)?; + if !inputs.llm_api_key.is_empty() { + write_encrypted_key(agw_dir, provider_name, &inputs.llm_api_key)?; + } + output::status(&format!( + " * Created initial provider: {}/{}", + provider_name, + model_slug(&inputs.llm_model) + )); + + Ok(()) +} + +fn write_provider_yaml( + agw_dir: &Path, + name: &str, + provider_type: &str, + host: &str, + path_override: &str, +) -> Result<()> { + let provider_dir = agw_dir.join("config.d/backends").join(name); + std::fs::create_dir_all(&provider_dir)?; + + // Build env var name from provider name + let env_var = format!("{}_API_KEY", name.to_uppercase().replace('-', "_")); + + let content = format!( + "\ +provider: + {provider_type}: + model: \"\" +hostOverride: \"{host}\" +pathOverride: \"{path_override}\" +backendAuth: + apiKey: + envKey: \"{env_var}\" +backendTLS: + sni: \"{sni}\" +", + provider_type = provider_type, + host = host, + path_override = path_override, + env_var = env_var, + sni = extract_host(host), + ); + + let path = provider_dir.join("_provider.yaml"); + std::fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display())) +} + +fn write_model_yaml(agw_dir: &Path, provider_name: &str, model_name: &str) -> Result<()> { + let slug = model_slug(model_name); + let provider_dir = agw_dir.join("config.d/backends").join(provider_name); + std::fs::create_dir_all(&provider_dir)?; + + let content = format!("model: \"{}\"\n", model_name); + let path = provider_dir.join(format!("{}.yaml", slug)); + std::fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display())) +} + +fn write_encrypted_key(agw_dir: &Path, provider_name: &str, api_key: &str) -> Result<()> { + if api_key.is_empty() { + return Ok(()); + } + // Write the key as plaintext for now — Pipelit's provider API will + // encrypt it with Fernet when providers are managed via the UI. + // For init bootstrap, plaintext is fine since start.sh handles both. + let path = agw_dir.join("keys").join(format!("{}.key", provider_name)); + std::fs::write(&path, api_key) + .with_context(|| format!("Failed to write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +/// Derive a model slug from a model name (e.g., "zai-org-glm-4.7" -> "glm-4.7"). +/// Keeps the full name but replaces characters unsafe for filenames. +fn model_slug(model_name: &str) -> String { + model_name.replace(['/', ':', ' '], "-") +} + +/// Derive a provider name from a base URL (e.g., "https://api.venice.ai/api/v1" -> "venice"). +fn provider_name_from_url(url: &str) -> String { + url.trim_end_matches('/') + .split("://") + .nth(1) + .unwrap_or(url) + .split('/') + .next() + .unwrap_or("custom") + .split('.') + .rev() + .nth(1) // second-level domain + .unwrap_or("custom") + .to_lowercase() + .replace('-', "_") +} + +/// Extract hostname from a URL for SNI. +fn extract_host(url: &str) -> String { + url.trim_end_matches('/') + .split("://") + .nth(1) + .unwrap_or(url) + .split('/') + .next() + .unwrap_or(url) + .to_string() +} diff --git a/src/commands/init/agentgateway_scripts/assemble-config.sh b/src/commands/init/agentgateway_scripts/assemble-config.sh new file mode 100755 index 0000000..dcc3811 --- /dev/null +++ b/src/commands/init/agentgateway_scripts/assemble-config.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Assemble config.yaml from config.d/ domain folders +# +# Structure: +# config.d/base.yaml → top-level config (admin, global) +# config.d/listeners/*.yaml → one file per listener (each is a bind) +# config.d/backends// → one subfolder per LLM provider +# _provider.yaml → shared config (host, path, auth, TLS) +# .yaml → one file per model (just "model: ") +# config.d/rules/*.yaml → CEL authorization rules (merged into backends) +# config.d/mcp_servers/*.yaml → MCP server targets (injected into MCP listener) +# config.d/jwt/jwks.json → JWT public key (referenced by llm listener) +# +# Only providers with matching keys/.key are included. +# Output: config.yaml (atomic write via tmp + rename) + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG_D="$SCRIPT_DIR/config.d" +KEYS_DIR="$SCRIPT_DIR/keys" +OUTPUT="$SCRIPT_DIR/config.yaml" +YQ="${YQ:-yq}" + +# --- Step 1: Base config --- +merged=$($YQ eval '.' "$CONFIG_D/base.yaml") + +# --- Step 2: Load authorization rules --- +# Collect all rules/*.yaml into a single array +rules="[]" +for f in "$CONFIG_D"/rules/*.yaml; do + [ -f "$f" ] || continue + rules=$(echo "$rules" | $YQ eval-all ' + select(fileIndex == 0) + select(fileIndex == 1) + ' - "$f") +done + +# --- Step 3: Add listeners as binds --- +for f in "$CONFIG_D"/listeners/*.yaml; do + [ -f "$f" ] || continue + merged=$(echo "$merged" | $YQ eval-all ' + select(fileIndex == 0).binds += [select(fileIndex == 1)] + | select(fileIndex == 0) + ' - "$f") +done + +# --- Step 4: Inject backends into LLM listener routes --- +# Structure: config.d/backends//_provider.yaml + .yaml +# Each model file becomes a separate route, inheriting from _provider.yaml +# Only included if keys/.key exists +included=0 +skipped=0 +for provider_dir in "$CONFIG_D"/backends/*/; do + [ -d "$provider_dir" ] || continue + provider="$(basename "$provider_dir")" + provider_file="$provider_dir/_provider.yaml" + + # Skip if no _provider.yaml + if [ ! -f "$provider_file" ]; then + echo " ! $provider: missing _provider.yaml, skipped" + continue + fi + + # Skip if no matching key file + if [ ! -d "$KEYS_DIR" ] || [ ! -f "$KEYS_DIR/${provider}.key" ]; then + echo " - $provider (no key, skipped)" + skipped=$((skipped + 1)) + continue + fi + + # Read provider config + provider_config=$($YQ eval '.' "$provider_file") + + # Process each model file (skip _provider.yaml) + for model_file in "$provider_dir"/*.yaml; do + [ -f "$model_file" ] || continue + [ "$(basename "$model_file")" = "_provider.yaml" ] && continue + + model_slug="$(basename "$model_file" .yaml)" + model_name=$($YQ eval '.model' "$model_file") + route_name="${provider}-${model_slug}" + + # Build the full route by merging provider + model + route=$($YQ eval -n "{ + \"name\": \"${route_name}-route\", + \"matches\": [{\"path\": {\"pathPrefix\": \"/${route_name}/\"}}], + \"policies\": { + \"authorization\": {\"rules\": []}, + \"backendAuth\": $(echo "$provider_config" | $YQ eval '.backendAuth' -o=json -), + \"backendTLS\": $(echo "$provider_config" | $YQ eval '.backendTLS // {}' -o=json -) + }, + \"backends\": [{ + \"ai\": { + \"provider\": $(echo "$provider_config" | $YQ eval '.provider' -o=json -), + \"name\": \"${route_name}\" + $(echo "$provider_config" | $YQ eval 'select(.hostOverride) | ", \"hostOverride\": \"" + .hostOverride + "\""' -) + $(echo "$provider_config" | $YQ eval 'select(.pathOverride) | ", \"pathOverride\": \"" + .pathOverride + "\""' -) + } + }] + }") + + # Set the model on the provider + route=$(echo "$route" | $YQ eval ".backends[0].ai.provider[][\"model\"] = \"${model_name}\"" -) + + # Inject authorization rules + route=$(echo "$route" | $YQ eval-all ' + select(fileIndex == 0).policies.authorization.rules = select(fileIndex == 1) + | select(fileIndex == 0) + ' - <(echo "$rules")) + + # Append to LLM listener routes + merged=$(echo "$merged" | $YQ eval-all ' + (select(fileIndex == 0).binds[] | select(.listeners[].name == "llm")).listeners[0].routes += [select(fileIndex == 1)] + | select(fileIndex == 0) + ' - <(echo "$route")) + + echo " + ${provider}/${model_slug} (model: ${model_name})" + included=$((included + 1)) + done +done + +# --- Step 5: Inject MCP server targets into MCP listener --- +mcp_count=0 +for f in "$CONFIG_D"/mcp_servers/*.yaml; do + [ -f "$f" ] || continue + mcp_name="$(basename "$f" .yaml)" + + merged=$(echo "$merged" | $YQ eval-all ' + (select(fileIndex == 0).binds[] | select(.listeners[].name == "mcp")).listeners[0].routes[0].backends += [select(fileIndex == 1)] + | select(fileIndex == 0) + ' - "$f") + + echo " + mcp: $mcp_name" + mcp_count=$((mcp_count + 1)) +done + +# --- Step 6: Atomic write --- +echo "$merged" > "${OUTPUT}.tmp" +mv "${OUTPUT}.tmp" "$OUTPUT" +echo "Assembled config.yaml ($included backends, $skipped skipped, $mcp_count mcp servers)" diff --git a/src/commands/init/agentgateway_scripts/decrypt_keys.py b/src/commands/init/agentgateway_scripts/decrypt_keys.py new file mode 100755 index 0000000..634a718 --- /dev/null +++ b/src/commands/init/agentgateway_scripts/decrypt_keys.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Decrypt Fernet-encrypted .key files and print NAME=VALUE pairs. + +Usage: FIELD_ENCRYPTION_KEY=xxx python3 decrypt_keys.py /path/to/keys/ + +Called by start.sh before launching agentgateway. +Outputs lines like: VENICE_API_KEY=sk-actual-key +These are eval'd by start.sh to export as env vars. + +If FIELD_ENCRYPTION_KEY is not set, reads key files as plaintext +(backwards compatible with pre-encryption key files). +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from cryptography.fernet import Fernet + + +def _name_to_env_var(stem: str) -> str: + """Convert a key file stem to env var name. + + Examples: + venice -> VENICE_API_KEY + openai -> OPENAI_API_KEY + my-model -> MY_MODEL_API_KEY + """ + return stem.upper().replace("-", "_").replace(".", "_") + "_API_KEY" + + +def main() -> None: + keys_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("keys") + + if not keys_dir.is_dir(): + return + + enc_key = os.environ.get("FIELD_ENCRYPTION_KEY", "") + + if not enc_key: + print( + "Warning: FIELD_ENCRYPTION_KEY not set, reading keys as plaintext", + file=sys.stderr, + ) + for kf in sorted(keys_dir.glob("*.key")): + name = _name_to_env_var(kf.stem) + print(f"export {name}={kf.read_text().strip()}") + return + + fernet = Fernet(enc_key.encode()) + for kf in sorted(keys_dir.glob("*.key")): + name = _name_to_env_var(kf.stem) + try: + decrypted = fernet.decrypt(kf.read_bytes()).decode() + print(f"export {name}={decrypted}") + except Exception as e: + print(f"Warning: Failed to decrypt {kf.name}: {e}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/src/commands/init/agentgateway_scripts/start.sh b/src/commands/init/agentgateway_scripts/start.sh new file mode 100755 index 0000000..e498d52 --- /dev/null +++ b/src/commands/init/agentgateway_scripts/start.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Start agentgateway: +# 1. Decrypt + load API keys from keys/ folder +# 2. Assemble config.yaml from config.d/ fragments +# 3. Start agentgateway +# +# Key file convention: +# keys/venice.key → exports VENICE_API_KEY= +# keys/anthropic.key → exports ANTHROPIC_API_KEY= +# +# Config fragment convention: +# config.d/00-base.yaml → top-level config (admin, etc.) +# config.d/01-mcp.yaml → MCP listener (full bind) +# config.d/02-llm-base.yaml → LLM listener skeleton (JWT, empty routes) +# config.d/10-*.yaml → LLM route fragments (one per backend) + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KEYS_DIR="$SCRIPT_DIR/keys" +YQ="${YQ:-yq}" + +# Find Python with cryptography (prefer Pipelit venv, fall back to system) +if [ -z "${PYTHON:-}" ]; then + for candidate in \ + "${PIPELIT_DIR:+$PIPELIT_DIR/.venv/bin/python3}" \ + "$HOME/.local/share/plit/venv/bin/python3" \ + "python3"; do + [ -n "$candidate" ] && [ -x "$candidate" ] && PYTHON="$candidate" && break + done +fi +PYTHON="${PYTHON:-python3}" + +# --- Step 1: Decrypt and load API keys --- +if [ -d "$KEYS_DIR" ] && ls "$KEYS_DIR"/*.key 1>/dev/null 2>&1; then + eval "$("${PYTHON:-python3}" "$SCRIPT_DIR/decrypt_keys.py" "$KEYS_DIR")" + echo "Loaded keys from $KEYS_DIR" +fi + +# --- Step 2: Assemble config from fragments --- +"$SCRIPT_DIR/assemble-config.sh" + +# --- Step 3: Start agentgateway --- +exec "$SCRIPT_DIR/bin/agentgateway" -f "$SCRIPT_DIR/config.yaml" "$@" diff --git a/src/commands/init/config.rs b/src/commands/init/config.rs index 566c35a..56bc407 100644 --- a/src/commands/init/config.rs +++ b/src/commands/init/config.rs @@ -223,6 +223,38 @@ pub fn write_gateway_config( Ok(()) } +/// Append agentgateway env vars to the existing .env file. +pub fn append_agentgateway_env(agw: &super::agentgateway::AgentgatewaySetup) -> Result<()> { + let path = dot_env_path()?; + + // Escape the PEM key for .env — replace newlines with literal \n + let escaped_key = agw.jwt_private_key.trim().replace('\n', "\\n"); + + let content = format!( + "\n\ +# agentgateway integration +AGENTGATEWAY_ENABLED=true +AGENTGATEWAY_URL=http://localhost:4000 +AGENTGATEWAY_DIR={agw_dir} +JWT_PRIVATE_KEY=\"{jwt_key}\" +", + agw_dir = agw.agw_dir.display(), + jwt_key = escaped_key, + ); + + let mut existing = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + existing.push_str(&content); + std::fs::write(&path, &existing) + .with_context(|| format!("Failed to write {}", path.display()))?; + + output::status(&format!( + " * Appended agentgateway config to {}", + path.display() + )); + Ok(()) +} + pub fn write_managed_markers() -> Result<()> { let marker = ".plit-managed"; diff --git a/src/commands/init/mod.rs b/src/commands/init/mod.rs index 90e25cd..b6391a7 100644 --- a/src/commands/init/mod.rs +++ b/src/commands/init/mod.rs @@ -1,3 +1,4 @@ +mod agentgateway; pub(crate) mod config; mod docker; mod install; @@ -133,7 +134,13 @@ pub async fn run(args: InitArgs) -> Result<()> { // 19b. Drop management markers so uninstall can detect plit-managed dirs config::write_managed_markers()?; - // 20. Done + // 20. Bootstrap agentgateway config structure + ES256 keypair + output::status("Setting up agentgateway..."); + let agw_setup = agentgateway::bootstrap(&inputs).await?; + config::append_agentgateway_env(&agw_setup)?; + output::status(""); + + // 21. Done let config_path = config::config_json_path()?; let env_display = config::dot_env_path()?; let pipelit_display = config::pipelit_dir()?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ddf7f09..b3e9643 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod api; pub mod auth; pub mod chat; pub mod credentials; diff --git a/src/main.rs b/src/main.rs index f930be6..2ad7e06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use clap::{Parser, Subcommand}; +use commands::api::ApiCommands; use commands::auth::AuthCommands; mod client; @@ -39,6 +40,10 @@ struct Cli { #[derive(Subcommand)] enum Commands { + /// Pipelit API operations (workflows, nodes, etc.) + #[command(subcommand)] + Api(ApiCommands), + /// Local gateway commands — chat, send, and listen via the generic adapter #[command(subcommand)] Local(LocalCommands), @@ -248,6 +253,8 @@ async fn main() -> anyhow::Result<()> { }; match cli.command { + Commands::Api(cmd) => commands::api::run(cmd, json_output).await, + Commands::Local(local_cmd) => match local_cmd { LocalCommands::Chat { credential_id, From 2a608e8b6693fd43dfeb13a588cf41b41d9d9428 Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 09:16:44 +1030 Subject: [PATCH 02/18] fix: use HTTP protocol for agentgateway listeners, fix hostOverride agentgateway v1.0.1 uses HTTP protocol for all listeners (not LLM/MCP). Also fix hostOverride to contain just the hostname, not the full URL. Extract path from base_url for openai-compatible providers. Co-Authored-By: Claude Opus 4.6 --- .omc/state/hud-state.json | 6 ++++++ .omc/state/hud-stdin-cache.json | 1 + src/commands/init/agentgateway.rs | 28 +++++++++++++++++++++------- 3 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 .omc/state/hud-state.json create mode 100644 .omc/state/hud-stdin-cache.json diff --git a/.omc/state/hud-state.json b/.omc/state/hud-state.json new file mode 100644 index 0000000..8c308ce --- /dev/null +++ b/.omc/state/hud-state.json @@ -0,0 +1,6 @@ +{ + "timestamp": "2026-03-19T23:49:52.064Z", + "backgroundTasks": [], + "sessionStartTimestamp": "2026-03-19T23:24:09.425Z", + "sessionId": "c28ba0e5-d3a0-4572-9165-fecdc67660dc" +} \ No newline at end of file diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json new file mode 100644 index 0000000..7bebe9f --- /dev/null +++ b/.omc/state/hud-stdin-cache.json @@ -0,0 +1 @@ +{"session_id":"c28ba0e5-d3a0-4572-9165-fecdc67660dc","transcript_path":"/home/aka/.claude/projects/-home-aka-programs/c28ba0e5-d3a0-4572-9165-fecdc67660dc.jsonl","cwd":"/home/aka/programs/plit","model":{"id":"claude-opus-4-6[1m]","display_name":"Opus 4.6 (1M context)"},"workspace":{"current_dir":"/home/aka/programs/plit","project_dir":"/home/aka/programs","added_dirs":[]},"version":"2.1.80","output_style":{"name":"default"},"cost":{"total_cost_usd":19.494767600000003,"total_duration_ms":19302648,"total_api_duration_ms":2314676,"total_lines_added":358,"total_lines_removed":28},"context_window":{"total_input_tokens":717,"total_output_tokens":75175,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":114,"cache_creation_input_tokens":305,"cache_read_input_tokens":210614},"used_percentage":21,"remaining_percentage":79},"exceeds_200k_tokens":true,"rate_limits":{"five_hour":{"used_percentage":1,"resets_at":1773986400},"seven_day":{"used_percentage":4,"resets_at":1774472400}}} \ No newline at end of file diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs index aed35c2..bf35e7d 100644 --- a/src/commands/init/agentgateway.rs +++ b/src/commands/init/agentgateway.rs @@ -170,7 +170,7 @@ fn write_llm_listener_yaml(agw_dir: &Path) -> Result<()> { "\ listeners: - name: llm - protocol: LLM + protocol: HTTP address: \"0.0.0.0:4000\" routes: [] authentication: @@ -188,7 +188,7 @@ fn write_mcp_listener_yaml(agw_dir: &Path) -> Result<()> { let content = "\ listeners: - name: mcp - protocol: MCP + protocol: HTTP address: \"0.0.0.0:3000\" routes: - name: mcp-route @@ -294,7 +294,8 @@ fn write_initial_provider(inputs: &UserInputs, agw_dir: &Path) -> Result<()> { } // Derive a provider name from the base URL hostname let name = provider_name_from_url(&inputs.llm_base_url); - write_provider_yaml(agw_dir, &name, "openAI", &inputs.llm_base_url, "/v1/")?; + let path = extract_path(&inputs.llm_base_url); + write_provider_yaml(agw_dir, &name, "openAI", &inputs.llm_base_url, &path)?; write_model_yaml(agw_dir, &name, &inputs.llm_model)?; write_encrypted_key(agw_dir, &name, &inputs.llm_api_key)?; output::status(&format!( @@ -334,24 +335,26 @@ fn write_provider_yaml( // Build env var name from provider name let env_var = format!("{}_API_KEY", name.to_uppercase().replace('-', "_")); + // Extract just the hostname for hostOverride and SNI + let hostname = extract_host(host); + let content = format!( "\ provider: {provider_type}: model: \"\" -hostOverride: \"{host}\" +hostOverride: \"{hostname}\" pathOverride: \"{path_override}\" backendAuth: apiKey: envKey: \"{env_var}\" backendTLS: - sni: \"{sni}\" + sni: \"{hostname}\" ", provider_type = provider_type, - host = host, + hostname = hostname, path_override = path_override, env_var = env_var, - sni = extract_host(host), ); let path = provider_dir.join("_provider.yaml"); @@ -420,3 +423,14 @@ fn extract_host(url: &str) -> String { .unwrap_or(url) .to_string() } + +/// Extract path from a URL (e.g., "https://api.venice.ai/api/v1" -> "/api/v1/"). +fn extract_path(url: &str) -> String { + let after_scheme = url.split("://").nth(1).unwrap_or(url); + let path = after_scheme + .find('/') + .map(|i| &after_scheme[i..]) + .unwrap_or("/"); + let path = path.trim_end_matches('/'); + format!("{}/", path) +} From dd2841ed215f73f227f0537cac2220592a967d45 Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 09:16:56 +1030 Subject: [PATCH 03/18] chore: remove accidentally committed .env and .omc files Co-Authored-By: Claude Opus 4.6 --- .omc/state/hud-state.json | 6 ------ .omc/state/hud-stdin-cache.json | 1 - 2 files changed, 7 deletions(-) delete mode 100644 .omc/state/hud-state.json delete mode 100644 .omc/state/hud-stdin-cache.json diff --git a/.omc/state/hud-state.json b/.omc/state/hud-state.json deleted file mode 100644 index 8c308ce..0000000 --- a/.omc/state/hud-state.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "timestamp": "2026-03-19T23:49:52.064Z", - "backgroundTasks": [], - "sessionStartTimestamp": "2026-03-19T23:24:09.425Z", - "sessionId": "c28ba0e5-d3a0-4572-9165-fecdc67660dc" -} \ No newline at end of file diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json deleted file mode 100644 index 7bebe9f..0000000 --- a/.omc/state/hud-stdin-cache.json +++ /dev/null @@ -1 +0,0 @@ -{"session_id":"c28ba0e5-d3a0-4572-9165-fecdc67660dc","transcript_path":"/home/aka/.claude/projects/-home-aka-programs/c28ba0e5-d3a0-4572-9165-fecdc67660dc.jsonl","cwd":"/home/aka/programs/plit","model":{"id":"claude-opus-4-6[1m]","display_name":"Opus 4.6 (1M context)"},"workspace":{"current_dir":"/home/aka/programs/plit","project_dir":"/home/aka/programs","added_dirs":[]},"version":"2.1.80","output_style":{"name":"default"},"cost":{"total_cost_usd":19.494767600000003,"total_duration_ms":19302648,"total_api_duration_ms":2314676,"total_lines_added":358,"total_lines_removed":28},"context_window":{"total_input_tokens":717,"total_output_tokens":75175,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":114,"cache_creation_input_tokens":305,"cache_read_input_tokens":210614},"used_percentage":21,"remaining_percentage":79},"exceeds_200k_tokens":true,"rate_limits":{"five_hour":{"used_percentage":1,"resets_at":1773986400},"seven_day":{"used_percentage":4,"resets_at":1774472400}}} \ No newline at end of file From 24fb01bf9f2f0866cf7cfb2029963272e3f58951 Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 09:25:39 +1030 Subject: [PATCH 04/18] fix: use 'key' instead of 'apiKey' for backendAuth agentgateway v1.0.1 expects 'key' not 'apiKey' for backend auth config. Co-Authored-By: Claude Opus 4.6 --- .omc/state/hud-state.json | 6 ++++++ .omc/state/hud-stdin-cache.json | 1 + src/commands/init/agentgateway.rs | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .omc/state/hud-state.json create mode 100644 .omc/state/hud-stdin-cache.json diff --git a/.omc/state/hud-state.json b/.omc/state/hud-state.json new file mode 100644 index 0000000..8c308ce --- /dev/null +++ b/.omc/state/hud-state.json @@ -0,0 +1,6 @@ +{ + "timestamp": "2026-03-19T23:49:52.064Z", + "backgroundTasks": [], + "sessionStartTimestamp": "2026-03-19T23:24:09.425Z", + "sessionId": "c28ba0e5-d3a0-4572-9165-fecdc67660dc" +} \ No newline at end of file diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json new file mode 100644 index 0000000..7bebe9f --- /dev/null +++ b/.omc/state/hud-stdin-cache.json @@ -0,0 +1 @@ +{"session_id":"c28ba0e5-d3a0-4572-9165-fecdc67660dc","transcript_path":"/home/aka/.claude/projects/-home-aka-programs/c28ba0e5-d3a0-4572-9165-fecdc67660dc.jsonl","cwd":"/home/aka/programs/plit","model":{"id":"claude-opus-4-6[1m]","display_name":"Opus 4.6 (1M context)"},"workspace":{"current_dir":"/home/aka/programs/plit","project_dir":"/home/aka/programs","added_dirs":[]},"version":"2.1.80","output_style":{"name":"default"},"cost":{"total_cost_usd":19.494767600000003,"total_duration_ms":19302648,"total_api_duration_ms":2314676,"total_lines_added":358,"total_lines_removed":28},"context_window":{"total_input_tokens":717,"total_output_tokens":75175,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":114,"cache_creation_input_tokens":305,"cache_read_input_tokens":210614},"used_percentage":21,"remaining_percentage":79},"exceeds_200k_tokens":true,"rate_limits":{"five_hour":{"used_percentage":1,"resets_at":1773986400},"seven_day":{"used_percentage":4,"resets_at":1774472400}}} \ No newline at end of file diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs index bf35e7d..5f6a96f 100644 --- a/src/commands/init/agentgateway.rs +++ b/src/commands/init/agentgateway.rs @@ -346,7 +346,7 @@ provider: hostOverride: \"{hostname}\" pathOverride: \"{path_override}\" backendAuth: - apiKey: + key: envKey: \"{env_var}\" backendTLS: sni: \"{hostname}\" From 09c03893f3bd46b0a08b7fd1e2e2840c3dc19c2c Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 09:25:51 +1030 Subject: [PATCH 05/18] chore: gitignore .env and .omc Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ .omc/state/hud-state.json | 6 ------ .omc/state/hud-stdin-cache.json | 1 - 3 files changed, 3 insertions(+), 7 deletions(-) delete mode 100644 .omc/state/hud-state.json delete mode 100644 .omc/state/hud-stdin-cache.json diff --git a/.gitignore b/.gitignore index bb9e3a3..4e5ff1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /target pipelit-client/target .sisyphus/ + +.env +.omc/ diff --git a/.omc/state/hud-state.json b/.omc/state/hud-state.json deleted file mode 100644 index 8c308ce..0000000 --- a/.omc/state/hud-state.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "timestamp": "2026-03-19T23:49:52.064Z", - "backgroundTasks": [], - "sessionStartTimestamp": "2026-03-19T23:24:09.425Z", - "sessionId": "c28ba0e5-d3a0-4572-9165-fecdc67660dc" -} \ No newline at end of file diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json deleted file mode 100644 index 7bebe9f..0000000 --- a/.omc/state/hud-stdin-cache.json +++ /dev/null @@ -1 +0,0 @@ -{"session_id":"c28ba0e5-d3a0-4572-9165-fecdc67660dc","transcript_path":"/home/aka/.claude/projects/-home-aka-programs/c28ba0e5-d3a0-4572-9165-fecdc67660dc.jsonl","cwd":"/home/aka/programs/plit","model":{"id":"claude-opus-4-6[1m]","display_name":"Opus 4.6 (1M context)"},"workspace":{"current_dir":"/home/aka/programs/plit","project_dir":"/home/aka/programs","added_dirs":[]},"version":"2.1.80","output_style":{"name":"default"},"cost":{"total_cost_usd":19.494767600000003,"total_duration_ms":19302648,"total_api_duration_ms":2314676,"total_lines_added":358,"total_lines_removed":28},"context_window":{"total_input_tokens":717,"total_output_tokens":75175,"context_window_size":1000000,"current_usage":{"input_tokens":1,"output_tokens":114,"cache_creation_input_tokens":305,"cache_read_input_tokens":210614},"used_percentage":21,"remaining_percentage":79},"exceeds_200k_tokens":true,"rate_limits":{"five_hour":{"used_percentage":1,"resets_at":1773986400},"seven_day":{"used_percentage":4,"resets_at":1774472400}}} \ No newline at end of file From 613b3060b6228b60d6121a7889ca05b53db4758b Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 09:34:37 +1030 Subject: [PATCH 06/18] fix: use 'env' key format for backendAuth in agentgateway config agentgateway expects key: { env: "VAR_NAME" } not key: { envKey: "VAR_NAME" }. Co-Authored-By: Claude Opus 4.6 --- src/commands/init/agentgateway.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs index 5f6a96f..4043f88 100644 --- a/src/commands/init/agentgateway.rs +++ b/src/commands/init/agentgateway.rs @@ -347,7 +347,7 @@ hostOverride: \"{hostname}\" pathOverride: \"{path_override}\" backendAuth: key: - envKey: \"{env_var}\" + env: \"{env_var}\" backendTLS: sni: \"{hostname}\" ", From 0ee35b72fa1693c2ce67d52947d95efbae160e67 Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 09:46:50 +1030 Subject: [PATCH 07/18] fix: convert config to JSON before passing to agentgateway Work around serde_yaml bug where untagged enum FileOrInline fails to deserialize key.env from YAML but works from JSON. Co-Authored-By: Claude Opus 4.6 --- src/commands/init/agentgateway_scripts/start.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/init/agentgateway_scripts/start.sh b/src/commands/init/agentgateway_scripts/start.sh index e498d52..2eeb647 100755 --- a/src/commands/init/agentgateway_scripts/start.sh +++ b/src/commands/init/agentgateway_scripts/start.sh @@ -39,5 +39,8 @@ fi # --- Step 2: Assemble config from fragments --- "$SCRIPT_DIR/assemble-config.sh" -# --- Step 3: Start agentgateway --- -exec "$SCRIPT_DIR/bin/agentgateway" -f "$SCRIPT_DIR/config.yaml" "$@" +# --- Step 3: Convert YAML to JSON (workaround for serde_yaml untagged enum bug) --- +$YQ eval -o=json '.' "$SCRIPT_DIR/config.yaml" > "$SCRIPT_DIR/config.json" + +# --- Step 4: Start agentgateway --- +exec "$SCRIPT_DIR/bin/agentgateway" -c "$(cat "$SCRIPT_DIR/config.json")" "$@" From b4b040c838bd763c975bc6ee7e26eff76076eb07 Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 09:55:39 +1030 Subject: [PATCH 08/18] fix: move address to bind level in listener config agentgateway expects address on the bind, not on the listener. The assemble-config.sh adds each listener file as a bind entry. Co-Authored-By: Claude Opus 4.6 --- src/commands/init/agentgateway.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs index 4043f88..d323c75 100644 --- a/src/commands/init/agentgateway.rs +++ b/src/commands/init/agentgateway.rs @@ -168,10 +168,10 @@ fn write_llm_listener_yaml(agw_dir: &Path) -> Result<()> { let jwks_path = agw_dir.join("config.d/jwt/jwks.json"); let content = format!( "\ +address: \"0.0.0.0:4000\" listeners: - name: llm protocol: HTTP - address: \"0.0.0.0:4000\" routes: [] authentication: jwt: @@ -186,10 +186,10 @@ listeners: fn write_mcp_listener_yaml(agw_dir: &Path) -> Result<()> { let content = "\ +address: \"0.0.0.0:3000\" listeners: - name: mcp protocol: HTTP - address: \"0.0.0.0:3000\" routes: - name: mcp-route matches: From 1a369cc22c2f1345e8de3947e5aaebf5cae36dc0 Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 10:04:07 +1030 Subject: [PATCH 09/18] fix: use -f with JSON file instead of -c for agentgateway config The -c flag may have argument length limits. Use -f with the converted JSON file instead. Co-Authored-By: Claude Opus 4.6 --- src/commands/init/agentgateway_scripts/start.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/init/agentgateway_scripts/start.sh b/src/commands/init/agentgateway_scripts/start.sh index 2eeb647..a69239f 100755 --- a/src/commands/init/agentgateway_scripts/start.sh +++ b/src/commands/init/agentgateway_scripts/start.sh @@ -43,4 +43,4 @@ fi $YQ eval -o=json '.' "$SCRIPT_DIR/config.yaml" > "$SCRIPT_DIR/config.json" # --- Step 4: Start agentgateway --- -exec "$SCRIPT_DIR/bin/agentgateway" -c "$(cat "$SCRIPT_DIR/config.json")" "$@" +exec "$SCRIPT_DIR/bin/agentgateway" -f "$SCRIPT_DIR/config.json" "$@" From 7ecd9d7febed3c18b7e6eabfb7cb92a8d358d222 Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 10:12:36 +1030 Subject: [PATCH 10/18] fix: use port (integer) instead of address for bind config agentgateway expects 'port' (integer) at the bind level, not 'address'. Co-Authored-By: Claude Opus 4.6 --- src/commands/init/agentgateway.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs index d323c75..551a90b 100644 --- a/src/commands/init/agentgateway.rs +++ b/src/commands/init/agentgateway.rs @@ -168,7 +168,7 @@ fn write_llm_listener_yaml(agw_dir: &Path) -> Result<()> { let jwks_path = agw_dir.join("config.d/jwt/jwks.json"); let content = format!( "\ -address: \"0.0.0.0:4000\" +port: 4000 listeners: - name: llm protocol: HTTP @@ -186,7 +186,7 @@ listeners: fn write_mcp_listener_yaml(agw_dir: &Path) -> Result<()> { let content = "\ -address: \"0.0.0.0:3000\" +port: 3000 listeners: - name: mcp protocol: HTTP From 9a3c008d62b659416979bccb1e770cb724a974e0 Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 10:20:55 +1030 Subject: [PATCH 11/18] fix: resolve env references in config before passing to agentgateway agentgateway's -f flag uses serde_yaml which doesn't support the env variant of FileOrInline. Resolve env references to plain strings using Python before starting agentgateway. Co-Authored-By: Claude Opus 4.6 --- .../init/agentgateway_scripts/start.sh | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/commands/init/agentgateway_scripts/start.sh b/src/commands/init/agentgateway_scripts/start.sh index a69239f..78206cf 100755 --- a/src/commands/init/agentgateway_scripts/start.sh +++ b/src/commands/init/agentgateway_scripts/start.sh @@ -39,8 +39,31 @@ fi # --- Step 2: Assemble config from fragments --- "$SCRIPT_DIR/assemble-config.sh" -# --- Step 3: Convert YAML to JSON (workaround for serde_yaml untagged enum bug) --- -$YQ eval -o=json '.' "$SCRIPT_DIR/config.yaml" > "$SCRIPT_DIR/config.json" +# --- Step 3: Assemble final config --- +# Convert YAML to JSON (workaround for serde_yaml untagged enum bug with -f). +# Then resolve env: references to inline values since -f doesn't support env. +$YQ eval -o=json '.' "$SCRIPT_DIR/config.yaml" > "$SCRIPT_DIR/config.tmp.json" + +# Replace {"env":"VAR_NAME"} with the actual env var value as a plain string +"${PYTHON}" -c " +import json, os, sys +with open('$SCRIPT_DIR/config.tmp.json') as f: + data = json.load(f) + +def resolve_env(obj): + if isinstance(obj, dict): + if set(obj.keys()) == {'env'} and isinstance(obj['env'], str): + return os.environ.get(obj['env'], '') + return {k: resolve_env(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [resolve_env(v) for v in obj] + return obj + +data = resolve_env(data) +with open('$SCRIPT_DIR/config.json', 'w') as f: + json.dump(data, f) +" +rm -f "$SCRIPT_DIR/config.tmp.json" # --- Step 4: Start agentgateway --- exec "$SCRIPT_DIR/bin/agentgateway" -f "$SCRIPT_DIR/config.json" "$@" From 072a9b1416cafba30a7c474072dcbb9d5fd88529 Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 10:38:34 +1030 Subject: [PATCH 12/18] feat: start agentgateway in Docker entrypoint Co-Authored-By: Claude Opus 4.6 --- docker/entrypoint.sh | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b8480ca..d9a8b93 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -24,8 +24,15 @@ if [ ! -f "$CONFIG_FILE" ]; then fi # Start agentgateway if configured -AGW_DIR=$(grep AGENTGATEWAY_DIR /root/.config/plit/.env 2>/dev/null | cut -d= -f2) +AGW_DIR="" +PIPELIT_ENV="/root/.local/share/plit/pipelit/.env" +if [ -f "$PIPELIT_ENV" ]; then + AGW_DIR=$(grep "^AGENTGATEWAY_DIR=" "$PIPELIT_ENV" | cut -d= -f2 | tr -d '"') +fi + if [ -n "$AGW_DIR" ] && [ -d "$AGW_DIR" ]; then + echo "Starting agentgateway..." + # Ensure binary is in place if [ ! -f "$AGW_DIR/bin/agentgateway" ]; then mkdir -p "$AGW_DIR/bin" @@ -33,23 +40,25 @@ if [ -n "$AGW_DIR" ] && [ -d "$AGW_DIR" ]; then fi # Get encryption key for key decryption - FIELD_ENCRYPTION_KEY=$(grep FIELD_ENCRYPTION_KEY /root/.config/plit/.env 2>/dev/null | head -1 | cut -d= -f2 | tr -d '"') + FIELD_ENC_KEY=$(grep "^FIELD_ENCRYPTION_KEY=" "$PIPELIT_ENV" | head -1 | cut -d= -f2 | tr -d '"') # Start agentgateway in background - echo "Starting agentgateway..." - cd "$AGW_DIR" && \ - FIELD_ENCRYPTION_KEY="$FIELD_ENCRYPTION_KEY" \ - PYTHON=/root/.local/share/plit/venv/bin/python3 \ - YQ=yq \ - ./start.sh > /tmp/agw.log 2>&1 & + ( + cd "$AGW_DIR" && \ + FIELD_ENCRYPTION_KEY="$FIELD_ENC_KEY" \ + PYTHON=/root/.local/share/plit/venv/bin/python3 \ + YQ=yq \ + ./start.sh > /tmp/agw.log 2>&1 + ) & # Wait for agentgateway to be ready (up to 10 seconds) for i in $(seq 1 10); do - curl -s -o /dev/null http://localhost:4000/ 2>/dev/null && break + if curl -s -o /dev/null http://localhost:4000/ 2>/dev/null; then + echo "agentgateway ready" + break + fi sleep 1 done - echo "agentgateway started" - cd / fi echo "Starting plit stack..." From b4cd0390684ef3edd6aa9bd2f8e36fea0377fa55 Mon Sep 17 00:00:00 2001 From: theuseless-ai Date: Mon, 25 May 2026 15:26:56 +0930 Subject: [PATCH 13/18] wip: rescue uncommitted agentgateway-docker changes Recovered from the old Alienware disk before decommissioning. Touches the Docker entrypoint, the init agentgateway command, and decrypt_keys.py (adds plaintext fallback when FIELD_ENCRYPTION_KEY is invalid). Co-Authored-By: Claude Opus 4.7 --- docker/entrypoint.sh | 36 ++++++++- src/commands/init/agentgateway.rs | 79 +++++++++++++------ .../init/agentgateway_scripts/decrypt_keys.py | 17 +++- 3 files changed, 101 insertions(+), 31 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index d9a8b93..d607821 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -24,10 +24,33 @@ if [ ! -f "$CONFIG_FILE" ]; then fi # Start agentgateway if configured +# Check both plit .env and pipelit .env for AGENTGATEWAY_DIR AGW_DIR="" +PLIT_ENV="/root/.config/plit/.env" PIPELIT_ENV="/root/.local/share/plit/pipelit/.env" -if [ -f "$PIPELIT_ENV" ]; then - AGW_DIR=$(grep "^AGENTGATEWAY_DIR=" "$PIPELIT_ENV" | cut -d= -f2 | tr -d '"') +for envfile in "$PLIT_ENV" "$PIPELIT_ENV"; do + if [ -f "$envfile" ] && [ -z "$AGW_DIR" ]; then + AGW_DIR=$(grep "^AGENTGATEWAY_DIR=" "$envfile" | cut -d= -f2 | tr -d '"') + fi +done + +# Copy agentgateway env vars to Pipelit's .env if not already there +if [ -n "$AGW_DIR" ] && [ -f "$PIPELIT_ENV" ]; then + for var in AGENTGATEWAY_ENABLED AGENTGATEWAY_URL AGENTGATEWAY_DIR JWT_PRIVATE_KEY; do + if grep -q "^${var}=" "$PLIT_ENV" 2>/dev/null && ! grep -q "^${var}=" "$PIPELIT_ENV" 2>/dev/null; then + grep "^${var}=" "$PLIT_ENV" >> "$PIPELIT_ENV" + fi + done + # JWT_PRIVATE_KEY may be multiline — handle separately + if grep -q "^JWT_PRIVATE_KEY=" "$PLIT_ENV" 2>/dev/null && ! grep -q "^JWT_PRIVATE_KEY=" "$PIPELIT_ENV" 2>/dev/null; then + python3 -c " +import re +with open('$PLIT_ENV') as f: content = f.read() +m = re.search(r'(JWT_PRIVATE_KEY=\".*?\")', content, re.DOTALL) +if m: + with open('$PIPELIT_ENV', 'a') as f: f.write('\n' + m.group(1) + '\n') +" + fi fi if [ -n "$AGW_DIR" ] && [ -d "$AGW_DIR" ]; then @@ -39,8 +62,13 @@ if [ -n "$AGW_DIR" ] && [ -d "$AGW_DIR" ]; then cp /usr/local/bin/agentgateway "$AGW_DIR/bin/agentgateway" fi - # Get encryption key for key decryption - FIELD_ENC_KEY=$(grep "^FIELD_ENCRYPTION_KEY=" "$PIPELIT_ENV" | head -1 | cut -d= -f2 | tr -d '"') + # Get encryption key for key decryption (check both .env files) + FIELD_ENC_KEY="" + for envfile in "$PIPELIT_ENV" "$PLIT_ENV"; do + if [ -f "$envfile" ] && [ -z "$FIELD_ENC_KEY" ]; then + FIELD_ENC_KEY=$(grep "^FIELD_ENCRYPTION_KEY=" "$envfile" | head -1 | cut -d= -f2 | tr -d '"') + fi + done # Start agentgateway in background ( diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs index 551a90b..591089d 100644 --- a/src/commands/init/agentgateway.rs +++ b/src/commands/init/agentgateway.rs @@ -120,7 +120,7 @@ jwks = { "y": b64url(y_bytes), "use": "sig", "alg": "ES256", - "kid": "pipelit-1" + "kid": "pipelit-001" }] } @@ -170,13 +170,17 @@ fn write_llm_listener_yaml(agw_dir: &Path) -> Result<()> { "\ port: 4000 listeners: - - name: llm - protocol: HTTP - routes: [] - authentication: - jwt: - localJwks: - filename: \"{jwks_path}\" +- name: llm + policies: + jwtAuth: + mode: strict + issuer: pipelit + audiences: [agentgateway] + jwks: + file: \"{jwks_path}\" + jwtValidationOptions: + requiredClaims: [] + routes: [] ", jwks_path = jwks_path.display() ); @@ -188,14 +192,14 @@ fn write_mcp_listener_yaml(agw_dir: &Path) -> Result<()> { let content = "\ port: 3000 listeners: - - name: mcp - protocol: HTTP - routes: - - name: mcp-route - matches: - - path: - pathPrefix: / - backends: [] +- name: mcp + routes: + - policies: + cors: + allowOrigins: ['*'] + allowHeaders: [mcp-protocol-version, content-type, cache-control] + exposeHeaders: [Mcp-Session-Id] + backends: [] "; let path = agw_dir.join("config.d/listeners/mcp.yaml"); std::fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display())) @@ -294,7 +298,7 @@ fn write_initial_provider(inputs: &UserInputs, agw_dir: &Path) -> Result<()> { } // Derive a provider name from the base URL hostname let name = provider_name_from_url(&inputs.llm_base_url); - let path = extract_path(&inputs.llm_base_url); + let path = extract_path(&inputs.llm_base_url, "openai-compatible"); write_provider_yaml(agw_dir, &name, "openAI", &inputs.llm_base_url, &path)?; write_model_yaml(agw_dir, &name, &inputs.llm_model)?; write_encrypted_key(agw_dir, &name, &inputs.llm_api_key)?; @@ -348,8 +352,7 @@ pathOverride: \"{path_override}\" backendAuth: key: env: \"{env_var}\" -backendTLS: - sni: \"{hostname}\" +backendTLS: {{}} ", provider_type = provider_type, hostname = hostname, @@ -412,25 +415,51 @@ fn provider_name_from_url(url: &str) -> String { .replace('-', "_") } -/// Extract hostname from a URL for SNI. +/// Extract host:port from a URL. Adds default port if missing (443 for https, 80 for http). fn extract_host(url: &str) -> String { - url.trim_end_matches('/') + let is_https = url.starts_with("https://"); + let host_part = url + .trim_end_matches('/') .split("://") .nth(1) .unwrap_or(url) .split('/') .next() - .unwrap_or(url) - .to_string() + .unwrap_or(url); + if host_part.contains(':') { + host_part.to_string() + } else if is_https { + format!("{host_part}:443") + } else { + format!("{host_part}:80") + } } /// Extract path from a URL (e.g., "https://api.venice.ai/api/v1" -> "/api/v1/"). -fn extract_path(url: &str) -> String { +fn extract_path(url: &str, provider_type: &str) -> String { let after_scheme = url.split("://").nth(1).unwrap_or(url); let path = after_scheme .find('/') .map(|i| &after_scheme[i..]) .unwrap_or("/"); let path = path.trim_end_matches('/'); - format!("{}/", path) + // Append endpoint suffix based on provider type + match provider_type { + "anthropic" => { + if path.ends_with("/messages") { + format!("{path}/") + } else { + format!("{path}/messages") + } + } + _ => { + // openai, openai-compatible, glm, gemini + if path.ends_with("/chat/completions") { + format!("{path}/") + } else { + format!("{path}/chat/completions") + } + } + } } +// cache bust 1775094575 diff --git a/src/commands/init/agentgateway_scripts/decrypt_keys.py b/src/commands/init/agentgateway_scripts/decrypt_keys.py index 634a718..7376cf3 100755 --- a/src/commands/init/agentgateway_scripts/decrypt_keys.py +++ b/src/commands/init/agentgateway_scripts/decrypt_keys.py @@ -49,14 +49,27 @@ def main() -> None: print(f"export {name}={kf.read_text().strip()}") return - fernet = Fernet(enc_key.encode()) + try: + fernet = Fernet(enc_key.encode()) + except Exception: + # Invalid Fernet key — fall back to plaintext for all files + print("Warning: FIELD_ENCRYPTION_KEY is invalid, reading keys as plaintext", file=sys.stderr) + for kf in sorted(keys_dir.glob("*.key")): + name = _name_to_env_var(kf.stem) + print(f"export {name}={kf.read_text().strip()}") + return + for kf in sorted(keys_dir.glob("*.key")): name = _name_to_env_var(kf.stem) try: decrypted = fernet.decrypt(kf.read_bytes()).decode() print(f"export {name}={decrypted}") except Exception as e: - print(f"Warning: Failed to decrypt {kf.name}: {e}", file=sys.stderr) + # Fall back to reading as plaintext + plaintext = kf.read_text().strip() + if plaintext: + print(f"export {name}={plaintext}") + print(f"Warning: Failed to decrypt {kf.name}, using as plaintext: {e}", file=sys.stderr) if __name__ == "__main__": From 82bcb322a1471f60611d949a153560e9297fd488 Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Fri, 3 Jul 2026 14:35:02 +0930 Subject: [PATCH 14/18] feat(docker): make agentgateway a hard boot dependency Replace the soft "probe ~10s then continue" with a blocking readiness gate that exits non-zero (FATAL) if agentgateway never becomes healthy, so plit never serves without the gateway. Exports AGENTGATEWAY_DIR/URL. Co-Authored-By: Claude Opus 4.8 --- docker/entrypoint.sh | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index d607821..6c927f1 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -56,6 +56,9 @@ fi if [ -n "$AGW_DIR" ] && [ -d "$AGW_DIR" ]; then echo "Starting agentgateway..." + export AGENTGATEWAY_DIR="$AGW_DIR" + export AGENTGATEWAY_URL="${AGENTGATEWAY_URL:-http://localhost:4000}" + # Ensure binary is in place if [ ! -f "$AGW_DIR/bin/agentgateway" ]; then mkdir -p "$AGW_DIR/bin" @@ -79,14 +82,24 @@ if [ -n "$AGW_DIR" ] && [ -d "$AGW_DIR" ]; then ./start.sh > /tmp/agw.log 2>&1 ) & - # Wait for agentgateway to be ready (up to 10 seconds) - for i in $(seq 1 10); do - if curl -s -o /dev/null http://localhost:4000/ 2>/dev/null; then + # Block until agentgateway is ready. agentgateway is a hard boot + # dependency — if it never comes up, plit must not start serving. + AGW_READY=false + AGW_READY_TIMEOUT=45 + for i in $(seq 1 "$AGW_READY_TIMEOUT"); do + if curl -s -o /dev/null "$AGENTGATEWAY_URL/" 2>/dev/null; then echo "agentgateway ready" + AGW_READY=true break fi sleep 1 done + + if [ "$AGW_READY" != true ]; then + echo "FATAL: agentgateway did not become ready within ${AGW_READY_TIMEOUT}s (${AGENTGATEWAY_URL}) — refusing to start plit without it" >&2 + cat /tmp/agw.log >&2 2>/dev/null || true + exit 1 + fi fi echo "Starting plit stack..." From 8b1af9eb6d795e2262765269136cf5c73a5770cd Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Fri, 3 Jul 2026 14:35:02 +0930 Subject: [PATCH 15/18] fix(agentgateway): pass-through model override + HTTP-upstream support in config gen - assemble-config.sh: skip the provider..model override when the model file declares none (pass-through), and include backendTLS only when the provider fragment declares it. - agentgateway.rs write_provider_yaml: emit backendTLS only for https upstreams, so plain-HTTP backends (LAN Qwen, local Ollama) are reachable instead of failing with a TLS error. Co-Authored-By: Claude Opus 4.8 --- src/commands/init/agentgateway.rs | 15 +++++++++++++-- .../agentgateway_scripts/assemble-config.sh | 18 ++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs index 591089d..32ee034 100644 --- a/src/commands/init/agentgateway.rs +++ b/src/commands/init/agentgateway.rs @@ -342,6 +342,17 @@ fn write_provider_yaml( // Extract just the hostname for hostOverride and SNI let hostname = extract_host(host); + // Emit backendTLS ONLY for TLS upstreams. agentgateway treats the mere + // presence of the policy (even {}) as "speak TLS to the upstream", which + // breaks plain-http backends (e.g. local Ollama or a LAN Qwen box) with + // a TLS InvalidContentType error. Only an explicit http:// scheme + // disables it; https:// (and scheme-less hosts) keep TLS on. + let backend_tls = if host.starts_with("http://") { + "" + } else { + "backendTLS: {}\n" + }; + let content = format!( "\ provider: @@ -352,12 +363,12 @@ pathOverride: \"{path_override}\" backendAuth: key: env: \"{env_var}\" -backendTLS: {{}} -", +{backend_tls}", provider_type = provider_type, hostname = hostname, path_override = path_override, env_var = env_var, + backend_tls = backend_tls, ); let path = provider_dir.join("_provider.yaml"); diff --git a/src/commands/init/agentgateway_scripts/assemble-config.sh b/src/commands/init/agentgateway_scripts/assemble-config.sh index dcc3811..77bf35b 100755 --- a/src/commands/init/agentgateway_scripts/assemble-config.sh +++ b/src/commands/init/agentgateway_scripts/assemble-config.sh @@ -98,8 +98,22 @@ for provider_dir in "$CONFIG_D"/backends/*/; do }] }") - # Set the model on the provider - route=$(echo "$route" | $YQ eval ".backends[0].ai.provider[][\"model\"] = \"${model_name}\"" -) + # Include backendTLS ONLY when the provider fragment declares it. + # agentgateway treats the mere presence of the policy (even {}) as + # "speak TLS to the upstream", which breaks plain-http backends + # (e.g. a LAN Qwen box or local Ollama) with InvalidContentType. + if [ "$(echo "$provider_config" | $YQ eval 'has("backendTLS")' -)" != "true" ]; then + route=$(echo "$route" | $YQ eval 'del(.policies.backendTLS)' -) + fi + + # Set the model override ONLY when the model file specifies a real + # upstream model id. A missing/null model means pass-through: + # agentgateway forwards the caller's requested model unchanged. Writing + # a "null" (or empty) override would ship literal "null" upstream and + # 404. (agentgateway provider..model is a hard outbound override.) + if [ -n "$model_name" ] && [ "$model_name" != "null" ]; then + route=$(echo "$route" | $YQ eval ".backends[0].ai.provider[][\"model\"] = \"${model_name}\"" -) + fi # Inject authorization rules route=$(echo "$route" | $YQ eval-all ' From 8bd2d38bad22f7906dc58bd47c830bc39fce58f5 Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Fri, 3 Jul 2026 22:39:24 +0930 Subject: [PATCH 16/18] fix(init): derive a valid provider name from IP/local base URLs provider_name_from_url picked the 2nd-level 'domain' via split('.').rev().nth(1), which for an IP host like 192.168.0.73 yields the octet '0' -> provider name '0' -> invalid env var '0_API_KEY', so start.sh's export fails and agentgateway never starts (fatal under the hard boot dependency). Now sanitize to a valid identifier: real hostnames still map api.openai.com->openai, but IPs/local hosts become p_192_168_0_73 / localhost. Found via a live Colima e2e against a LAN openai-compatible (Qwen) backend. Co-Authored-By: Claude Opus 4.8 --- src/commands/init/agentgateway.rs | 46 ++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs index 32ee034..6079470 100644 --- a/src/commands/init/agentgateway.rs +++ b/src/commands/init/agentgateway.rs @@ -410,20 +410,52 @@ fn model_slug(model_name: &str) -> String { } /// Derive a provider name from a base URL (e.g., "https://api.venice.ai/api/v1" -> "venice"). +/// +/// The result is always a valid environment-variable identifier component +/// (lowercase `[a-z0-9_]`, never leading with a digit) so that IP-based and +/// local backends work — e.g. "http://192.168.0.73:8080/v1" -> "p_192_168_0_73", +/// "http://localhost:11434" -> "localhost". A bare octet like "0" used to leak +/// through and produce an invalid `0_API_KEY` env var, breaking gateway startup. fn provider_name_from_url(url: &str) -> String { - url.trim_end_matches('/') + // Host without scheme, path, or port. + let host = url + .trim_end_matches('/') .split("://") .nth(1) .unwrap_or(url) .split('/') .next() - .unwrap_or("custom") - .split('.') - .rev() - .nth(1) // second-level domain - .unwrap_or("custom") + .unwrap_or("") + .split(':') + .next() + .unwrap_or(""); + + // Real multi-label hostnames -> second-level domain (api.openai.com -> openai). + // IPs and single-label hosts -> use the whole host (localhost, 192.168.0.73). + let labels: Vec<&str> = host.split('.').filter(|s| !s.is_empty()).collect(); + let is_ip = !labels.is_empty() + && host + .split('.') + .all(|l| !l.is_empty() && l.bytes().all(|b| b.is_ascii_digit())); + let raw = if is_ip || labels.len() < 2 { + host + } else { + labels[labels.len() - 2] + }; + + // Sanitize into a valid identifier component. + let mut name: String = raw .to_lowercase() - .replace('-', "_") + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + if name.is_empty() { + name = "custom".to_string(); + } + if name.starts_with(|c: char| c.is_ascii_digit()) { + name = format!("p_{name}"); + } + name } /// Extract host:port from a URL. Adds default port if missing (443 for https, 80 for http). From 28f396c9ceb24cdd8122ae7155e033cf7fe2b411 Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Sat, 4 Jul 2026 09:00:12 +0930 Subject: [PATCH 17/18] feat(init): set default node's backend_route to the created agentgateway route plit init created the agentgateway route (-) but never told pipelit; the default node's backend_route was NULL, so pipelit fell back to a mismatched name and its proxied LLM call 404'd ("route not found") at the gateway. plit init now computes the route (new agentgateway::initial_route_name) and passes it via `apply-fixture --backend-route`. Verified end-to-end: real chat -> agentgateway -> LAN Qwen box returns 200. Co-Authored-By: Claude Opus 4.8 --- src/commands/init/agentgateway.rs | 24 ++++++++++++++++++++++++ src/commands/init/install.rs | 8 ++++++++ 2 files changed, 32 insertions(+) diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs index 6079470..fe4167a 100644 --- a/src/commands/init/agentgateway.rs +++ b/src/commands/init/agentgateway.rs @@ -326,6 +326,30 @@ fn write_initial_provider(inputs: &UserInputs, agw_dir: &Path) -> Result<()> { Ok(()) } +/// The agentgateway route name (`"-"`) that +/// `write_initial_provider` + `assemble-config.sh` produce for the initial +/// provider/model. plit init passes this to `apply-fixture --backend-route` +/// so pipelit records it on the default model node and its proxied LLM calls +/// hit the matching gateway route. Returns `None` for providers that create no +/// route. NOTE: the provider arm here must stay in sync with +/// `write_initial_provider` above. +pub fn initial_route_name(inputs: &UserInputs) -> Option { + let provider = match inputs.llm_provider.as_str() { + "openai" => "openai".to_string(), + "anthropic" => "anthropic".to_string(), + "gemini" => "gemini".to_string(), + "ollama" => "ollama".to_string(), + "openai-compatible" => { + if inputs.llm_base_url.is_empty() { + return None; + } + provider_name_from_url(&inputs.llm_base_url) + } + _ => return None, + }; + Some(format!("{}-{}", provider, model_slug(&inputs.llm_model))) +} + fn write_provider_yaml( agw_dir: &Path, name: &str, diff --git a/src/commands/init/install.rs b/src/commands/init/install.rs index 5c0e414..bd83673 100644 --- a/src/commands/init/install.rs +++ b/src/commands/init/install.rs @@ -201,6 +201,14 @@ pub async fn run_apply_fixture( .arg(&inputs.llm_base_url) .current_dir(pipelit_dir.join("platform")); + // Bridge the naming contract: tell pipelit the exact agentgateway route + // this provider/model will be reachable at, so the default node's + // backend_route matches what plit init creates (otherwise pipelit's + // proxied call 404s with "route not found"). + if let Some(route) = super::agentgateway::initial_route_name(inputs) { + cmd.arg("--backend-route").arg(route); + } + for (key, value) in &env_vars { cmd.env(key, value); } From 14b1b07738bfbbf62a8cf3d531997f78658bd4e0 Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Sat, 4 Jul 2026 23:53:21 +0930 Subject: [PATCH 18/18] feat(agentgateway): add starter local rate limits on LLM routes No rate limiting or spend guard existed anywhere, leaving autonomous agent loops able to cause runaway LLM spend. agentgateway v1.0.1 has no dollar-budget feature, so use route-level localRateLimit token buckets (schema verified against the v1.0.1 tag). - write_rate_limits() seeds config.d/policies/rate-limit.yaml only if absent (operator tuning survives re-init); generous defaults: 300 req/min (600 burst) + 200k tokens/min (400k burst) per route - assemble-config.sh injects it as policies.localRateLimit on every LLM route; absent/empty fragment -> byte-identical output to before - emit the fragment as JSON with a `// []` fallback so an empty, null, or comment-only file collapses to [] (a comment-only file would otherwise inject localRateLimit: null, which agentgateway rejects at startup and bricks the hard-boot dependency) Co-Authored-By: Claude Opus 4.8 --- src/commands/init/agentgateway.rs | 41 +++++++++++++++++++ .../agentgateway_scripts/assemble-config.sh | 29 +++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/commands/init/agentgateway.rs b/src/commands/init/agentgateway.rs index fe4167a..d8edfda 100644 --- a/src/commands/init/agentgateway.rs +++ b/src/commands/init/agentgateway.rs @@ -39,6 +39,7 @@ pub async fn bootstrap(inputs: &UserInputs) -> Result { "config.d/listeners", "config.d/backends", "config.d/rules", + "config.d/policies", "config.d/mcp_servers", "keys", ]; @@ -63,6 +64,7 @@ pub async fn bootstrap(inputs: &UserInputs) -> Result { write_llm_listener_yaml(&agw_dir)?; write_mcp_listener_yaml(&agw_dir)?; write_rules(&agw_dir)?; + write_rate_limits(&agw_dir)?; output::status(" * Wrote config fragments"); // Write shell scripts @@ -218,6 +220,45 @@ fn write_rules(agw_dir: &Path) -> Result<()> { .with_context(|| format!("Failed to write {}", user_path.display())) } +/// Write the default local rate-limit policy fragment. +/// +/// assemble-config.sh injects these RateLimitSpec entries into EVERY LLM +/// route as `policies.localRateLimit` (schema verified against agentgateway +/// v1.0.1: `schema/config.json` + `examples/ratelimiting/local/config.yaml` +/// at that tag). The defaults are deliberately generous — a guard against +/// runaway autonomous agent loops, not a throttle on legitimate long runs. +/// +/// The file is only written if absent so operator tuning survives re-init. +/// Deleting the file (and reassembling) disables rate limiting entirely. +fn write_rate_limits(agw_dir: &Path) -> Result<()> { + let path = agw_dir.join("config.d/policies/rate-limit.yaml"); + if path.exists() { + return Ok(()); + } + let content = "\ +# Local rate limits applied to every LLM route (token bucket per route, +# state local to the gateway). Generous starter defaults: stop runaway +# agent loops without disturbing long legitimate agent runs. +# +# Schema (agentgateway v1.0.1 RateLimitSpec): +# maxTokens - bucket capacity (burst) +# tokensPerFill - added every fillInterval (sustained rate) +# fillInterval - duration string, e.g. 60s +# type - 'requests' (HTTP requests) or 'tokens' (LLM tokens) +# +# Tune per install; delete this file to disable rate limiting. +- maxTokens: 600 + tokensPerFill: 300 + fillInterval: 60s + type: requests +- maxTokens: 400000 + tokensPerFill: 200000 + fillInterval: 60s + type: tokens +"; + std::fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display())) +} + // --- Shell script writers --- fn write_assemble_config_sh(agw_dir: &Path) -> Result<()> { diff --git a/src/commands/init/agentgateway_scripts/assemble-config.sh b/src/commands/init/agentgateway_scripts/assemble-config.sh index 77bf35b..758d371 100755 --- a/src/commands/init/agentgateway_scripts/assemble-config.sh +++ b/src/commands/init/agentgateway_scripts/assemble-config.sh @@ -8,6 +8,7 @@ # _provider.yaml → shared config (host, path, auth, TLS) # .yaml → one file per model (just "model: ") # config.d/rules/*.yaml → CEL authorization rules (merged into backends) +# config.d/policies/rate-limit.yaml → localRateLimit specs applied to every LLM route (optional) # config.d/mcp_servers/*.yaml → MCP server targets (injected into MCP listener) # config.d/jwt/jwks.json → JWT public key (referenced by llm listener) # @@ -34,6 +35,24 @@ for f in "$CONFIG_D"/rules/*.yaml; do ' - "$f") done +# --- Step 2b: Load local rate limit policy (optional) --- +# config.d/policies/rate-limit.yaml holds an array of agentgateway +# RateLimitSpec entries (schema verified against agentgateway v1.0.1: +# maxTokens / tokensPerFill / fillInterval / type: requests|tokens). +# They are applied to EVERY LLM route as policies.localRateLimit — a +# generous runaway-spend guard. Delete the file OR comment out its +# entries to disable entirely. +# +# Emit JSON with a `// []` fallback so that an empty, null, or +# comment-only file collapses to "[]" (comments do not survive JSON +# encoding). Without this, a comment-only file would echo the comments +# and inject `localRateLimit: null`, which agentgateway rejects at +# startup — bricking the hard-boot dependency. +rate_limit="[]" +if [ -f "$CONFIG_D/policies/rate-limit.yaml" ]; then + rate_limit=$($YQ eval '. // []' -o=json "$CONFIG_D/policies/rate-limit.yaml") +fi + # --- Step 3: Add listeners as binds --- for f in "$CONFIG_D"/listeners/*.yaml; do [ -f "$f" ] || continue @@ -121,6 +140,16 @@ for provider_dir in "$CONFIG_D"/backends/*/; do | select(fileIndex == 0) ' - <(echo "$rules")) + # Inject local rate limits (per-route token bucket, local state). + # Skipped when config.d/policies/rate-limit.yaml is absent or empty, + # so installs without the fragment keep the previous behaviour. + if [ -n "$rate_limit" ] && [ "$rate_limit" != "[]" ] && [ "$rate_limit" != "null" ]; then + route=$(echo "$route" | $YQ eval-all ' + select(fileIndex == 0).policies.localRateLimit = select(fileIndex == 1) + | select(fileIndex == 0) + ' - <(echo "$rate_limit")) + fi + # Append to LLM listener routes merged=$(echo "$merged" | $YQ eval-all ' (select(fileIndex == 0).binds[] | select(.listeners[].name == "llm")).listeners[0].routes += [select(fileIndex == 1)]