From d5f502335046590beea541e5be032ce003719f17 Mon Sep 17 00:00:00 2001 From: aka Date: Thu, 2 Apr 2026 08:58:35 +1030 Subject: [PATCH 01/10] feat: agentgateway LLM proxy integration - JWT issuer (ES256) for server-to-server auth with agentgateway - Proxy client routing LLM calls through agentgateway - Config writer for provider/model subfolder structure - Providers API (admin CRUD) and available-models API (all users) - LLM resolution via backend_route with llm_credential_id fallback - Migration script (cli migrate-credentials) - Frontend: Providers page, model picker dropdown, credentials page cleanup - Auto-restart agentgateway on provider add/remove - Feature-flagged via AGENTGATEWAY_ENABLED Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 91 ++ .../archive/gateway-mcp-design.v1.md | 816 +++++++++++++++++ .../gateway-mcp-design.v2-iterative.md | 827 ++++++++++++++++++ docs/architecture/gateway-mcp-design.md | 493 +++++++++++ docs/architecture/platform-vision-2026-03.md | 488 +++++++++++ .../workflow-composition-design.md | 316 +++++++ ..._add_backend_route_to_component_configs.py | 25 + platform/api/__init__.py | 4 + platform/api/available_models.py | 25 + platform/api/credentials.py | 9 + platform/api/nodes.py | 3 +- platform/api/providers.py | 350 ++++++++ platform/cli/__main__.py | 336 +++++++ platform/config.py | 6 + platform/frontend/src/App.tsx | 2 + platform/frontend/src/api/available_models.ts | 10 + platform/frontend/src/api/providers.ts | 62 ++ .../src/components/layout/AppLayout.tsx | 2 + .../features/credentials/CredentialsPage.tsx | 33 +- .../src/features/providers/ProvidersPage.tsx | 329 +++++++ .../workflows/components/NodeDetailsPanel.tsx | 77 +- platform/frontend/src/types/models.ts | 8 +- platform/models/node.py | 3 + platform/requirements.txt | 1 + platform/schemas/credential.py | 1 + platform/schemas/node.py | 1 + platform/services/agentgateway_client.py | 130 +++ platform/services/agentgateway_config.py | 416 +++++++++ platform/services/jwt_issuer.py | 75 ++ platform/services/llm.py | 306 ++++++- platform/tests/test_agentgateway_client.py | 366 ++++++++ platform/tests/test_agentgateway_config.py | 658 ++++++++++++++ platform/tests/test_available_models_api.py | 153 ++++ .../tests/test_credentials_agentgateway.py | 293 +++++++ platform/tests/test_jwt_issuer.py | 151 ++++ platform/tests/test_llm_agentgateway.py | 750 ++++++++++++++++ platform/tests/test_migrate_credentials.py | 759 ++++++++++++++++ platform/tests/test_providers_api.py | 491 +++++++++++ 38 files changed, 8806 insertions(+), 60 deletions(-) create mode 100644 docs/architecture/archive/gateway-mcp-design.v1.md create mode 100644 docs/architecture/archive/gateway-mcp-design.v2-iterative.md create mode 100644 docs/architecture/gateway-mcp-design.md create mode 100644 docs/architecture/platform-vision-2026-03.md create mode 100644 docs/architecture/workflow-composition-design.md create mode 100644 platform/alembic/versions/758cd1ca2aad_add_backend_route_to_component_configs.py create mode 100644 platform/api/available_models.py create mode 100644 platform/api/providers.py create mode 100644 platform/frontend/src/api/available_models.ts create mode 100644 platform/frontend/src/api/providers.ts create mode 100644 platform/frontend/src/features/providers/ProvidersPage.tsx create mode 100644 platform/services/agentgateway_client.py create mode 100644 platform/services/agentgateway_config.py create mode 100644 platform/services/jwt_issuer.py create mode 100644 platform/tests/test_agentgateway_client.py create mode 100644 platform/tests/test_agentgateway_config.py create mode 100644 platform/tests/test_available_models_api.py create mode 100644 platform/tests/test_credentials_agentgateway.py create mode 100644 platform/tests/test_jwt_issuer.py create mode 100644 platform/tests/test_llm_agentgateway.py create mode 100644 platform/tests/test_migrate_credentials.py create mode 100644 platform/tests/test_providers_api.py diff --git a/CLAUDE.md b/CLAUDE.md index 2df517a0..6859e8df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,97 @@ Visual workflow automation platform for building LLM-powered agents. Design work - Agent nodes can have sub-components (tools, memory) - Always check that new component types are registered in ALL required places: SQLAlchemy polymorphic_identity, Pydantic literals, frontend type definitions, and migration stamps +## Dev Container + +The dev container (`./dev.sh up`) uses a **separate database** at `/root/.local/share/plit/pipelit.db`, not the platform default `db.sqlite3`. The server gets `DATABASE_URL` from `/root/.config/plit/.env` (loaded by honcho), but manual shell commands inside the container don't. + +**Always use `./dev.sh exec` to run CLI commands** — it sources the correct `.env` before execution: +```bash +# Correct: +./dev.sh exec python -m cli import-fixture tests/dsl_fixtures/workflow_generator/fixture.json + +# Wrong — hits the wrong database: +docker exec plit-dev bash -c '... python -m cli import-fixture ...' +``` + +To rebuild the frontend for the dev container, build on the host then copy into the image path: +```bash +cd platform/frontend && npm run build +docker exec plit-dev bash -c 'rm -rf /app/frontend/dist && cp -r /root/.local/share/plit/pipelit/platform/frontend/dist /app/frontend/dist' +``` + +## agentgateway Integration + +LLM calls route through agentgateway (v1.0.1) when `AGENTGATEWAY_ENABLED=true`. Feature-flagged — disabled by default. + +**Architecture:** +``` +Pipelit (workflow engine, JWT issuer) + → agentgateway (:4000, LLM proxy, JWT validator, CEL enforcer) + → Venice / OpenAI / Anthropic / etc. +``` + +**Key files:** +- `services/agentgateway_config.py` — provider/model config writer (writes to agentgateway's `config.d/`) +- `services/agentgateway_client.py` — LangChain clients pointing at agentgateway +- `services/jwt_issuer.py` — mints ES256 JWTs (60s, per-request) +- `services/llm.py` — `resolve_llm_for_node()` checks `backend_route` first, falls back to `llm_credential_id` +- `api/providers.py` — admin CRUD for providers + models +- `api/available_models.py` — read-only model list for all users +- `schemas/node.py` — `ComponentConfigData.backend_route` field +- `api/nodes.py` — `model_fields` tuple must include `"backend_route"` + +**Settings** (in `.env`): +- `AGENTGATEWAY_ENABLED=true/false` — feature flag +- `AGENTGATEWAY_URL=http://localhost:4000` — LLM listener +- `AGENTGATEWAY_DIR=/path/to/agentgateway` — config.d, keys, scripts +- `JWT_PRIVATE_KEY` — PEM-encoded ES256 private key (signs JWTs) + +**agentgateway folder structure** (`AGENTGATEWAY_DIR`): +``` +config.d/ +├── base.yaml # admin addr +├── jwt/jwks.json # ES256 public key (validates JWTs) +├── listeners/ +│ ├── llm.yaml # LLM listener (:4000) + JWT auth +│ └── mcp.yaml # MCP listener (:3000) +├── backends// +│ ├── _provider.yaml # shared: host, path, auth, TLS +│ └── .yaml # model: +├── rules/ # CEL authorization rules +└── mcp_servers/ # future MCP targets +keys/.key # Fernet-encrypted API keys +config.yaml # assembled output (generated) +assemble-config.sh # merges config.d/ → config.yaml via yq +start.sh # decrypt keys + assemble + exec agentgateway +``` + +**How it works:** +1. Admin adds provider via `/api/v1/providers/` → writes `_provider.yaml` + encrypted key +2. Admin adds models → writes `.yaml` per model +3. `assemble-config.sh` merges fragments → one route per model in `config.yaml` +4. agentgateway hot-reloads config (~250ms) for model changes; auto-restarts for new providers +5. Non-admin picks model via `/api/v1/available-models/` → stored as `backend_route` on node +6. At runtime: `resolve_llm_for_node()` → mint JWT → `create_proxied_llm()` → agentgateway → provider + +**Conventions:** +- Route naming: `-` (e.g., `venice-glm-4.7`) +- Key naming: `keys/.key` → `$_API_KEY` env var +- Provider names must NOT contain hyphens (breaks route parsing) +- JWT: ES256 only (agentgateway does not support HS256) +- JWKS path in `listeners/llm.yaml` must be absolute in Docker + +### Dev Container with agentgateway + +When testing agentgateway integration in Docker: + +1. **Mount code + agentgateway dir** into the GHCR plit image +2. **Two databases exist** — app uses `/root/.local/share/plit/pipelit.db`, mounted code has `platform/db.sqlite3` (not used) +3. **agentgateway must be started manually** inside container after each restart +4. **Frontend is served from `/app/frontend/dist`** (baked in image) — after rebuilding, copy: `docker exec bash -c 'cp -r /root/.local/share/plit/pipelit/platform/frontend/dist /app/frontend/dist'` +5. **Python bytecache** — after changing `.py` files, delete `.pyc` and restart container +6. **Binary symlink** — replace `bin/agentgateway` symlink with actual binary copy before mounting (host symlinks don't resolve in container) + ## Working Style - Always present a plan and get user approval BEFORE writing code for architectural changes - Do not rush to fix/improve things the user is just showing you for discussion diff --git a/docs/architecture/archive/gateway-mcp-design.v1.md b/docs/architecture/archive/gateway-mcp-design.v1.md new file mode 100644 index 00000000..94abfa23 --- /dev/null +++ b/docs/architecture/archive/gateway-mcp-design.v1.md @@ -0,0 +1,816 @@ +# Gateway MCP Integration Design + +**Date:** 2026-03-22 +**Status:** Design phase +**Context:** Design session on MCP hosting, credential security, and adapter refactoring + +--- + +## Security Model: Gateway as Trust Boundary + +### The Core Principle + +**Credentials never leave the gateway.** Agents and workflows interact with external services through the gateway API, never by holding tokens directly. This prevents LLM agents from accidentally leaking secrets in outputs, tool calls, error messages, or prompts sent to LLM providers. + +``` +┌─────────────────────────────────────────────────────────┐ +│ TRUST BOUNDARY │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ PLIT GATEWAY │ │ +│ │ │ │ +│ │ Credentials: never leave this boundary │ │ +│ │ MCP servers: run inside this boundary │ │ +│ │ Secrets: encrypted at rest, in memory at runtime │ │ +│ │ │ │ +│ │ External calls happen HERE: │ │ +│ │ Slack API ← MCP server ← gateway │ │ +│ │ Gmail API ← MCP server ← gateway │ │ +│ │ Discord ← MCP server ← gateway │ │ +│ │ Postgres ← MCP server ← gateway │ │ +│ │ │ │ +│ └──────────────────────┬────────────────────────────┘ │ +│ │ │ +│ Authenticated API only │ +│ (role-based, capabilities not secrets) │ +│ │ +└─────────────────────────┼────────────────────────────────┘ + │ + ┌───────────▼───────────┐ + │ Pipelit / Agents │ + │ │ + │ "send to #general" │ + │ "read my inbox" │ + │ "query postgres" │ + │ │ + │ Never sees tokens. │ + │ Just makes requests. │ + └───────────────────────┘ +``` + +### Why Not a Secrets Store (Vault/OpenBao)? + +Vault/OpenBao are **secrets delivery services** — they encrypt at rest and hand out plaintext secrets to authorized clients. The secret leaves the boundary. An agent that reads a Slack token from Vault has that token in its LLM context. + +The gateway is a **secrets usage service** — like Fireblocks for integrations. It holds the credentials and performs operations on behalf of callers. The caller gets results, never tokens. + +| | Secrets delivery (Vault) | Secrets usage (Gateway) | +|---|---|---| +| Agent gets | The actual token | A capability to act | +| LLM context contains | `xoxb-1234-secret...` | `"you can send Slack messages"` | +| If agent leaks output | Token is exposed | Nothing sensitive | +| Revocation | Rotate the leaked token | Revoke the agent's role | + +### Gateway as the Only Holder of External Credentials + +The gateway holds ALL external credentials — not just integration tokens, but also LLM API keys. Backends (Pipelit, OpenCode, future) hold ZERO real external keys. They only hold gateway tokens. + +``` +Things in backend processes (Pipelit, OpenCode, agents, sandboxes): + ✅ Gateway tokens (only useful for calling the gateway) + ✅ Gateway URL + ❌ No LLM API keys + ❌ No Slack/Discord/Gmail tokens + ❌ No database connection strings + ❌ No OAuth tokens + → An LLM/agent cannot leak what doesn't exist in its environment + +Things ONLY in the gateway process: + 🔒 All LLM API keys (Anthropic, OpenAI, Ollama, etc.) + 🔒 All integration tokens (Slack, Discord, Gmail, etc.) + 🔒 All database credentials + 🔒 All OAuth tokens + refresh tokens + 🔒 Encrypted at rest in Dragonfly, decrypted only in gateway memory +``` + +### Gateway APIs + +The gateway exposes three API surfaces — **capability API**, **LLM proxy**, and **admin API**: + +#### Integration Capability API + +Exposes **actions**, not credentials: + +``` +POST /api/v1/integrations/{integration}/tools/{tool} + Authorization: Bearer + Body: { ...tool parameters... } + +Examples: + +POST /api/v1/integrations/slack/tools/send_message + Body: { channel: "#general", text: "hello" } + +GET /api/v1/integrations/slack/tools/conversations_history + Body: { channel: "#general", limit: 10 } + +POST /api/v1/integrations/gmail/tools/send_email + Body: { to: "user@example.com", subject: "hi", body: "..." } + +POST /api/v1/integrations/postgres/tools/execute_sql + Body: { source_id: "production", query: "SELECT * FROM users LIMIT 10" } +``` + +#### LLM Proxy (OpenAI-Compatible) + +Backends point their LLM clients at the gateway instead of real providers: + +``` +POST /api/v1/llm/v1/chat/completions + Authorization: Bearer + Body: { model: "claude-sonnet-4-20250514", messages: [...] } + +Gateway: + 1. Resolve gateway token → identity + 2. Check: does identity have llm access? Which models? + 3. Look up real LLM API key from encrypted credential store + 4. Proxy request to real provider (Anthropic, OpenAI, Ollama) + 5. Return response + 6. Log usage (tokens, cost) per identity +``` + +Backend configuration becomes trivial — no real API keys: +``` +# Pipelit config (no real keys!) +LLM_BASE_URL=http://gateway:8080/api/v1/llm +LLM_API_KEY=gw-tok-aaa # gateway token, not an LLM key + +# OpenCode config (no real keys!) +providerID: "custom" +baseURL: "http://gateway:8080/api/v1/llm" +apiKey: gw-tok-bbb # gateway token +``` + +This is seamless — LangChain, LiteLLM, OpenCode, any OpenAI-compatible client works without code changes. They just point at the gateway URL. + +Benefits of LLM proxy: +- **Zero key exposure** — real LLM keys never exist in backend processes, agent sandboxes, or env files +- **Usage tracking** — gateway sees every LLM call, tracks tokens/cost per user +- **Budget enforcement** — per-user/per-identity spending limits +- **Model access control** — kid's token gets Haiku only, dad's gets Opus +- **Provider switching** — change Anthropic → OpenAI without touching backend config +- **Rate limiting** — centralized across all backends + +#### Common Gateway Behavior + +For all API surfaces, the gateway: +1. Validates the caller's gateway token +2. Resolves token → identity +3. Checks identity permissions (integration access, LLM model access) +4. Looks up the real credential from encrypted store +5. Performs the action (MCP tool call, LLM proxy) +6. Returns the result — real credentials never in the response +7. Logs usage per identity + +### Gateway Identity Model (Backend-Agnostic) + +The gateway has its own token-based identity. It doesn't know about Pipelit users, OpenCode sessions, or any backend's user model. Backends are just clients. + +``` +Gateway Token Registry (in Dragonfly): + gw-tok-aaa → identity: "dad" + owns: [slack-dad, gmail-dad, llm-anthropic] + llm_models: [claude-sonnet-4-*, claude-opus-4-*] + budget: $50/month + + gw-tok-bbb → identity: "kid" + owns: [slack-kid] + llm_models: [claude-haiku-*] + budget: $5/month + + gw-tok-ccc → identity: "opencode-session" + owns: [github-work] + llm_models: [claude-sonnet-4-*] + + gw-tok-admin → identity: "admin" + owns: * (all credentials) + admin: true +``` + +Backends map their users to gateway tokens — how they do it is their business: + +``` +Pipelit: UserProfile.gateway_token = "gw-tok-aaa" (dad) +OpenCode: session config → gateway_token = "gw-tok-ccc" +plit CLI: ~/.config/plit/auth.json → gateway_token = "gw-tok-admin" +``` + +Permission check on every capability/LLM call: +``` +1. Resolve gateway token → identity +2. Does identity own this credential? (integration calls) +3. Does identity have access to this model? (LLM calls) +4. Is identity within budget? (cost enforcement) +→ Allow or deny. No knowledge of which backend called. +``` + +### Credential Administration + +Only admin tokens can manage credentials: + +``` +POST /admin/credentials — store new credential (encrypted) +GET /admin/credentials — list (metadata only, no secret values) +DELETE /admin/credentials/{id} — revoke +PATCH /admin/credentials/{id} — update (e.g., rotate token) +POST /admin/credentials/{id}/test — verify credential works +POST /admin/identities — create gateway identity + token +GET /admin/identities — list identities + their permissions +PATCH /admin/identities/{id} — update permissions, budget, model access +``` + +--- + +## Design Decisions + +### Decision 1: MCP Server Instance Model → 1D (1:1 default, opt-in shared) + +Default is one MCP server instance per credential. MCP servers that natively support multi-credential (email-mcp, dbhub) can opt into shared instances. + +```json +{ + "mcp_servers": { + "slack-workspace-a": { + "package": "korotovsky/slack-mcp-server", + "credential": "slack-a", + "mode": "single" + }, + "email": { + "package": "codefuturist/email-mcp", + "credentials": ["gmail-personal", "gmail-work"], + "mode": "multi" + } + } +} +``` + +### Decision 2: Adapter ↔ MCP Relationship → 2D (Generic binary + custom) + +One generic MCP adapter binary that works with any MCP server via config. Custom adapters only for protocols that need persistent connections (Telegram grammy). + +``` +Gateway spawns: + generic-mcp-adapter (config: slack-mcp, poll 30s, send via send_message) + generic-mcp-adapter (config: email-mcp, poll 60s, send via send_email) + telegram-adapter (custom, grammy — if needed later) +``` + +The generic adapter: +- Owns the poll loop (configurable interval) +- Talks to MCP server for polling + sending +- Tracks cursor/offset per source +- Normalizes MCP responses → gateway inbound protocol +- Receives /send → calls MCP send tool + +### Decision 3: Credential Storage → Dragonfly + Fernet (unified config + secrets) + +Two namespaces in the same Dragonfly instance: + +``` +Config (not encrypted, fast reads, hot-reload): + config:slack-a:poll_interval → 30 + config:slack-a:normalize_map → {...} + config:mcp:servers → {...} + +Secrets (Fernet encrypted, read at spawn time): + secret:slack-a:token → encrypted("xoxb-...") + secret:gmail:oauth → encrypted({access_token, refresh_token, expiry}) + secret:postgres:dsn → encrypted("postgres://...") +``` + +- Fernet encryption for secret values (existing infrastructure) +- Redis ACLs for basic RBAC (admin, gateway-internal, read-only) +- Dragonfly persistence (RDB) survives restarts +- Keyspace notifications for config hot-reload +- No unseal ceremony — gateway decrypts on read with FIELD_ENCRYPTION_KEY +- redis/mcp-redis available for agent config access (config namespace only, not secrets) + +**Secrets never leave the gateway process.** The gateway reads encrypted secrets from Dragonfly, decrypts in memory, injects as env vars when spawning MCP servers. No external process or API exposes plaintext credentials. + +### Decision 4: Credential Setup UX → Schema-driven (4D) + Agent + CLI + +Each MCP server integration publishes a credential schema: + +```json +{ + "integration": "slack", + "credential_schema": { + "type": "token", + "fields": { + "bot_token": { + "type": "string", + "secret": true, + "description": "Slack bot token (xoxb-...)", + "setup_url": "https://api.slack.com/apps" + } + } + } +} +``` + +For OAuth integrations: +```json +{ + "integration": "gmail", + "credential_schema": { + "type": "oauth2", + "provider": "google", + "scopes": ["gmail.readonly", "gmail.send"], + "setup_url": "https://console.cloud.google.com" + } +} +``` + +Any frontend renders from this schema: +- **CLI**: `plit credentials create --type slack` → prompts for fields from schema +- **Web UI**: Platform credential page → renders form from schema +- **Agent**: Reads schema, asks user conversationally, stores via admin API +- **TUI**: Renders form in terminal from schema + +### Decision 5: Adapter Refactoring → 5A (Clean break) + +Remove the current custom adapter protocol. Replace with: +- MCP server hosting (gateway spawns and manages MCP server processes) +- Generic MCP adapter (poll loop + MCP client + gateway inbound bridge) +- Gateway capability API (agents call integrations through gateway, not directly) + +Telegram adapter is not needed currently — TUI talks to Pipelit chat directly. + +--- + +## How Pipelit Agents Use Integrations + +### Current Model (secrets exposed) + +```python +# Agent has platform_api tool +# Agent reads credential, gets plaintext token +# Token is in LLM context — leak risk + +@tool +def send_slack_message(channel, text): + token = get_credential("slack-a") # plaintext token in agent memory + requests.post("https://slack.com/api/chat.postMessage", + headers={"Authorization": f"Bearer {token}"}, + json={"channel": channel, "text": text}) +``` + +### New Model (capabilities only) + +```python +# Agent has gateway_integration tool +# Agent requests action, gateway executes with credential internally +# No token in LLM context + +@tool +def gateway_call(integration, tool_name, params): + """Call an integration tool through the gateway.""" + response = requests.post( + f"{GATEWAY_URL}/api/v1/integrations/{integration}/tools/{tool_name}", + headers={"Authorization": f"Bearer {AGENT_GATEWAY_TOKEN}"}, + json=params + ) + return response.json() + +# Agent calls: +gateway_call("slack", "send_message", {"channel": "#general", "text": "hello"}) +# Agent never sees the Slack token +``` + +### Pipelit Node Types for Integrations + +Integration nodes in workflows call the gateway capability API: + +| Node | What it does | Gateway call | +|---|---|---| +| `trigger_slack` | Inbound messages | Adapter polls MCP via gateway | +| `send_slack` | Send message | `POST /integrations/slack/tools/send_message` | +| `query_postgres` | Run SQL | `POST /integrations/postgres/tools/execute_sql` | +| `send_email` | Send email | `POST /integrations/gmail/tools/send_email` | + +All are thin wrappers around `gateway_call()`. No credentials in the workflow engine. + +--- + +## Integration Lifecycle + +### Adding a New Integration + +``` +1. plit mcp add slack + → Downloads korotovsky/slack-mcp-server + → Prompts for credential (reads schema, asks for bot token) + → Stores encrypted credential in Dragonfly + → Registers MCP server config in Dragonfly + → Spawns MCP server process + → Creates generic adapter with poll config + → Gateway starts serving slack capabilities + +2. Agent discovers: + GET /api/v1/integrations/ + → [{ name: "slack", tools: ["send_message", "conversations_history", ...] }] + +3. Agent uses: + POST /api/v1/integrations/slack/tools/send_message + → Gateway routes to MCP server → Slack API → result +``` + +### Credential Rotation + +``` +1. plit credentials rotate slack-a + → Prompts for new token + → Encrypts and stores in Dragonfly + → Restarts MCP server with new credential + → Zero downtime for other integrations +``` + +### OAuth Flow (Gmail, Microsoft 365) + +``` +1. plit credentials create --type gmail + → Opens browser for Google OAuth consent + → Captures authorization code + → Exchanges for access_token + refresh_token + → Encrypts and stores in Dragonfly + → Gateway manages token refresh lifecycle + → MCP server always has a valid token +``` + +--- + +## Implementation Phases + +### Phase 1: Gateway Credential Store +- Dragonfly namespaces (config: / secret:) with Fernet encryption +- Admin credential CRUD API +- Redis ACLs for namespace isolation +- `plit credentials` CLI updates + +### Phase 2: MCP Server Hosting +- Gateway spawns and manages MCP server processes +- Health monitoring, restart on failure +- Credential injection via env vars at spawn time +- `plit mcp add/remove/list` CLI commands + +### Phase 3: Generic MCP Adapter +- Poll loop with configurable interval +- MCP client for tool calls +- Cursor tracking +- Normalization config (MCP response → gateway inbound format) +- Adapter process management + +### Phase 4: Capability API +- `/api/v1/integrations/{integration}/tools/{tool}` endpoint +- Role-based permission checking +- Route to appropriate MCP server +- Integration discovery endpoint +- Pipelit agent tool: `gateway_call()` + +### Phase 5: Credential Schema System +- Schema format for describing credential requirements +- CLI, web UI, agent, and TUI all render from schema +- OAuth flow handling for Google, Microsoft, etc. + +--- + +## Architecture Diagram (Revised) + +``` +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ EXTERNAL WORLD │ +│ Telegram Slack Discord Gmail Postgres GitHub REST/Webhook MCP Clients │ +└──────┬───────┬──────┬────────┬──────┬────────┬────────┬──────────────┬───────────┘ + │ │ │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ████████████████████████████████████████ │ +│ █ PLIT GATEWAY (Trust Boundary) █ │ +│ ████████████████████████████████████████ │ +│ │ +│ ┌─ Credential + Config Store (Dragonfly + Fernet) ───────────────────────────┐ │ +│ │ │ │ +│ │ secret:slack-a:token ➜ fernet_encrypted (read at spawn time) │ │ +│ │ secret:gmail:oauth ➜ fernet_encrypted (access + refresh token) │ │ +│ │ secret:postgres:dsn ➜ fernet_encrypted │ │ +│ │ config:slack-a:poll_intv ➜ 30 (hot-reload via keyspace)│ │ +│ │ config:slack-a:normalize ➜ {...} (normalization mapping) │ │ +│ │ config:mcp:servers ➜ {...} (registered servers) │ │ +│ │ │ │ +│ │ Redis ACLs: admin (rw all), gateway (rw), pipelit (r config), agent (r) │ │ +│ │ 🔒 Secrets NEVER leave this boundary │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ OAuth Refresh Manager ────────────────────────────────────────────────────┐ │ +│ │ Watches: secret:*:oauth.expiry │ │ +│ │ When expiry < 5 min: │ │ +│ │ 1. Use refresh_token → call provider token endpoint │ │ +│ │ 2. Encrypt + update Dragonfly │ │ +│ │ 3. Re-inject into running MCP server (or restart) │ │ +│ │ Required for: Gmail, Microsoft 365, Slack OAuth │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ MCP Server Processes (spawned + managed by gateway) ──────────────────────┐ │ +│ │ │ │ +│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ ┌─────────────┐ │ │ +│ │ │ Slack MCP │ │ Email MCP │ │ Discord MCP │ │ Postgres MCP│ │ │ +│ │ │ (workspace A) │ │ (multi-account)│ │ (bot A) │ │ (dbhub) │ │ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ │ │ Token injected │ │ Config injected│ │ Token injected │ │ DSN injected│ │ │ +│ │ │ as env var │ │ as TOML file │ │ as env var │ │ as TOML │ │ │ +│ │ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ └──────┬──────┘ │ │ +│ │ │ Slack API │ IMAP/SMTP │ Discord API │ PG │ │ +│ │ ▼ ▼ ▼ ▼ │ │ +│ │ (credentials used here, inside the boundary, never exposed) │ │ +│ │ │ │ +│ │ Transport: stdio servers ←→ MCP Bridge (stdio↔HTTP) ←→ internal routing │ │ +│ │ HTTP servers ←→ internal routing directly │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Generic MCP Adapters (bilateral normalization) ───────────────────────────┐ │ +│ │ │ │ +│ │ Each adapter is a generic binary configured per integration. │ │ +│ │ Same binary, different config. Bilateral: normalizes inbound + outbound. │ │ +│ │ │ │ +│ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ +│ │ │ Slack Adapter │ │ Email Adapter │ │ Discord Adapter │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ INBOUND: │ │ INBOUND: │ │ INBOUND: │ │ │ +│ │ │ poll: 30s │ │ poll: 60s │ │ poll: 30s │ │ │ +│ │ │ tool: convos_hist│ │ tool: list_emails│ │ tool: read_msgs │ │ │ +│ │ │ cursor: date_aftr│ │ cursor: since │ │ cursor: minId │ │ │ +│ │ │ normalize → Inbnd│ │ normalize → Inbnd│ │ normalize → Inb.│ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ OUTBOUND: │ │ OUTBOUND: │ │ OUTBOUND: │ │ │ +│ │ │ std send request │ │ std send request │ │ std send request│ │ │ +│ │ │ → add_message() │ │ → send_email() │ │ → discord_send()│ │ │ +│ │ │ normalize params │ │ normalize params │ │ normalize params│ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ talks to ↕ │ │ talks to ↕ │ │ talks to ↕ │ │ │ +│ │ │ Slack MCP server │ │ Email MCP server │ │ Discord MCP srv │ │ │ +│ │ └────────┬──────────┘ └────────┬──────────┘ └────────┬────────┘ │ │ +│ │ │ │ │ │ │ +│ └───────────┼──────────────────────┼──────────────────────┼──────────────────┘ │ +│ │ inbound msgs │ inbound emails │ inbound msgs │ +│ ▼ ▼ ▼ │ +│ ┌─ Gateway Core Pipeline ────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Inbound: adapter → guardrails (CEL, both directions) → route to backend│ │ +│ │ Outbound: capability API → guardrails (CEL) → adapter → MCP server │ │ +│ │ │ │ +│ └──────────────────────────┬─────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─ Capability API ────────┼──────────────────────────────────────────────────┐ │ +│ │ │ │ │ +│ │ POST /api/v1/integrations/{integration}/tools/{tool} │ │ +│ │ 1. Validate caller token │ │ +│ │ 2. Check role/permission (RBAC) │ │ +│ │ 3. Route to adapter (outbound normalization → MCP server) │ │ +│ │ 4. Return result (no secrets in response) │ │ +│ │ │ │ +│ │ GET /api/v1/integrations/ │ │ +│ │ → List available integrations + tools (agent discovery) │ │ +│ │ → Auto-syncs with Pipelit node catalog on change │ │ +│ │ │ │ +│ └──────────────────────────┼─────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─ Response Cache (Dragonfly, TTL-based) ────────────────────────────────────┐ │ +│ │ Read-heavy tools: cache 5-30s (conversations_history, list_emails) │ │ +│ │ Write tools: never cache (send_message, send_email) │ │ +│ │ Protects against rate limits when multiple agents query same integration │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─ Health Monitor ────────┼──────────────────────────────────────────────────┐ │ +│ │ Per MCP server: ping interval, failure count, state machine │ │ +│ │ States: healthy → degraded → down → recovering │ │ +│ │ Publishes: integration health status for TUI/UI consumption │ │ +│ │ Alerts: notify admin credentials on integration failure │ │ +│ └──────────────────────────┼─────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─ Backend Routing ────────┼─────────────────────────────────────────────────┐ │ +│ │ ┌─────────┐ ┌────▼────┐ ┌──────────┐ │ │ +│ │ │ Pipelit │ │ OpenCode│ │ Future │ │ │ +│ │ │(webhook)│ │(REST+SSE│ │ backends │ │ │ +│ │ └────┬────┘ └────┬────┘ └──────────┘ │ │ +│ └───────┼─────────────────┼──────────────────────────────────────────────────┘ │ +│ │ │ │ +│ ┌─ Admin API ─────────────┼──────────────────────────────────────────────────┐ │ +│ │ POST /admin/credentials (store new, encrypted) │ │ +│ │ POST /admin/mcp/add (install MCP server package) │ │ +│ │ GET /admin/mcp/list (list running MCP servers + health) │ │ +│ │ POST /admin/mcp/{id}/restart (restart MCP server) │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────┬─────────────────┬─────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────┐ ┌──────────────────┐ +│ PIPELIT │ │ OPENCODE │ +│ PLATFORM │ │ SERVER │ +│ │ │ │ +│ ┌────────────┐ │ │ AI-assisted │ +│ │ Workflow │ │ │ code editing │ +│ │ Engine │ │ │ │ +│ │ │ │ └──────────────────┘ +│ │ Nodes call │ │ +│ │ gateway │ │ +│ │ capability │ │ +│ │ API — never│ │ +│ │ hold tokens│ │ +│ └──────┬─────┘ │ +│ │ │ +│ ┌──────▼─────┐ │ +│ │ Agent │ │ +│ │ Backends │ │ +│ │ │ │ +│ │ LangGraph │ │ +│ │ (current) │ │ +│ │ CC (future)│ │ +│ └────────────┘ │ +│ │ +│ ┌────────────┐ │ +│ │ Node │ │ +│ │ Catalog │ │ +│ │ │ │ +│ │ Auto-syncs │ │ +│ │ from GW │ │ +│ │ integration│ │ +│ │ list │ │ +│ └────────────┘ │ +│ │ +└────────┬─────────┘ + │ + │ API + WebSocket + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ USER INTERFACES │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Tela Situation Room │ │ +│ │ │ │ +│ │ ┌─ Graph ──────────────────┐ ┌─ Status ────────────────┐ │ │ +│ │ │ live DAG visualization │ │ Workflow: idle │ │ │ +│ │ │ algorithmic layout │ │ │ │ │ +│ │ │ updates via WebSocket │ │ Integrations: │ │ │ +│ │ └──────────────────────────┘ │ ✅ slack (3s ago) │ │ │ +│ │ ┌─ Chat ──────────────────────│ ✅ postgres (1s ago) │ │ │ +│ │ │ "send hello to #general" ││ ❌ gmail (token exp.) │ │ │ +│ │ │ Agent: gateway_call(...) ││ ⚠️ discord (slow) │ │ │ +│ │ │ → gateway handles it │└──────────────────────────┘ │ │ +│ │ │ Agent never sees token │ │ │ +│ │ └────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ PLIT CLI │ │ +│ │ plit mcp add slack Install + configure integration │ │ +│ │ plit credentials create Store encrypted credential │ │ +│ │ plit mcp list Running integrations + health │ │ +│ │ plit start / stop Full stack lifecycle │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ React SPA (maintained for casual users) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Key Data Flows + +**Inbound (trigger — new Slack message triggers workflow):** +``` +Slack API ← Slack MCP server ← Adapter (poll, normalize) → Guardrails (CEL) + → Gateway inbound pipeline → Pipelit backend → Workflow trigger +``` + +**Outbound (workflow sends Slack message):** +``` +Workflow node → gateway_call("slack", "send_message", {channel, text}) + → Capability API → RBAC check → Guardrails (CEL, outbound) + → Adapter (normalize std request → MCP tool params) + → Slack MCP server → Slack API → result +``` + +**Agent mid-workflow tool call:** +``` +Agent reasoning → gateway_call("postgres", "execute_sql", {query}) + → Capability API → RBAC check → Adapter (normalize) → Postgres MCP → PG → result + (no DSN in agent context, ever) +``` + +**OAuth token refresh (automatic, background):** +``` +OAuth Refresh Manager watches secret:gmail:oauth.expiry + → expiry < 5 min → use refresh_token to call Google token endpoint + → encrypt new access_token → update Dragonfly + → re-inject into Email MCP server (restart or env update) +``` + +**Integration health monitoring:** +``` +Health Monitor pings each MCP server on interval + → healthy / degraded / down state machine + → publishes status → TUI status panel shows integration health + → on failure → alerts admin credentials +``` + +**Node catalog sync (new integration → available in palette):** +``` +plit mcp add slack → Gateway registers Slack MCP server + → Gateway notifies Pipelit: POST /api/v1/node-types/sync + → Pipelit registers slack:send_message, slack:conversations_history, etc. + → Available in node palette and workflow_discover results +``` + +--- + +## Architectural Notes + +### Adapter as Bilateral Normalization Layer + +The adapter is NOT just an inbound poller. It normalizes in both directions: + +**Inbound:** MCP server returns Slack-specific JSON → adapter normalizes to gateway's standardized `InboundMessage` format. + +**Outbound:** Capability API sends a standardized "send message" request → adapter translates to the specific MCP tool's parameter format. + +Without the adapter on outbound, the capability API would need integration-specific code for every MCP server's tool signature. The adapter absorbs this complexity via normalization config: + +```json +{ + "normalize_in": { + "text": "$.text", + "chat_id": "$.channel", + "message_id": "$.ts", + "from.name": "$.user" + }, + "normalize_out": { + "channel_id": "$.chat_id", + "payload": "$.text", + "content_type": "text" + }, + "poll_tool": "conversations_history", + "send_tool": "conversations_add_message", + "poll_interval": "30s", + "cursor_field": "filter_date_after" +} +``` + +This keeps the generic adapter binary truly generic — same code, different config per integration. + +### MCP Transport Bridge + +Most MCP servers default to stdio (JSON-RPC over stdin/stdout). This is single-threaded and one-request-at-a-time. The gateway needs concurrent access. + +**Solution:** The gateway includes an MCP transport bridge: +- For stdio-only servers: gateway spawns process, bridges stdio ↔ internal HTTP, queues concurrent requests +- For HTTP-capable servers: connect directly, no bridge needed + +``` +MCP server (stdio only) ←stdio→ [Gateway MCP Bridge] ←→ internal routing +MCP server (HTTP native) ←HTTP→ internal routing directly +``` + +Server capability is detected at spawn time from the MCP server's manifest or transport config. + +### Response Cache + +Multiple agents or workflows querying the same integration (e.g., "what's in #general?") would result in duplicate external API calls, risking rate limits. + +**Solution:** TTL-based response cache in Dragonfly: +- **Read tools** (conversations_history, list_emails, execute_sql): cache 5-30 seconds, configurable per tool +- **Write tools** (send_message, send_email): never cached +- **Cache key**: `cache:{integration}:{tool}:{hash(params)}` +- Cache invalidated on outbound write to same integration + +### Integration Health in Status Panel + +The Tela situation room surfaces integration health alongside workflow status: + +``` +Integrations: + ✅ slack — last poll 3s ago, 0 errors + ✅ postgres — last query 1s ago, 0 errors + ❌ gmail — token expired, refresh failed 2 min ago + ⚠️ discord — responding slowly (avg 2.1s, threshold 1s) +``` + +Health data published via WebSocket alongside existing workflow execution events. + +--- + +## Open Questions + +1. **MCP server packaging**: How are MCP servers distributed and installed? npm packages? Docker images? Binary downloads? `plit mcp add` needs to know how to fetch and run them. May need a registry or manifest format. + +2. **Integration discovery protocol**: Should the gateway expose available integrations as MCP tools themselves? That would let agents discover capabilities via the same MCP protocol they use for everything else — a meta-MCP layer. + +3. **Rate limiting strategy**: If multiple agents call the same Slack integration, who manages rate limits? Options: gateway-level rate limiter (centralized, per-integration), MCP server handles it (inconsistent across servers), or adapter manages it (per-adapter config). + +4. **Multi-tenant credential isolation**: In SaaS, different users own different credentials. The RBAC needs to scope not just by integration but by user/org. This requires extending the capability API's permission model beyond flat roles. + +5. **Adapter process model**: The generic adapter binary adds processes. Could the gateway run poll loops + normalization natively in Rust, eliminating adapter processes entirely? Trade-off: more gateway complexity vs fewer processes. For now, separate processes are simpler and more modular. + +6. **Node catalog sync mechanism**: Push (gateway notifies Pipelit on change) vs pull (Pipelit polls gateway's integration list)? Push is more responsive but requires gateway-to-Pipelit callback. Pull is simpler but has latency. diff --git a/docs/architecture/archive/gateway-mcp-design.v2-iterative.md b/docs/architecture/archive/gateway-mcp-design.v2-iterative.md new file mode 100644 index 00000000..0a53b617 --- /dev/null +++ b/docs/architecture/archive/gateway-mcp-design.v2-iterative.md @@ -0,0 +1,827 @@ +# Gateway Architecture — Final Design + +**Date:** 2026-03-22 +**Status:** Design complete +**Context:** Design session on MCP hosting, credential security, adapter refactoring, and agentgateway adoption +**Supersedes:** gateway-mcp-design.v1.md (iterative design notes) + +--- + +## Executive Summary + +The plit gateway stack splits into two components: + +- **agentgateway** (Linux Foundation, Rust, Apache 2.0) — owns ALL external credentials, LLM proxy, MCP server federation, RBAC enforcement via CEL policies. This is the **trust boundary**. No credential ever leaves this process. +- **plit-gw** (our Rust code) — identity provider (issues JWTs), adapter poll loops (inbound triggers), backend routing (Pipelit/OpenCode), message normalization. Thin orchestrator. + +Backends (Pipelit, OpenCode, future) hold ZERO real external keys. They authenticate to agentgateway with JWTs. An LLM agent cannot leak what doesn't exist in its environment. + +--- + +## Security Model: Gateway as Trust Boundary + +### The Core Principle + +**Credentials never leave agentgateway.** Agents and workflows interact with external services through agentgateway's MCP and LLM endpoints. They never hold tokens directly. This prevents LLM agents from accidentally leaking secrets in outputs, tool calls, error messages, or prompts sent to LLM providers. + +``` +┌─────────────────────────────────────────────────────────┐ +│ TRUST BOUNDARY │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ PLIT GATEWAY │ │ +│ │ │ │ +│ │ Credentials: never leave this boundary │ │ +│ │ MCP servers: run inside this boundary │ │ +│ │ Secrets: encrypted at rest, in memory at runtime │ │ +│ │ │ │ +│ │ External calls happen HERE: │ │ +│ │ Slack API ← MCP server ← gateway │ │ +│ │ Gmail API ← MCP server ← gateway │ │ +│ │ Discord ← MCP server ← gateway │ │ +│ │ Postgres ← MCP server ← gateway │ │ +│ │ │ │ +│ └──────────────────────┬────────────────────────────┘ │ +│ │ │ +│ Authenticated API only │ +│ (role-based, capabilities not secrets) │ +│ │ +└─────────────────────────┼────────────────────────────────┘ + │ + ┌───────────▼───────────┐ + │ Pipelit / Agents │ + │ │ + │ "send to #general" │ + │ "read my inbox" │ + │ "query postgres" │ + │ │ + │ Never sees tokens. │ + │ Just makes requests. │ + └───────────────────────┘ +``` + +### Why agentgateway, Not a Secrets Store (Vault/OpenBao)? + +Vault/OpenBao are **secrets delivery services** — they hand out plaintext secrets to authorized clients. The secret leaves the boundary. An agent that reads a Slack token from Vault has that token in its LLM context and could leak it. + +agentgateway is a **secrets usage service** — like Fireblocks for integrations. It holds credentials and performs operations on behalf of callers. Callers get results, never tokens. Credentials never leave the process. + +| | Secrets delivery (Vault) | Secrets usage (agentgateway) | +|---|---|---| +| Agent gets | The actual token | A capability to act | +| LLM context contains | `xoxb-1234-secret...` | `"you can send Slack messages"` | +| If agent leaks output | Token is exposed | Nothing sensitive | +| Revocation | Rotate the leaked token | Revoke the agent's JWT | + +### What Exists in Each Process + +``` +Backend processes (Pipelit, OpenCode, agents, sandboxes): + ✅ JWTs issued by plit-gw (identify the user/agent) + ✅ agentgateway URL + ❌ No LLM API keys + ❌ No Slack/Discord/Gmail tokens + ❌ No database connection strings + ❌ No OAuth tokens + → An LLM/agent cannot leak what doesn't exist in its environment + +agentgateway process (trust boundary): + 🔒 All LLM API keys (Anthropic, OpenAI, Ollama, etc.) + 🔒 All integration tokens (Slack, Discord, Gmail, etc.) + 🔒 All database credentials + 🔒 All OAuth tokens + refresh tokens + 🔒 Managed in agentgateway's own config/credential store + 🔒 Never exposed via any API response +``` + +### Gateway APIs + +The gateway exposes three API surfaces — **capability API**, **LLM proxy**, and **admin API**: + +#### Integration Capability API + +Exposes **actions**, not credentials: + +``` +POST /api/v1/integrations/{integration}/tools/{tool} + Authorization: Bearer + Body: { ...tool parameters... } + +Examples: + +POST /api/v1/integrations/slack/tools/send_message + Body: { channel: "#general", text: "hello" } + +GET /api/v1/integrations/slack/tools/conversations_history + Body: { channel: "#general", limit: 10 } + +POST /api/v1/integrations/gmail/tools/send_email + Body: { to: "user@example.com", subject: "hi", body: "..." } + +POST /api/v1/integrations/postgres/tools/execute_sql + Body: { source_id: "production", query: "SELECT * FROM users LIMIT 10" } +``` + +#### LLM Proxy (OpenAI-Compatible) + +Backends point their LLM clients at the gateway instead of real providers: + +``` +POST /api/v1/llm/v1/chat/completions + Authorization: Bearer + Body: { model: "claude-sonnet-4-20250514", messages: [...] } + +Gateway: + 1. Resolve gateway token → identity + 2. Check: does identity have llm access? Which models? + 3. Look up real LLM API key from encrypted credential store + 4. Proxy request to real provider (Anthropic, OpenAI, Ollama) + 5. Return response + 6. Log usage (tokens, cost) per identity +``` + +Backend configuration becomes trivial — no real API keys: +``` +# Pipelit config (no real keys!) +LLM_BASE_URL=http://gateway:8080/api/v1/llm +LLM_API_KEY=gw-tok-aaa # gateway token, not an LLM key + +# OpenCode config (no real keys!) +providerID: "custom" +baseURL: "http://gateway:8080/api/v1/llm" +apiKey: gw-tok-bbb # gateway token +``` + +This is seamless — LangChain, LiteLLM, OpenCode, any OpenAI-compatible client works without code changes. They just point at the gateway URL. + +Benefits of LLM proxy: +- **Zero key exposure** — real LLM keys never exist in backend processes, agent sandboxes, or env files +- **Usage tracking** — gateway sees every LLM call, tracks tokens/cost per user +- **Budget enforcement** — per-user/per-identity spending limits +- **Model access control** — kid's token gets Haiku only, dad's gets Opus +- **Provider switching** — change Anthropic → OpenAI without touching backend config +- **Rate limiting** — centralized across all backends + +#### Common Gateway Behavior + +For all API surfaces, the gateway: +1. Validates the caller's gateway token +2. Resolves token → identity +3. Checks identity permissions (integration access, LLM model access) +4. Looks up the real credential from encrypted store +5. Performs the action (MCP tool call, LLM proxy) +6. Returns the result — real credentials never in the response +7. Logs usage per identity + +### Gateway Identity Model (Backend-Agnostic) + +The gateway has its own token-based identity. It doesn't know about Pipelit users, OpenCode sessions, or any backend's user model. Backends are just clients. + +``` +Gateway Token Registry (in Dragonfly): + gw-tok-aaa → identity: "dad" + owns: [slack-dad, gmail-dad, llm-anthropic] + llm_models: [claude-sonnet-4-*, claude-opus-4-*] + budget: $50/month + + gw-tok-bbb → identity: "kid" + owns: [slack-kid] + llm_models: [claude-haiku-*] + budget: $5/month + + gw-tok-ccc → identity: "opencode-session" + owns: [github-work] + llm_models: [claude-sonnet-4-*] + + gw-tok-admin → identity: "admin" + owns: * (all credentials) + admin: true +``` + +Backends map their users to gateway tokens — how they do it is their business: + +``` +Pipelit: UserProfile.gateway_token = "gw-tok-aaa" (dad) +OpenCode: session config → gateway_token = "gw-tok-ccc" +plit CLI: ~/.config/plit/auth.json → gateway_token = "gw-tok-admin" +``` + +Permission check on every capability/LLM call: +``` +1. Resolve gateway token → identity +2. Does identity own this credential? (integration calls) +3. Does identity have access to this model? (LLM calls) +4. Is identity within budget? (cost enforcement) +→ Allow or deny. No knowledge of which backend called. +``` + +### Credential Administration + +Only admin tokens can manage credentials: + +``` +POST /admin/credentials — store new credential (encrypted) +GET /admin/credentials — list (metadata only, no secret values) +DELETE /admin/credentials/{id} — revoke +PATCH /admin/credentials/{id} — update (e.g., rotate token) +POST /admin/credentials/{id}/test — verify credential works +POST /admin/identities — create gateway identity + token +GET /admin/identities — list identities + their permissions +PATCH /admin/identities/{id} — update permissions, budget, model access +``` + +--- + +## Design Decisions + +### Decision 1: MCP Server Instance Model → 1D (1:1 default, opt-in shared) + +Default is one MCP server instance per credential. MCP servers that natively support multi-credential (email-mcp, dbhub) can opt into shared instances. + +```json +{ + "mcp_servers": { + "slack-workspace-a": { + "package": "korotovsky/slack-mcp-server", + "credential": "slack-a", + "mode": "single" + }, + "email": { + "package": "codefuturist/email-mcp", + "credentials": ["gmail-personal", "gmail-work"], + "mode": "multi" + } + } +} +``` + +### Decision 2: Adapter ↔ MCP Relationship → 2D (Generic binary + custom) + +One generic MCP adapter binary that works with any MCP server via config. Custom adapters only for protocols that need persistent connections (Telegram grammy). + +``` +Gateway spawns: + generic-mcp-adapter (config: slack-mcp, poll 30s, send via send_message) + generic-mcp-adapter (config: email-mcp, poll 60s, send via send_email) + telegram-adapter (custom, grammy — if needed later) +``` + +The generic adapter: +- Owns the poll loop (configurable interval) +- Talks to MCP server for polling + sending +- Tracks cursor/offset per source +- Normalizes MCP responses → gateway inbound protocol +- Receives /send → calls MCP send tool + +### Decision 3: Credential Storage → Dragonfly + Fernet (unified config + secrets) + +Two namespaces in the same Dragonfly instance: + +``` +Config (not encrypted, fast reads, hot-reload): + config:slack-a:poll_interval → 30 + config:slack-a:normalize_map → {...} + config:mcp:servers → {...} + +Secrets (Fernet encrypted, read at spawn time): + secret:slack-a:token → encrypted("xoxb-...") + secret:gmail:oauth → encrypted({access_token, refresh_token, expiry}) + secret:postgres:dsn → encrypted("postgres://...") +``` + +- Fernet encryption for secret values (existing infrastructure) +- Redis ACLs for basic RBAC (admin, gateway-internal, read-only) +- Dragonfly persistence (RDB) survives restarts +- Keyspace notifications for config hot-reload +- No unseal ceremony — gateway decrypts on read with FIELD_ENCRYPTION_KEY +- redis/mcp-redis available for agent config access (config namespace only, not secrets) + +**Secrets never leave the gateway process.** The gateway reads encrypted secrets from Dragonfly, decrypts in memory, injects as env vars when spawning MCP servers. No external process or API exposes plaintext credentials. + +### Decision 4: Credential Setup UX → Schema-driven (4D) + Agent + CLI + +Each MCP server integration publishes a credential schema: + +```json +{ + "integration": "slack", + "credential_schema": { + "type": "token", + "fields": { + "bot_token": { + "type": "string", + "secret": true, + "description": "Slack bot token (xoxb-...)", + "setup_url": "https://api.slack.com/apps" + } + } + } +} +``` + +For OAuth integrations: +```json +{ + "integration": "gmail", + "credential_schema": { + "type": "oauth2", + "provider": "google", + "scopes": ["gmail.readonly", "gmail.send"], + "setup_url": "https://console.cloud.google.com" + } +} +``` + +Any frontend renders from this schema: +- **CLI**: `plit credentials create --type slack` → prompts for fields from schema +- **Web UI**: Platform credential page → renders form from schema +- **Agent**: Reads schema, asks user conversationally, stores via admin API +- **TUI**: Renders form in terminal from schema + +### Decision 5: Adapter Refactoring → 5A (Clean break) + +Remove the current custom adapter protocol. Replace with: +- MCP server hosting (gateway spawns and manages MCP server processes) +- Generic MCP adapter (poll loop + MCP client + gateway inbound bridge) +- Gateway capability API (agents call integrations through gateway, not directly) + +Telegram adapter is not needed currently — TUI talks to Pipelit chat directly. + +--- + +## How Pipelit Agents Use Integrations + +### Current Model (secrets exposed) + +```python +# Agent has platform_api tool +# Agent reads credential, gets plaintext token +# Token is in LLM context — leak risk + +@tool +def send_slack_message(channel, text): + token = get_credential("slack-a") # plaintext token in agent memory + requests.post("https://slack.com/api/chat.postMessage", + headers={"Authorization": f"Bearer {token}"}, + json={"channel": channel, "text": text}) +``` + +### New Model (capabilities only) + +```python +# Agent has gateway_integration tool +# Agent requests action, gateway executes with credential internally +# No token in LLM context + +@tool +def gateway_call(integration, tool_name, params): + """Call an integration tool through the gateway.""" + response = requests.post( + f"{GATEWAY_URL}/api/v1/integrations/{integration}/tools/{tool_name}", + headers={"Authorization": f"Bearer {AGENT_GATEWAY_TOKEN}"}, + json=params + ) + return response.json() + +# Agent calls: +gateway_call("slack", "send_message", {"channel": "#general", "text": "hello"}) +# Agent never sees the Slack token +``` + +### Pipelit Node Types for Integrations + +Integration nodes in workflows call the gateway capability API: + +| Node | What it does | Gateway call | +|---|---|---| +| `trigger_slack` | Inbound messages | Adapter polls MCP via gateway | +| `send_slack` | Send message | `POST /integrations/slack/tools/send_message` | +| `query_postgres` | Run SQL | `POST /integrations/postgres/tools/execute_sql` | +| `send_email` | Send email | `POST /integrations/gmail/tools/send_email` | + +All are thin wrappers around `gateway_call()`. No credentials in the workflow engine. + +--- + +## Integration Lifecycle + +### Adding a New Integration + +``` +1. plit mcp add slack + → Downloads korotovsky/slack-mcp-server + → Prompts for credential (reads schema, asks for bot token) + → Stores encrypted credential in Dragonfly + → Registers MCP server config in Dragonfly + → Spawns MCP server process + → Creates generic adapter with poll config + → Gateway starts serving slack capabilities + +2. Agent discovers: + GET /api/v1/integrations/ + → [{ name: "slack", tools: ["send_message", "conversations_history", ...] }] + +3. Agent uses: + POST /api/v1/integrations/slack/tools/send_message + → Gateway routes to MCP server → Slack API → result +``` + +### Credential Rotation + +``` +1. plit credentials rotate slack-a + → Prompts for new token + → Encrypts and stores in Dragonfly + → Restarts MCP server with new credential + → Zero downtime for other integrations +``` + +### OAuth Flow (Gmail, Microsoft 365) + +``` +1. plit credentials create --type gmail + → Opens browser for Google OAuth consent + → Captures authorization code + → Exchanges for access_token + refresh_token + → Encrypts and stores in Dragonfly + → Gateway manages token refresh lifecycle + → MCP server always has a valid token +``` + +--- + +## Implementation Phases + +### Phase 1: Gateway Credential Store +- Dragonfly namespaces (config: / secret:) with Fernet encryption +- Admin credential CRUD API +- Redis ACLs for namespace isolation +- `plit credentials` CLI updates + +### Phase 2: MCP Server Hosting +- Gateway spawns and manages MCP server processes +- Health monitoring, restart on failure +- Credential injection via env vars at spawn time +- `plit mcp add/remove/list` CLI commands + +### Phase 3: Generic MCP Adapter +- Poll loop with configurable interval +- MCP client for tool calls +- Cursor tracking +- Normalization config (MCP response → gateway inbound format) +- Adapter process management + +### Phase 4: Capability API +- `/api/v1/integrations/{integration}/tools/{tool}` endpoint +- Role-based permission checking +- Route to appropriate MCP server +- Integration discovery endpoint +- Pipelit agent tool: `gateway_call()` + +### Phase 5: Credential Schema System +- Schema format for describing credential requirements +- CLI, web UI, agent, and TUI all render from schema +- OAuth flow handling for Google, Microsoft, etc. + +--- + +## Architecture Diagram (Revised) + +``` +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ EXTERNAL WORLD │ +│ Telegram Slack Discord Gmail Postgres GitHub REST/Webhook MCP Clients │ +└──────┬───────┬──────┬────────┬──────┬────────┬────────┬──────────────┬───────────┘ + │ │ │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ████████████████████████████████████████ │ +│ █ PLIT GATEWAY (Trust Boundary) █ │ +│ ████████████████████████████████████████ │ +│ │ +│ ┌─ Credential + Config Store (Dragonfly + Fernet) ───────────────────────────┐ │ +│ │ │ │ +│ │ secret:slack-a:token ➜ fernet_encrypted (read at spawn time) │ │ +│ │ secret:gmail:oauth ➜ fernet_encrypted (access + refresh token) │ │ +│ │ secret:postgres:dsn ➜ fernet_encrypted │ │ +│ │ config:slack-a:poll_intv ➜ 30 (hot-reload via keyspace)│ │ +│ │ config:slack-a:normalize ➜ {...} (normalization mapping) │ │ +│ │ config:mcp:servers ➜ {...} (registered servers) │ │ +│ │ │ │ +│ │ Redis ACLs: admin (rw all), gateway (rw), pipelit (r config), agent (r) │ │ +│ │ 🔒 Secrets NEVER leave this boundary │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ OAuth Refresh Manager ────────────────────────────────────────────────────┐ │ +│ │ Watches: secret:*:oauth.expiry │ │ +│ │ When expiry < 5 min: │ │ +│ │ 1. Use refresh_token → call provider token endpoint │ │ +│ │ 2. Encrypt + update Dragonfly │ │ +│ │ 3. Re-inject into running MCP server (or restart) │ │ +│ │ Required for: Gmail, Microsoft 365, Slack OAuth │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ MCP Server Processes (spawned + managed by gateway) ──────────────────────┐ │ +│ │ │ │ +│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ ┌─────────────┐ │ │ +│ │ │ Slack MCP │ │ Email MCP │ │ Discord MCP │ │ Postgres MCP│ │ │ +│ │ │ (workspace A) │ │ (multi-account)│ │ (bot A) │ │ (dbhub) │ │ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ │ │ Token injected │ │ Config injected│ │ Token injected │ │ DSN injected│ │ │ +│ │ │ as env var │ │ as TOML file │ │ as env var │ │ as TOML │ │ │ +│ │ └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ └──────┬──────┘ │ │ +│ │ │ Slack API │ IMAP/SMTP │ Discord API │ PG │ │ +│ │ ▼ ▼ ▼ ▼ │ │ +│ │ (credentials used here, inside the boundary, never exposed) │ │ +│ │ │ │ +│ │ Transport: stdio servers ←→ MCP Bridge (stdio↔HTTP) ←→ internal routing │ │ +│ │ HTTP servers ←→ internal routing directly │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Generic MCP Adapters (bilateral normalization) ───────────────────────────┐ │ +│ │ │ │ +│ │ Each adapter is a generic binary configured per integration. │ │ +│ │ Same binary, different config. Bilateral: normalizes inbound + outbound. │ │ +│ │ │ │ +│ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ +│ │ │ Slack Adapter │ │ Email Adapter │ │ Discord Adapter │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ INBOUND: │ │ INBOUND: │ │ INBOUND: │ │ │ +│ │ │ poll: 30s │ │ poll: 60s │ │ poll: 30s │ │ │ +│ │ │ tool: convos_hist│ │ tool: list_emails│ │ tool: read_msgs │ │ │ +│ │ │ cursor: date_aftr│ │ cursor: since │ │ cursor: minId │ │ │ +│ │ │ normalize → Inbnd│ │ normalize → Inbnd│ │ normalize → Inb.│ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ OUTBOUND: │ │ OUTBOUND: │ │ OUTBOUND: │ │ │ +│ │ │ std send request │ │ std send request │ │ std send request│ │ │ +│ │ │ → add_message() │ │ → send_email() │ │ → discord_send()│ │ │ +│ │ │ normalize params │ │ normalize params │ │ normalize params│ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ talks to ↕ │ │ talks to ↕ │ │ talks to ↕ │ │ │ +│ │ │ Slack MCP server │ │ Email MCP server │ │ Discord MCP srv │ │ │ +│ │ └────────┬──────────┘ └────────┬──────────┘ └────────┬────────┘ │ │ +│ │ │ │ │ │ │ +│ └───────────┼──────────────────────┼──────────────────────┼──────────────────┘ │ +│ │ inbound msgs │ inbound emails │ inbound msgs │ +│ ▼ ▼ ▼ │ +│ ┌─ Gateway Core Pipeline ────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ Inbound: adapter → guardrails (CEL, both directions) → route to backend│ │ +│ │ Outbound: capability API → guardrails (CEL) → adapter → MCP server │ │ +│ │ │ │ +│ └──────────────────────────┬─────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─ Capability API ────────┼──────────────────────────────────────────────────┐ │ +│ │ │ │ │ +│ │ POST /api/v1/integrations/{integration}/tools/{tool} │ │ +│ │ 1. Validate caller token │ │ +│ │ 2. Check role/permission (RBAC) │ │ +│ │ 3. Route to adapter (outbound normalization → MCP server) │ │ +│ │ 4. Return result (no secrets in response) │ │ +│ │ │ │ +│ │ GET /api/v1/integrations/ │ │ +│ │ → List available integrations + tools (agent discovery) │ │ +│ │ → Auto-syncs with Pipelit node catalog on change │ │ +│ │ │ │ +│ └──────────────────────────┼─────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─ Response Cache (Dragonfly, TTL-based) ────────────────────────────────────┐ │ +│ │ Read-heavy tools: cache 5-30s (conversations_history, list_emails) │ │ +│ │ Write tools: never cache (send_message, send_email) │ │ +│ │ Protects against rate limits when multiple agents query same integration │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─ Health Monitor ────────┼──────────────────────────────────────────────────┐ │ +│ │ Per MCP server: ping interval, failure count, state machine │ │ +│ │ States: healthy → degraded → down → recovering │ │ +│ │ Publishes: integration health status for TUI/UI consumption │ │ +│ │ Alerts: notify admin credentials on integration failure │ │ +│ └──────────────────────────┼─────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─ Backend Routing ────────┼─────────────────────────────────────────────────┐ │ +│ │ ┌─────────┐ ┌────▼────┐ ┌──────────┐ │ │ +│ │ │ Pipelit │ │ OpenCode│ │ Future │ │ │ +│ │ │(webhook)│ │(REST+SSE│ │ backends │ │ │ +│ │ └────┬────┘ └────┬────┘ └──────────┘ │ │ +│ └───────┼─────────────────┼──────────────────────────────────────────────────┘ │ +│ │ │ │ +│ ┌─ Admin API ─────────────┼──────────────────────────────────────────────────┐ │ +│ │ POST /admin/credentials (store new, encrypted) │ │ +│ │ POST /admin/mcp/add (install MCP server package) │ │ +│ │ GET /admin/mcp/list (list running MCP servers + health) │ │ +│ │ POST /admin/mcp/{id}/restart (restart MCP server) │ │ +│ └────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────┬─────────────────┬─────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────┐ ┌──────────────────┐ +│ PIPELIT │ │ OPENCODE │ +│ PLATFORM │ │ SERVER │ +│ │ │ │ +│ ┌────────────┐ │ │ AI-assisted │ +│ │ Workflow │ │ │ code editing │ +│ │ Engine │ │ │ │ +│ │ │ │ └──────────────────┘ +│ │ Nodes call │ │ +│ │ gateway │ │ +│ │ capability │ │ +│ │ API — never│ │ +│ │ hold tokens│ │ +│ └──────┬─────┘ │ +│ │ │ +│ ┌──────▼─────┐ │ +│ │ Agent │ │ +│ │ Backends │ │ +│ │ │ │ +│ │ LangGraph │ │ +│ │ (current) │ │ +│ │ CC (future)│ │ +│ └────────────┘ │ +│ │ +│ ┌────────────┐ │ +│ │ Node │ │ +│ │ Catalog │ │ +│ │ │ │ +│ │ Auto-syncs │ │ +│ │ from GW │ │ +│ │ integration│ │ +│ │ list │ │ +│ └────────────┘ │ +│ │ +└────────┬─────────┘ + │ + │ API + WebSocket + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ USER INTERFACES │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Tela Situation Room │ │ +│ │ │ │ +│ │ ┌─ Graph ──────────────────┐ ┌─ Status ────────────────┐ │ │ +│ │ │ live DAG visualization │ │ Workflow: idle │ │ │ +│ │ │ algorithmic layout │ │ │ │ │ +│ │ │ updates via WebSocket │ │ Integrations: │ │ │ +│ │ └──────────────────────────┘ │ ✅ slack (3s ago) │ │ │ +│ │ ┌─ Chat ──────────────────────│ ✅ postgres (1s ago) │ │ │ +│ │ │ "send hello to #general" ││ ❌ gmail (token exp.) │ │ │ +│ │ │ Agent: gateway_call(...) ││ ⚠️ discord (slow) │ │ │ +│ │ │ → gateway handles it │└──────────────────────────┘ │ │ +│ │ │ Agent never sees token │ │ │ +│ │ └────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ PLIT CLI │ │ +│ │ plit mcp add slack Install + configure integration │ │ +│ │ plit credentials create Store encrypted credential │ │ +│ │ plit mcp list Running integrations + health │ │ +│ │ plit start / stop Full stack lifecycle │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ React SPA (maintained for casual users) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Key Data Flows + +**Inbound (trigger — new Slack message triggers workflow):** +``` +Slack API ← Slack MCP server ← Adapter (poll, normalize) → Guardrails (CEL) + → Gateway inbound pipeline → Pipelit backend → Workflow trigger +``` + +**Outbound (workflow sends Slack message):** +``` +Workflow node → gateway_call("slack", "send_message", {channel, text}) + → Capability API → RBAC check → Guardrails (CEL, outbound) + → Adapter (normalize std request → MCP tool params) + → Slack MCP server → Slack API → result +``` + +**Agent mid-workflow tool call:** +``` +Agent reasoning → gateway_call("postgres", "execute_sql", {query}) + → Capability API → RBAC check → Adapter (normalize) → Postgres MCP → PG → result + (no DSN in agent context, ever) +``` + +**OAuth token refresh (automatic, background):** +``` +OAuth Refresh Manager watches secret:gmail:oauth.expiry + → expiry < 5 min → use refresh_token to call Google token endpoint + → encrypt new access_token → update Dragonfly + → re-inject into Email MCP server (restart or env update) +``` + +**Integration health monitoring:** +``` +Health Monitor pings each MCP server on interval + → healthy / degraded / down state machine + → publishes status → TUI status panel shows integration health + → on failure → alerts admin credentials +``` + +**Node catalog sync (new integration → available in palette):** +``` +plit mcp add slack → Gateway registers Slack MCP server + → Gateway notifies Pipelit: POST /api/v1/node-types/sync + → Pipelit registers slack:send_message, slack:conversations_history, etc. + → Available in node palette and workflow_discover results +``` + +--- + +## Architectural Notes + +### Adapter as Bilateral Normalization Layer + +The adapter is NOT just an inbound poller. It normalizes in both directions: + +**Inbound:** MCP server returns Slack-specific JSON → adapter normalizes to gateway's standardized `InboundMessage` format. + +**Outbound:** Capability API sends a standardized "send message" request → adapter translates to the specific MCP tool's parameter format. + +Without the adapter on outbound, the capability API would need integration-specific code for every MCP server's tool signature. The adapter absorbs this complexity via normalization config: + +```json +{ + "normalize_in": { + "text": "$.text", + "chat_id": "$.channel", + "message_id": "$.ts", + "from.name": "$.user" + }, + "normalize_out": { + "channel_id": "$.chat_id", + "payload": "$.text", + "content_type": "text" + }, + "poll_tool": "conversations_history", + "send_tool": "conversations_add_message", + "poll_interval": "30s", + "cursor_field": "filter_date_after" +} +``` + +This keeps the generic adapter binary truly generic — same code, different config per integration. + +### MCP Transport Bridge + +Most MCP servers default to stdio (JSON-RPC over stdin/stdout). This is single-threaded and one-request-at-a-time. The gateway needs concurrent access. + +**Solution:** The gateway includes an MCP transport bridge: +- For stdio-only servers: gateway spawns process, bridges stdio ↔ internal HTTP, queues concurrent requests +- For HTTP-capable servers: connect directly, no bridge needed + +``` +MCP server (stdio only) ←stdio→ [Gateway MCP Bridge] ←→ internal routing +MCP server (HTTP native) ←HTTP→ internal routing directly +``` + +Server capability is detected at spawn time from the MCP server's manifest or transport config. + +### Response Cache + +Multiple agents or workflows querying the same integration (e.g., "what's in #general?") would result in duplicate external API calls, risking rate limits. + +**Solution:** TTL-based response cache in Dragonfly: +- **Read tools** (conversations_history, list_emails, execute_sql): cache 5-30 seconds, configurable per tool +- **Write tools** (send_message, send_email): never cached +- **Cache key**: `cache:{integration}:{tool}:{hash(params)}` +- Cache invalidated on outbound write to same integration + +### Integration Health in Status Panel + +The Tela situation room surfaces integration health alongside workflow status: + +``` +Integrations: + ✅ slack — last poll 3s ago, 0 errors + ✅ postgres — last query 1s ago, 0 errors + ❌ gmail — token expired, refresh failed 2 min ago + ⚠️ discord — responding slowly (avg 2.1s, threshold 1s) +``` + +Health data published via WebSocket alongside existing workflow execution events. + +--- + +## Open Questions + +1. **MCP server packaging**: How are MCP servers distributed and installed? npm packages? Docker images? Binary downloads? `plit mcp add` needs to know how to fetch and run them. May need a registry or manifest format. + +2. **Integration discovery protocol**: Should the gateway expose available integrations as MCP tools themselves? That would let agents discover capabilities via the same MCP protocol they use for everything else — a meta-MCP layer. + +3. **Rate limiting strategy**: If multiple agents call the same Slack integration, who manages rate limits? Options: gateway-level rate limiter (centralized, per-integration), MCP server handles it (inconsistent across servers), or adapter manages it (per-adapter config). + +4. **Multi-tenant credential isolation**: In SaaS, different users own different credentials. The RBAC needs to scope not just by integration but by user/org. This requires extending the capability API's permission model beyond flat roles. + +5. **Adapter process model**: The generic adapter binary adds processes. Could the gateway run poll loops + normalization natively in Rust, eliminating adapter processes entirely? Trade-off: more gateway complexity vs fewer processes. For now, separate processes are simpler and more modular. + +6. **Node catalog sync mechanism**: Push (gateway notifies Pipelit on change) vs pull (Pipelit polls gateway's integration list)? Push is more responsive but requires gateway-to-Pipelit callback. Pull is simpler but has latency. diff --git a/docs/architecture/gateway-mcp-design.md b/docs/architecture/gateway-mcp-design.md new file mode 100644 index 00000000..5da7fa08 --- /dev/null +++ b/docs/architecture/gateway-mcp-design.md @@ -0,0 +1,493 @@ +# Gateway Architecture — Final Design + +**Date:** 2026-03-22 +**Status:** Design complete +**Context:** Design session on MCP hosting, credential security, adapter refactoring, and agentgateway adoption +**Previous iterations:** gateway-mcp-design.v1.md, gateway-mcp-design.v2-iterative.md + +--- + +## Executive Summary + +The plit gateway stack splits into two components: + +- **agentgateway** ([Linux Foundation, Rust, Apache 2.0](https://github.com/agentgateway/agentgateway), 2,100+ stars) — owns ALL external credentials (LLM keys, integration tokens, OAuth), LLM proxy with provider translation, MCP server federation, CEL-based RBAC enforcement, prompt guards, budget/spend controls. This is the **trust boundary**. +- **plit-gw** (our Rust code) — identity provider (issues JWTs), adapter poll loops (inbound triggers), backend routing (Pipelit/OpenCode), message normalization. Thin orchestrator. + +Backends (Pipelit, OpenCode, future) hold ZERO real external keys. They authenticate to agentgateway with JWTs issued by plit-gw. An LLM agent cannot leak what doesn't exist in its environment. + +--- + +## Security Model: agentgateway as Trust Boundary + +### Core Principle + +**No credential ever leaves agentgateway.** Agents interact with external services (Slack, Gmail, LLM providers) through agentgateway's APIs. They never hold tokens. This prevents LLM agents from leaking secrets in outputs, tool calls, error messages, or prompts. + +``` +Backend processes (Pipelit, OpenCode, agents, sandboxes): + ✅ JWTs issued by plit-gw (identify the user/agent) + ✅ agentgateway URL + ❌ No LLM API keys + ❌ No Slack/Discord/Gmail tokens + ❌ No database connection strings + ❌ No OAuth tokens + → An LLM/agent cannot leak what doesn't exist in its environment + +agentgateway process (trust boundary): + 🔒 All LLM API keys (Anthropic, OpenAI, Ollama, etc.) + 🔒 All integration tokens (Slack, Discord, Gmail, etc.) + 🔒 All database credentials + 🔒 All OAuth tokens + refresh tokens + 🔒 Managed in agentgateway's own config/credential store + 🔒 Never exposed via any API response +``` + +### Why agentgateway, Not Vault/OpenBao? + +Vault/OpenBao are **secrets delivery services** — they hand out plaintext secrets to authorized clients. The secret leaves the boundary. An agent that reads a Slack token from Vault has that token in its LLM context. + +agentgateway is a **secrets usage service** — like Fireblocks for integrations. It holds credentials and performs operations on behalf of callers. Callers get results, never tokens. + +| | Secrets delivery (Vault) | Secrets usage (agentgateway) | +|---|---|---| +| Agent gets | The actual token | A capability to act | +| LLM context contains | `xoxb-1234-secret...` | `"you can send Slack messages"` | +| If agent leaks output | Token is exposed | Nothing sensitive | +| Revocation | Rotate the leaked token | Revoke the agent's JWT | + +--- + +## What agentgateway Provides + +agentgateway is a single Rust binary that handles: + +### LLM Proxy (multi-provider, protocol translation) + +- OpenAI-compatible endpoint: `/v1/chat/completions` +- Anthropic native endpoint: `/v1/messages` +- Embeddings: `/v1/embeddings` +- Provider translation: clients speak OpenAI/Anthropic, agentgateway translates to any provider +- Supported providers: OpenAI, Anthropic, Gemini, Vertex AI, Bedrock, Azure OpenAI, any OpenAI-compatible (Ollama, vLLM, Groq, etc.) +- Model aliases: map friendly names to real model names +- Credentials stored in agentgateway's config, never exposed + +Backend configuration becomes trivial: +``` +# Pipelit config (no real keys!) +LLM_BASE_URL=http://agentgateway:8080/v1 +LLM_API_KEY= # JWT issued by plit-gw, not an LLM key + +# OpenCode config (no real keys!) +providerID: "custom" +baseURL: "http://agentgateway:8080/v1" +apiKey: +``` + +### MCP Server Federation + +- Hosts and manages multiple MCP server processes (stdio, SSE, HTTP) +- Tool aggregation: single MCP endpoint federates tools from all upstream servers +- Tool name prefixing: `slack_send_message`, `postgres_execute_sql` (avoids conflicts) +- stdio to HTTP bridging for stdio-only servers +- OpenAPI to MCP conversion: point at a REST API, get MCP tools automatically +- Config hot-reload via file watcher + +### RBAC via CEL Policies + +CEL (Common Expression Language) rules control who can access what: + +```yaml +mcpAuthorization: + rules: + # Only dad can use Gmail + - 'jwt.sub == "dad" && mcp.tool.target == "gmail"' + + # Kid can only read Slack, not send + - 'jwt.sub == "kid" && mcp.tool.name == "conversations_history"' + + # Admin gets everything + - 'jwt.role == "admin"' + + # Model restrictions + # (kid can't use opus) + - 'jwt.sub == "kid" && llm.requestModel contains "opus"' # → deny rule +``` + +Available CEL variables: + +| Variable | What it gives you | +|---|---| +| `jwt.sub`, `jwt.role`, `jwt.*` | User identity from JWT claims | +| `apiKey.*` | API key metadata | +| `mcp.tool.name` | Which MCP tool is being called | +| `mcp.tool.target` | Which MCP server/integration | +| `mcp.prompt.name`, `mcp.resource.name` | MCP prompts and resources | +| `llm.requestModel`, `llm.provider` | Which LLM model/provider | +| `llm.inputTokens`, `llm.outputTokens` | Token usage | +| `request.headers`, `request.path` | HTTP request details | + +Key feature: **unauthorized tools are automatically filtered from `list_tools`** — users don't even see tools they can't use. + +### Additional Capabilities + +- **Budget/spend controls** — per-model, rate limiting +- **Prompt guards** — PII detection, content moderation (regex, OpenAI mod, Bedrock Guardrails, Google Model Armor) +- **Prompt enrichment** — prepend/append system prompts +- **A2A protocol** — Agent-to-Agent protocol support +- **Observability** — OpenTelemetry, Prometheus metrics, traces to Langfuse/LangSmith + +--- + +## What plit-gw Provides + +plit-gw handles everything agentgateway doesn't: + +### Identity Provider (JWT Issuer) + +plit-gw manages users/identities and issues JWTs with appropriate claims: + +``` +User authenticates to plit-gw → JWT minted: + { + sub: "kid", + role: "family", + integrations: ["slack"], + models: ["claude-haiku-*"], + budget_remaining: 5.00 + } + +Client uses this JWT to call agentgateway directly. +agentgateway validates JWT → evaluates CEL rules → allow/deny. +``` + +plit-gw is the **policy decision point** (decides what's allowed via JWT claims). +agentgateway is the **policy enforcement point** (enforces at runtime via CEL). + +### Adapter Poll Loops (Inbound Triggers) + +agentgateway doesn't poll MCP servers for new messages. plit-gw's adapters handle inbound: + +``` +Generic MCP Adapter: + 1. Poll agentgateway's MCP endpoint on interval (30s, 60s) + 2. Track cursor/offset per source + 3. Normalize MCP response → InboundMessage format + 4. Route to backend (Pipelit webhook, OpenCode, etc.) +``` + +### Backend Routing + +Routes inbound messages and outbound delivery to the right backend: + +``` +Inbound: adapter → plit-gw pipeline → Pipelit webhook / OpenCode / future +Outbound: Pipelit says "send reply" → adapter → agentgateway MCP → external +``` + +### Health Monitoring + +- Monitors agentgateway health +- Monitors MCP server health (via agentgateway) +- Monitors backend health (Pipelit, OpenCode) +- Publishes integration health for TUI status panel +- Emergency alerts on failure + +--- + +## Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ EXTERNAL WORLD │ +│ Slack Discord Gmail Postgres GitHub Anthropic OpenAI Ollama │ +└────┬──────┬────────┬──────┬────────┬────────┬─────────┬────────┬────────────┘ + │ │ │ │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ██████████████████████████████████████████████████████████ │ +│ █ AGENTGATEWAY (Trust Boundary — Linux Foundation, Rust) █ │ +│ ██████████████████████████████████████████████████████████ │ +│ │ +│ ┌─ LLM Proxy ──────────────────────────────────────────────────────────┐ │ +│ │ /v1/chat/completions (OpenAI) │ /v1/messages (Anthropic native) │ │ +│ │ Provider translation: OpenAI ↔ Anthropic ↔ Bedrock ↔ Vertex ↔ etc │ │ +│ │ Model aliases │ Budget/spend controls │ Prompt guards + PII │ │ +│ │ 🔒 Real LLM API keys stored here, never exposed │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ MCP Server Federation ──────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │Slack MCP │ │Email MCP │ │Discord │ │Postgres │ + more │ │ +│ │ │(stdio) │ │(HTTP) │ │MCP(stdio)│ │MCP(HTTP) │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ │ │ +│ │ Tool aggregation + prefixing │ stdio ↔ HTTP bridge │ │ +│ │ OpenAPI → MCP conversion │ Config hot-reload (file watcher) │ │ +│ │ 🔒 Integration tokens stored here, never exposed │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ RBAC (CEL Policies) ────────────────────────────────────────────────┐ │ +│ │ jwt.sub == "kid" && mcp.tool.target == "gmail" → deny │ │ +│ │ jwt.sub == "kid" && llm.requestModel contains "opus" → deny │ │ +│ │ jwt.role == "admin" → allow all │ │ +│ │ Unauthorized tools filtered from list_tools automatically │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Credential Store ───────────────────────────────────────────────────┐ │ +│ │ agentgateway's own config — LLM keys, MCP server tokens, OAuth │ │ +│ │ Written by plit-gw / plit CLI → file-watched, hot-reloaded │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────┬───────────────────────────────────────────────┘ + │ JWT-authenticated calls + │ +┌──────────────────────────────┼───────────────────────────────────────────────┐ +│ │ │ +│ ┌──────────────────────▼──────────────────────┐ │ +│ │ PLIT-GW (Rust) │ │ +│ │ (Thin Orchestrator) │ │ +│ │ │ │ +│ │ Identity: issues JWTs with claims │ │ +│ │ Adapters: poll loops for inbound triggers │ │ +│ │ Routing: Pipelit / OpenCode / future │ │ +│ │ Normalization: bilateral InboundMessage │ │ +│ │ Health: monitors all components │ │ +│ │ Config: writes agentgateway YAML │ │ +│ │ │ │ +│ └──────┬──────────────────┬────────────────────┘ │ +│ │ │ │ +│ ┌──────▼──────┐ ┌──────▼──────┐ │ +│ │ Pipelit │ │ OpenCode │ │ +│ │ (webhook) │ │ (REST+SSE) │ │ +│ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ +│ ┌──────▼──────────────────▼──────┐ │ +│ │ Dragonfly │ │ +│ │ (plit-gw config, RQ queues │ │ +│ │ NOT credentials — those are │ │ +│ │ in agentgateway) │ │ +│ └────────────────────────────────┘ │ +│ │ +└──────────────────────────────┬───────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ PIPELIT PLATFORM │ +│ │ +│ Workflow Engine │ Agent Backends (LangGraph, CC future) │ REST API + WS │ +│ │ +│ LLM calls → agentgateway URL (no real keys in env) │ +│ MCP calls → agentgateway MCP endpoint (JWT auth) │ +│ Integration nodes → gateway_call() → agentgateway → external │ +│ │ +└──────────────────────────────┬───────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ USER INTERFACES │ +│ │ +│ ┌─ Tela Situation Room ─────────────────────────────────────────────────┐ │ +│ │ Graph (live DAG) │ Status (integrations + executions) │ Chat (agent) │ │ +│ │ Agent never sees tokens — talks through agentgateway │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ PLIT CLI ─────────────────────────────────────────────────────────────┐ │ +│ │ plit mcp add slack → writes agentgateway config, hot-reloaded │ │ +│ │ plit credentials create → stores in agentgateway config │ │ +│ │ plit start / stop → manages full stack lifecycle │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ React SPA (maintained for casual users) │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Key Data Flows + +**LLM call from agent (no real keys in backend):** +``` +Agent reasoning → LLM client → POST agentgateway/v1/chat/completions (JWT auth) + → agentgateway: validate JWT → check model access (CEL) → inject real API key + → proxy to Anthropic/OpenAI/Ollama → return response + → agent never sees sk-ant-... key +``` + +**MCP tool call from agent (no integration tokens in backend):** +``` +Agent → gateway_call("slack", "send_message", {channel, text}) (JWT auth) + → agentgateway: validate JWT → check mcp.tool.target access (CEL) + → route to Slack MCP server (internal) → Slack API → result + → agent never sees xoxb-... token +``` + +**Inbound trigger (new Slack message → workflow):** +``` +plit-gw adapter polls agentgateway MCP endpoint (JWT auth) + → agentgateway: route to Slack MCP server → conversations_history + → adapter: normalize → plit-gw inbound pipeline → Pipelit webhook + → workflow trigger fires +``` + +**Outbound delivery (workflow sends reply):** +``` +Workflow node → gateway_call("slack", "send_message", {...}) + → plit-gw adapter (normalize outbound params) + → agentgateway MCP endpoint → Slack MCP server → Slack API +``` + +**Credential setup:** +``` +plit credentials create --type slack + → prompts for token (schema-driven) + → writes to agentgateway YAML config + → agentgateway file-watcher detects change → hot-reloads + → Slack MCP server spawned with token injected +``` + +**Identity + RBAC flow:** +``` +User authenticates to plit-gw → JWT minted: + { sub: "kid", role: "family", integrations: ["slack"], models: ["haiku"] } + +Client calls agentgateway with JWT: + → agentgateway validates JWT + → CEL: mcp.tool.target == "gmail"? JWT says integrations=["slack"] → deny + → CEL: llm.requestModel == "opus"? JWT says models=["haiku"] → deny + → CEL: mcp.tool.target == "slack"? JWT allows → proceed +``` + +--- + +## Adapter Architecture: Bilateral Normalization + +The adapter normalizes in both directions — translation layer between plit-gw's standardized message format and each MCP server's tool-specific parameters: + +```json +{ + "normalize_in": { "text": "$.text", "chat_id": "$.channel" }, + "normalize_out": { "channel_id": "$.chat_id", "payload": "$.text" }, + "poll_tool": "conversations_history", + "send_tool": "conversations_add_message", + "poll_interval": "30s", + "cursor_field": "filter_date_after" +} +``` + +Same generic adapter binary, different config per integration. Without adapters on outbound, every send call would need integration-specific code. + +--- + +## Pipelit Agent Integration + +### Current Model (secrets exposed — being replaced) + +```python +@tool +def send_slack_message(channel, text): + token = get_credential("slack-a") # plaintext token in agent memory! + requests.post("https://slack.com/api/chat.postMessage", + headers={"Authorization": f"Bearer {token}"}, ...) +``` + +### New Model (capabilities only — no secrets) + +```python +@tool +def gateway_call(integration, tool_name, params): + """Call an integration tool through agentgateway.""" + response = requests.post( + f"{AGENTGATEWAY_URL}/mcp/tools/call", + headers={"Authorization": f"Bearer {JWT_TOKEN}"}, + json={"name": f"{integration}_{tool_name}", "arguments": params} + ) + return response.json() + +# Agent calls — never sees any token: +gateway_call("slack", "send_message", {"channel": "#general", "text": "hello"}) +``` + +--- + +## Design Decisions Summary + +| # | Decision | Choice | Rationale | +|---|---|---|---| +| 1 | MCP instance model | 1D: 1:1 default, opt-in shared | Safe default, leverages multi-credential servers when available | +| 2 | Adapter ↔ MCP | 2D: Generic adapter binary + custom | One codebase for most, custom only for persistent connections | +| 3 | Credential storage | agentgateway owns all credentials | Trust boundary — credentials never leave, agents can't leak them | +| 4 | Credential setup UX | Schema-driven (4D) | Zero per-integration UI code, works across CLI/web/agent/TUI | +| 5 | Adapter refactoring | 5A: Clean break | Telegram not needed (TUI talks to Pipelit chat directly) | +| 6 | LLM proxy | agentgateway (built-in) | Full provider translation, Anthropic native, budget controls | +| 7 | RBAC enforcement | agentgateway CEL policies | JWT claims + CEL rules cover all access control needs | +| 8 | Identity management | plit-gw issues JWTs | Backend-agnostic, works for Pipelit/OpenCode/future backends | + +--- + +## Implementation Phases + +### Phase 1: agentgateway Integration +- Run agentgateway as sidecar alongside plit-gw +- Configure LLM backends (Anthropic, OpenAI) in agentgateway config +- Point Pipelit's LLM client at agentgateway URL +- Remove real LLM keys from Pipelit config +- Validate: agent workflows run through agentgateway LLM proxy + +### Phase 2: MCP Server Setup +- Configure MCP servers in agentgateway (Slack, Postgres, etc.) +- Test MCP tool federation via agentgateway's MCP endpoint +- Write CEL policies for tool access control +- `plit mcp add` CLI writes agentgateway config + +### Phase 3: Adapter Refactoring (5A clean break) +- Build generic MCP adapter binary (poll loop + bilateral normalization) +- Remove current custom adapter protocol +- Connect adapters to agentgateway's MCP endpoint +- Bilateral normalization (inbound + outbound) via config + +### Phase 4: Identity + JWT +- plit-gw becomes JWT issuer +- JWT claims carry user permissions (integrations, models, budget) +- agentgateway CEL policies enforce based on JWT claims +- Per-user model access, integration access, budget enforcement + +### Phase 5: Credential Schema + Setup UX +- Schema format for credential requirements per MCP server +- `plit credentials create` renders from schema +- OAuth flow handling for Google, Microsoft +- TUI/web/agent all render credential forms from schema + +--- + +## Open Questions + +1. **agentgateway process restart**: agentgateway doesn't auto-restart dead stdio MCP server processes. plit-gw health monitor needs to detect and trigger restart (by rewriting config or via agentgateway admin API if available). + +2. **MCP server packaging**: How are MCP servers installed? `plit mcp add slack` needs to know how to fetch npm packages, Docker images, or binaries. + +3. **OAuth token refresh**: Does agentgateway handle OAuth token rotation natively, or does plit-gw need to manage refresh cycles and update agentgateway's config? + +4. **Response caching**: agentgateway may not cache MCP tool responses. For rate limit protection, plit-gw or Dragonfly may need a TTL cache layer. + +5. **Node catalog sync**: When new MCP servers are added to agentgateway, Pipelit's node palette needs updating. Push (agentgateway → Pipelit webhook) or pull (Pipelit polls agentgateway's tool list)? + +6. **agentgateway stability**: The project is v0.0.0 with `publish = false`. API stability not guaranteed. Pin to a specific commit/release. + +--- + +## MCP Ecosystem Research (2026-03-22) + +Available MCP servers for planned integrations: + +| Integration | Best MCP Server | Stars | Cursor Support | Transport | +|---|---|---|---|---| +| Discord | `barryyip0625/mcp-discord` | 76 | `minId`/`maxId` on search | stdio + HTTP | +| Slack | `korotovsky/slack-mcp-server` | 1,470 | `filter_date_after`, cursor pagination | stdio + SSE + HTTP | +| Email | `codefuturist/email-mcp` | 21 | `since` (ISO 8601), IMAP IDLE | stdio + HTTP | +| Postgres | `bytebase/dbhub` | 2,372 | Via SQL WHERE clause | stdio + HTTP | +| GitHub | `github/github-mcp-server` | Official | Via search queries | stdio | + +All are request-response only — no push/subscription. The MCP protocol has no event primitive. This is why plit-gw adapters own the poll loops. diff --git a/docs/architecture/platform-vision-2026-03.md b/docs/architecture/platform-vision-2026-03.md new file mode 100644 index 00000000..a9bf5a83 --- /dev/null +++ b/docs/architecture/platform-vision-2026-03.md @@ -0,0 +1,488 @@ +# Platform Vision — State of the World + +**Date:** 2026-03-22 +**Context:** Design session consolidating architecture across pipelit, plit, msg-gateway, and tela + +--- + +## The System Today + +Four repositories, three layers, one vision that hasn't fully connected yet. + +### Repository Map + +``` +plit/ — CLI + packager (Rust) + ├── plit — Control plane CLI + ├── plit-gw — Gateway binary (from msg-gateway crate) + └── plit-tui — Terminal UI (from tela engine) + +msg-gateway/ — Universal message router (Rust) + ├── adapters/ — External adapter subprocesses (Telegram, generic) + └── backends/ — Backend adapters (Pipelit webhook, OpenCode REST+SSE) + +pipelit/ — Workflow engine (Python/FastAPI) + ├── platform/ — Backend: API, orchestrator, components, models + └── platform/frontend/ — React SPA (the GUI that's becoming a burden) + +tela/ — Terminal UI engine (Rust + JSX) + ├── tela-engine — QuickJS + ratatui runtime + ├── tela-cli — CLI tool (run, dev, init) + └── examples/ — plit-tui, graph-boxes, graph-canvas +``` + +### What Exists and Works + +``` +PIPELIT (Workflow Engine) + ✅ Workflow CRUD via REST API + ✅ 23+ built-in node types (agents, code, triggers, routing, assertions) + ✅ LangGraph agent execution with sandboxing (bwrap/container) + ✅ Workflow composition — workflow nodes trigger child workflows + ✅ Real-time WebSocket updates (node status, execution progress) + ✅ Jinja2 expression resolution between nodes + ✅ Assertion nodes for in-graph validation + ✅ Agent tools: platform_api, workflow_create, workflow_discover + ✅ CodeBlock + CodeBlockVersion + CodeBlockTest (versioned code storage) + ✅ input_schema / output_schema fields on Workflow model (exist, unpopulated) + ✅ Chat endpoint for conversational workflow triggering + ✅ Scheduler (self-rescheduling via RQ) + +GATEWAY (Universal Router) + ✅ Multi-backend routing (Pipelit webhook, OpenCode REST+SSE, External subprocess) + ✅ External adapter architecture (subprocess lifecycle management) + ✅ Telegram adapter (Node.js, grammy long-polling) + ✅ Generic adapter (built-in HTTP/WebSocket) + ✅ CEL-based guardrails (inbound/outbound message filtering, hot-reload) + ✅ Health monitoring with emergency alerts + ✅ Admin API for credential CRUD + ✅ Hot-reload config without restart + ✅ Normalized message protocol (InboundMessage struct) + +PLIT CLI (Control Plane) + ✅ plit init (interactive setup wizard) + ✅ plit start/stop (full stack lifecycle via honcho) + ✅ plit credentials create/list/activate/deactivate + ✅ plit auth login/status/logout (Pipelit backend auth) + ✅ plit api workflow list/get/validate/delete + ✅ plit api node-types (component catalog) + ✅ plit local chat/send/listen (gateway messaging) + ✅ plit health (system status) + ✅ Auto-generated Rust API client (84 models, 19 modules) + +TELA (Terminal UI Engine) + ✅ JSX → QuickJS → ratatui rendering pipeline + ✅ Component library: box, text, list, table, tabs, input, gauge, canvas + ✅ Native APIs: fetch, WebSocket, storage, clipboard, env, filesystem + ✅ Permission-gated security model (manifest.json) + ✅ Hot-reload dev mode + ✅ plit-tui chat client (working) + ✅ graph-boxes example (DAG visualization with Unicode box drawing) + ✅ graph-canvas example (braille-based graph rendering) + ✅ Published to crates.io (v0.1.0) +``` + +--- + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ EXTERNAL WORLD │ +│ Telegram REST/WebSocket (Discord, Email, Slack — planned) │ +│ MCP Clients (Claude Desktop, CC, Cursor — future) │ +└──────┬──────────┬──────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ PLIT GATEWAY │ +│ (Universal Message Router) │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Adapter Layer (external subprocesses) │ │ +│ │ telegram (Node.js) │ generic (built-in) │ future: MCP-backed │ │ +│ └─────────────────────────┬─────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────────▼─────────────────────────────────────┐ │ +│ │ Guardrails (CEL-based, hot-reload) │ │ +│ └─────────────────────────┬─────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────────▼─────────────────────────────────────┐ │ +│ │ Routing Layer (credential.backend → named backend) │ │ +│ └──────┬──────────────────┬─────────────────────┬───────────────┘ │ +│ │ │ │ │ +│ ┌──────▼──────┐ ┌───────▼───────┐ ┌──────────▼───────────┐ │ +│ │ Pipelit │ │ OpenCode │ │ External (any) │ │ +│ │ (webhook) │ │ (REST+SSE) │ │ (subprocess) │ │ +│ └──────┬──────┘ └───────┬───────┘ └──────────┬───────────┘ │ +│ │ │ │ │ +│ ┌──────▼─────────────────▼──────────────────────▼───────────────┐ │ +│ │ MCP Server Host (planned) │ │ +│ │ Spawns + manages MCP server processes │ │ +│ │ Adapters talk to MCP servers for polling + sending │ │ +│ │ Agents call MCP tools directly for mid-workflow actions │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────┬─────────────────┬────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌────────────────┐ ┌────────────────┐ +│ PIPELIT │ │ OPENCODE │ +│ PLATFORM │ │ SERVER │ +│ │ │ │ +│ Workflow engine│ │ AI-assisted │ +│ Agent backends │ │ code editing │ +│ REST API │ │ │ +│ WebSocket │ │ │ +└───────┬────────┘ └────────────────┘ + │ + │ API + WebSocket + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ USER INTERFACES │ +│ │ +│ ┌────────────────────┐ ┌────────────────────────────────────────┐ │ +│ │ React SPA │ │ Tela Situation Room (target state) │ │ +│ │ (current GUI) │ │ │ │ +│ │ │ │ ┌─ Graph ──────────┐┌─ Status ──────┐│ │ +│ │ Becoming a burden │ │ │ live DAG view ││ execution ││ │ +│ │ as node types │ │ │ algorithmic ││ node outputs ││ │ +│ │ multiply │ │ │ layout ││ errors ││ │ +│ │ │ │ └──────────────────┘└───────────────┘│ │ +│ │ Every new type │ │ ┌─ Chat ───────────────────────────┐│ │ +│ │ requires frontend │ │ │ "change scribe temp to 0.3" ││ │ +│ │ changes │ │ │ Agent makes API calls ││ │ +│ │ │ │ │ Graph updates live ││ │ +│ │ │ │ │ "run it with this input" ││ │ +│ │ │ │ │ Execution streams in status panel ││ │ +│ └────────────────────┘ │ └──────────────────────────────────┘│ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ PLIT CLI (already built) │ │ +│ │ plit start/stop │ credentials │ api │ chat │ health │ │ +│ │ Used by: humans and privileged agents │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Design Principles (Established 2026-03-22) + +### 1. The Smallest Unit Is a Function + +`(state: dict) → dict` — the atom. Nodes are functions with contracts. Workflows are compositions of functions. Workflows embedded as nodes are fractal. The distinction between node and workflow is granularity, not kind. + +### 2. Workflows Are Deterministic Pipelines + +Strict typed contracts between workflows. No inter-workflow conversation. Agents reason inside nodes; orchestration stays deterministic. This is why N8N works — humans trust what they can see and predict. + +### 3. REPL via Composition + +Every node and workflow is independently testable. Test harness pattern: `trigger_manual → workflow node → assertion node`. No new eval endpoints — the platform primitives already provide full REPL capability. + +### 4. Execution Primitives Are Sufficient + +Three node types cover all execution needs: +- **Code node** — any Python function +- **Agent node** — LLM with tools (LangGraph today, CC/others future) +- **Workflow node** — calls another workflow by slug + +No new runtime primitives needed. A "custom node" is just one of these three with specific configuration. + +### 5. Dynamic Registry for Visibility (Not Execution) + +Execution and discoverability are separate concerns. Custom nodes are "dark" without a registry. The `node_type_templates` table makes them visible in palettes, discoverable via API, and renderable on canvas. Uses versioned references (package manager pattern) backed by existing `CodeBlock`/`CodeBlockVersion` infrastructure. + +### 6. Machine-Readable Contracts + +`input_schema` / `output_schema` (JSON Schema) on every workflow. Foundation for: discovery, composition, REPL, MCP tool exposure, schema-driven config UI. The fields exist on the model today — just need populating. + +### 7. Agent Backend Abstraction + +Agent nodes abstract over execution backends. LangGraph today. Claude Code, OpenAI Agents SDK, others in future. Same sandbox, same `(state) → dict` contract. Configuration choice, not structural change. + +--- + +## The MCP-First Integration Strategy + +### The Insight + +Millions of MCP servers already exist for Discord, Slack, Gmail, Postgres, GitHub, Jira, Notion, and more. Building custom adapters for each is unnecessary. + +### Research Findings (2026-03-22) + +| Integration | Best MCP Server | Stars | Cursor/Since Support | +|---|---|---|---| +| Discord | `barryyip0625/mcp-discord` | 76 | `minId`/`maxId` on search | +| Slack | `korotovsky/slack-mcp-server` | 1,470 | `filter_date_after`, cursor pagination | +| Email | `codefuturist/email-mcp` | 21 | `since` (ISO 8601), IMAP IDLE | +| Postgres | `bytebase/dbhub` | 2,372 | Via SQL WHERE clause | +| GitHub | `github/github-mcp-server` | Official | Via search queries | + +**Key finding:** No MCP server supports push/subscription. All are request-response. The MCP protocol has no event primitive. + +### The Architecture + +The gateway manages two categories of processes: + +``` +Gateway Process Manager + ├── Adapter Processes (own poll loops, speak gateway protocol) + │ └── Adapter talks to MCP server for polling + sending + │ + └── MCP Server Processes (passive tool providers) + └── Available to adapters, agents, and config UI +``` + +Three usage patterns: + +| Pattern | Adapter | MCP Server | Example | +|---|---|---|---| +| Bilateral trigger | Adapter polls MCP, pushes inbound | Passive tools | Slack, Discord, Email | +| Tool only | No adapter needed | Passive tools | Postgres, GitHub, Jira | +| Custom real-time | Adapter owns connection directly | No MCP | Telegram (already built) | + +### One MCP Server, Multiple Roles + +A single integration (e.g., Slack) touches four node-level concerns: + +| Role | Node Type | MCP Tool Used | +|---|---|---| +| **Trigger** (inbound) | `trigger_slack` | `conversations_history` (via adapter poll) | +| **Send** (outbound) | `send_slack` | `conversations_add_message` | +| **Tool** (mid-workflow) | `slack_search` | `conversations_search_messages` | +| **Config** (UI discovery) | Config dropdowns | `channels_list`, `users_search` | + +The MCP server is shared across all roles. Same auth, same process, same credential. + +### What This Means for v0.6.0 + +The planned custom adapters (#9 Discord, #10 Slack, #11 Email) can largely be replaced by: +1. Gateway hosts MCP servers +2. Thin adapter wraps MCP server with a poll loop +3. Agent tools call MCP directly for mid-workflow actions +4. Config UI queries MCP for discovery (list channels, users, etc.) + +Only truly real-time bilateral chat (Telegram) needs a custom adapter. Everything else is MCP-polled. + +--- + +## The GUI Problem and the TUI Solution + +### The Problem + +The React frontend is becoming a burden. Every new node type requires: +- Backend: component registration, schemas, node_type_defs +- Frontend: NodePalette, NodeDetailsPanel, WorkflowCanvas, type definitions +- Both sides must stay in sync + +With MCP-based integrations, node types will explode (15-60 tools per MCP server). The web GUI cannot keep up. + +### The Solution: Conversational TUI + +The Tela-based situation room replaces the "click and configure" paradigm with "observe and talk": + +**Three panels:** +- **Graph** — live read-only DAG visualization (algorithmic layout, no manual positioning) +- **Status** — execution state, node outputs, errors (WebSocket-driven) +- **Chat** — natural language interface to a privileged agent + +**The agent has tools to:** +- Create/modify/delete nodes and edges (platform API) +- Create/modify workflows (workflow_create) +- Run executions (chat endpoint) +- Discover existing workflows and nodes (workflow_discover) +- Query MCP servers for config values (list channels, users, etc.) + +**No config panels. No forms. No per-node-type frontend code.** The editing surface is conversation. The graph and status are feedback loops. + +### Why This Works + +1. **Schema-driven** — `input_schema`/`output_schema` tells the agent what's valid +2. **API-complete** — every operation is already REST CRUD +3. **Live updates** — WebSocket pushes changes to graph view instantly +4. **REPL** — "run it with this input" is just a chat message +5. **Scales infinitely** — new MCP tools, new node types, new integrations require zero UI code + +### What Tela Already Has + +- JSX → terminal rendering pipeline (working) +- Chat client connected to Pipelit (working) +- DAG visualization prototypes (graph-boxes, graph-canvas) +- WebSocket support (working) +- Situation room design doc (PLIT-TUI-REDESIGN.md) + +### What Tela Needs + +- Graph view connected to live workflow data (API + WebSocket) +- Split-panel layout (graph + status + chat) +- Agent integration for conversational editing +- Schema-driven rendering for node details (when user wants to inspect) + +--- + +## The Dynamic Node Registry — Detailed Design + +### Execution vs Discoverability + +**Execution primitives are sufficient.** Code nodes, agent nodes, and workflow nodes cover all runtime needs. + +**The registry solves visibility.** Custom nodes exist but are "dark" without it. + +### Templates with Versioned References + +Registry entries are reusable types (classes). Nodes in workflows are instances that pin to a specific version. + +``` +node_type_templates table: + slug, name, description, category, icon + input_schema, output_schema + implementation_type (code / workflow / agent) + implementation_ref (code_block_id / workflow_slug / agent_config_id) + current_version + created_at, updated_at + +WorkflowNode additions: + node_type_template_slug — which registry type + pinned_version — which version +``` + +- Execution layer unchanged — nodes run as regular code/workflow/agent +- Version pinning — updates don't break existing workflows +- Workflows with schemas auto-register as node types + +### MCP-Sourced Registry Entries + +When gateway hosts an MCP server, its tools can auto-register as node types: + +``` +MCP Server: slack-mcp (15 tools) + → auto-registers 15 node_type_templates: + slug: slack/conversations_history + slug: slack/conversations_add_message + slug: slack/channels_list + ... + → each has input_schema/output_schema from MCP tool definition + → implementation_type: "mcp_tool" + → implementation_ref: "slack-mcp:conversations_history" +``` + +This is the bridge between MCP ecosystem and the node palette. + +--- + +## Connective Tissue — What's Missing + +The pieces don't come together because three things are missing: + +### 1. Contracts (`input_schema` / `output_schema`) + +**What it unblocks:** +- Workflow-as-node composability (callers know what to send) +- Agent discovery via `workflow_discover` (agents know how to call workflows) +- Schema-driven config UI (TUI or web — render from schema, not hardcoded) +- MCP tool exposure (expose workflows as MCP tools to external clients) +- REPL testing (know what input to provide) +- Dynamic registry (schemas define the node type's ports) + +**Effort:** Low. The fields exist. Just populate them on existing workflows. + +### 2. MCP Hosting in Gateway + +**What it unblocks:** +- Universal integrations (Slack, Discord, Email, Postgres, GitHub, etc.) +- Agent tool access to external systems (mid-workflow MCP calls) +- Config discovery (list channels, users, tables via MCP) +- MCP-backed adapter pattern (thin adapter + MCP server) +- Exposing workflows as MCP tools to external clients + +**Effort:** Medium-high. Requires gateway changes: MCP process management, MCP protocol bridge, adapter-to-MCP communication. + +### 3. Tela Situation Room + +**What it unblocks:** +- Conversational workflow editing (no more per-node-type frontend code) +- Live graph visualization +- REPL via chat +- Eliminates the GUI bottleneck entirely +- Power user workflow — keyboard-driven, scriptable + +**Effort:** Medium. Core pieces exist (chat client, graph rendering, WebSocket). Need: split-panel layout, API-driven graph view, agent integration. + +### Dependency Graph + +``` +input_schema/output_schema (P0 — enables everything) + │ + ├──→ Dynamic Node Registry + │ │ + │ ├──→ #169 Node/Workflow Generator + │ │ │ + │ │ └──→ #163 DSL Spec (codifies what works) + │ │ │ + │ │ └──→ #181 Workflow Test Execution + │ │ + │ └──→ MCP-sourced registry entries + │ │ + │ └──→ v0.6.0 MCP-based integrations + │ + ├──→ Tela Situation Room (schema-driven rendering) + │ + └──→ MCP Hosting in Gateway + │ + ├──→ MCP-backed adapters (Slack, Discord, Email) + ├──→ Agent MCP tool access (mid-workflow) + └──→ Expose workflows as MCP tools (inbound) +``` + +### Recommended Execution Order + +``` +Phase 1: Foundation + → Populate input_schema/output_schema on existing workflows + → Tela situation room MVP (graph + chat, schema-driven) + +Phase 2: Composition + → Dynamic Node Registry (node_type_templates table) + → #169 Node/Workflow Generator (uses composition + registers outputs) + → #163 DSL Spec (codifies proven patterns) + +Phase 3: Universal Integration + → MCP hosting in gateway + → MCP-backed adapter pattern + → MCP-sourced registry entries + → v0.6.0 integrations via MCP (Slack, Discord, Email) + +Phase 4: Full Vision + → Agent backend abstraction (CC alongside LangGraph) + → Expose workflows as MCP tools to external clients + → #181 Workflow test execution + → Self-modifying system (privileged agents install + configure via plit CLI) +``` + +--- + +## What We Proved Today (2026-03-22) + +1. **Workflow composition works** — split workflow-generator into requirements-gatherer + workflow-generator-b, end-to-end execution validated +2. **Data passing works** — `input_mapping` on workflow nodes, trigger_wf flattens payload +3. **The platform primitives are sufficient** — no new node types needed for composition +4. **The MCP ecosystem covers most integrations** — cursor/pagination support exists +5. **The GUI is the bottleneck** — not the engine, not the gateway, not the architecture +6. **The TUI + conversational editing is the path forward** — no per-node-type frontend code + +--- + +## Open Questions + +1. **MCP protocol for triggers** — MCP has no event/subscription primitive. Gateway-driven polling via adapters is the current answer. Is there a better pattern emerging in the MCP ecosystem? + +2. **Tela graph layout algorithm** — Sugiyama (layered) vs force-directed vs custom? Need to handle parallel branches, merge points, conditional routing. Left-to-right vs top-down? + +3. **Agent for conversational editing** — Which agent tools are needed beyond platform_api? How does the agent understand workflow semantics (not just CRUD)? + +4. **Schema evolution** — When input_schema/output_schema change on a workflow, what happens to upstream callers that depend on the old contract? Semver for workflows? + +5. **Cross-repo coordination** — Changes span pipelit, plit, msg-gateway, and tela. What's the release coordination strategy? diff --git a/docs/architecture/workflow-composition-design.md b/docs/architecture/workflow-composition-design.md new file mode 100644 index 00000000..c4f16704 --- /dev/null +++ b/docs/architecture/workflow-composition-design.md @@ -0,0 +1,316 @@ +# Workflow Composition Design + +**Date:** 2026-03-22 +**Status:** Validated via prototype +**Context:** Session exploring decomposition of monolithic workflow-generator into composable units + +## Core Insight + +The smallest unit in Pipelit is a **function**: `(state: dict) → dict`. + +Everything is composition of this atom: + +``` +function — (state) → dict — the atom +node — function + config + schema — atom with a contract +workflow — nodes + edges — composition of atoms +workflow-as-node — workflow embedded in another graph — fractal composition +``` + +Nodes and workflows are interchangeable. A workflow embedded as a node in another graph has the same interface as any other node. The distinction between "node" and "workflow" is granularity, not kind. + +## Design Principles + +### 1. Workflows Are Deterministic Pipelines + +- Strict typed contracts between workflows +- No negotiation/clarification loops between composed workflows +- Agents do reasoning *inside* nodes; orchestration stays deterministic +- This is the N8N layer — the reliable backbone +- The moment workflows "converse" with each other, you've left the workflow space and entered the agent space (hard to debug, hard to price, hard to guarantee) + +### 2. Prefer `agent` Over `deep_agent` in Composed Workflows + +- Deep agents (planning, filesystem, subagents) are overkill for scoped tasks within a pipeline +- Regular agents with specific tools are faster, cheaper, more predictable, easier to test +- Reserve deep_agent for genuinely open-ended conversational nodes (e.g., the Scribe gathering requirements from a human) + +### 3. Machine-Readable Contracts via input_schema / output_schema + +- `input_schema` and `output_schema` fields already exist on the Workflow model (currently null) +- Populate with JSON Schema to define the contract +- Enables autonomous agents to discover workflows via `workflow_discover` and know: + - What the workflow does (description) + - What input it expects (input_schema) + - What it returns (output_schema) +- Same pattern as OpenAPI / function calling tool schemas +- This is the foundation for workflow-as-node composability + +### 4. Workflows and Nodes Are REPL (Read-Eval-Print Loop) + +- Every node is independently invocable with an input and returns observable output +- Every workflow is independently callable with a typed payload and returns a result +- No need to run the full chain to test one piece +- **REPL is achieved through composition, not new infrastructure:** + - Assertion nodes provide in-graph validation (already built) + - A workflow embedded as a `workflow` node + downstream `assertion` node = REPL for that workflow + - Test harness pattern: `trigger_manual → workflow node → assertion node` +- This enables rapid prompt iteration: change a node's prompt, eval it, see output, adjust, repeat + +### 5. The Generator Is Scale-Invariant + +- A "node generator" and a "workflow generator" are the same thing at different scales +- The generator takes requirements and produces a **function with a contract** (`input_schema → output_schema`) +- Whether that function is implemented as a code node, an agent with tools, a small workflow, or a nested workflow is an implementation detail +- The contract is what matters, not the internal structure + +## Proof of Concept + +### What We Built + +Split the monolithic workflow-generator (22 nodes) into two composed workflows: + +**requirements-gatherer** (6 nodes): +``` +trigger_chat → scribe (deep_agent) → scribe_check (switch) + ├─ "ready" → invoke_constructor (workflow node → workflow-generator-b) + └─ "__other__" → reply_clarify +``` + +**workflow-generator-b** (18 nodes): +``` +trigger_workflow → dispatch → gherkin_agent + topology_agent (parallel) + → checks → join → verifier → check → builder/reply +``` + +### What Worked + +- Parent triggers child via `workflow` node with `input_mapping` +- Data passes correctly from parent's `node_outputs.scribe.output` to child's `trigger_wf.requirements` +- Child executes full pipeline (dispatch, parallel agents, join, verify) +- Parent resumes after child completes, receives child's output +- Output visible on canvas in both workflows + +### Bugs Found (Infrastructure) + +1. **Jinja2 path resolution**: `trigger_wf.payload.requirements` should be `trigger_wf.requirements` — the trigger_workflow node flattens the payload when emitting as node output +2. **Trigger mode**: `implicit` mode bypasses trigger resolver; `explicit` mode required for the child's `trigger_workflow` node to fire +3. **Long prompts in inline JSON**: System prompts dropped when embedded in large JSON payloads during node creation — must patch separately via API + +## Decomposition Plan + +| Workflow | Input Contract | Output Contract | +|---|---|---| +| **requirements-gatherer** | natural language (chat) | `{requirements: string}` | +| **spec-generator** | `{requirements: string}` | `{gherkin: string, topology: string}` | +| **spec-verifier** | `{gherkin: string, topology: string}` | `{verdict: pass\|fail, issues: string[]}` | +| **workflow-builder** | `{gherkin: string, topology: string}` | `{workflow_slug: string}` | + +Orchestration chain: +``` +requirements-gatherer → spec-generator → spec-verifier → workflow-builder +``` + +Each independently testable with fixture inputs. Each REPL-invocable via test harness workflows. + +## Dynamic Node Registry — Critical Gap + +### Problem + +All node types are currently hardcoded: + +- `COMPONENT_REGISTRY` — Python dict populated at import time by `@register` decorators +- `NODE_TYPE_REGISTRY` — hardcoded in `node_type_defs.py` with port definitions for 23+ built-in types +- Frontend — hardcoded component types in palette, canvas, config panel + +If the generator creates a custom node at runtime, there is **nowhere for it to live**. It cannot be registered without restarting the server, visualized on the canvas, discovered by other workflows, or inspected for its ports and schema. + +### Solution: Database-Backed Node Registry + +A `custom_node_types` table (or similar) that stores dynamically created node types: + +| Field | Purpose | +|---|---| +| `slug` | Unique identifier | +| `name` | Display name | +| `description` | What it does | +| `input_schema` | JSON Schema for inputs | +| `output_schema` | JSON Schema for outputs | +| `implementation_type` | `code` / `workflow` / `agent` | +| `implementation_ref` | Code block ID, workflow slug, or agent config | +| `icon` / `category` | For the palette/canvas | +| `created_at` | When it was registered | + +**Implementation types:** +- `code` — points to a `CodeBlock` row (Python function) +- `workflow` — points to a workflow slug (composition of nodes) +- `agent` — points to an agent config (LLM with tools) + +All expose the same `input_schema → output_schema` contract. + +### Integration Points + +- `GET /workflows/node-types/` merges built-in `NODE_TYPE_REGISTRY` + dynamic registry from DB +- Frontend palette renders both built-in and custom nodes from the same API +- `workflow_discover` tool can search custom nodes alongside workflows +- Generator (#169) writes new node types to this registry +- DSL spec (#163) references both built-in and custom node types + +### Why This Is a Prerequisite + +The node generator (#169) needs somewhere to **put** what it creates. Without a dynamic registry, generated nodes cannot be reused, discovered, or composed into other workflows. This must be built before the generator and before the DSL spec. + +## Roadmap Implications + +1. **Populate `input_schema`/`output_schema` on workflows (P0)** — Foundation for contracts, discoverability, and workflow-as-node composability. Just populate the existing fields. + +2. **Dynamic Node Registry (P0)** — Separate concern from execution. Makes custom nodes visible in the palette, discoverable via API, renderable on the canvas. Prerequisite for the generator to produce reusable, visible outputs. + +3. **#169 (Node/Workflow Generator)** — Uses composition pattern. Produces code nodes, agent nodes, or workflows depending on scale. Registers outputs in the dynamic registry. + +4. **#163 (DSL Spec)** — Codifies proven patterns. Must include `input_schema`/`output_schema` as first-class fields. References both built-in and custom node types from registry. + +5. **REPL is composition** — No new eval endpoints needed. Test any workflow by embedding it as a `workflow` node in a test harness with assertion nodes. + +6. **Agent backend abstraction (future)** — CC as an alternative agent backend alongside LangGraph. Same sandbox, same contract. + +7. **Recommended order:** + ``` + ✅ Workflow composition (validated 2026-03-22) + → Populate input_schema/output_schema on workflows + → Dynamic Node Registry + → #169 Node/workflow generator (uses composition + registers outputs) + → #163 DSL spec (codifies what works, references registry) + → #181 Workflow test execution (builds on DSL + assertions) + ``` + +## Key Architectural Insight + +The distinction between workflows and agents is the critical business decision: + +- **Workflows** = deterministic, strict contracts, testable, composable like functions. Humans trust things they can see and predict. +- **Agents** = LLM reasoning contained *within* workflow nodes. Powerful but scoped. + +The power is that a workflow *contains* agents, not that workflows *are* agents. Keep the orchestration deterministic, let the LLM do its thing inside each node. + +This is why N8N's business works — a thin layer of deterministic orchestration that humans can reason about, with the intelligence happening inside individual nodes. + +## Agent Node as Backend Abstraction + +The agent node type is an abstraction over execution backends. Today it uses LangGraph (`create_react_agent`). The same node contract — `(state) → dict`, sandboxed execution, input/output schema — can support alternative backends: + +| Backend | Strength | Use Case | +|---|---|---| +| **LangGraph** (current) | Structured tool calling, ReAct loop | Most agent tasks — classification, extraction, API calls | +| **Claude Code** (future) | Autonomous coding, planning, filesystem ops | Code generation, refactoring, complex multi-step builds | +| **Other SDKs** (future) | Varies | OpenAI Agents SDK, CrewAI, etc. | + +All backends run inside the same sandboxed environment (bwrap/container). The node's external contract is unchanged — callers don't know or care which backend executes the work. This is a configuration choice on the node, not a structural change. + +The builder node in the workflow-generator is a natural candidate for a CC backend — it needs to call APIs, inspect results, handle errors, and retry. That's an autonomous session, not a single ReAct loop. + +**Status:** Future direction. Not a blocker for current work. The existing LangGraph backend covers all immediate needs. + +## Execution Primitives Are Sufficient + +The existing node types cover all **execution** needs — no new runtime primitives required: + +- **Code node** — runs any Python function. A "custom node" is just a code node with specific logic. +- **Agent node** — LangGraph agent (or future backends) with tools wired via tool edges. A "custom tool" is just a code node connected as a tool edge. +- **Workflow node** — calls any workflow by slug. A "custom composite node" is just a workflow. + +## Dynamic Node Registry — Visibility and Discoverability + +Execution and discoverability are separate concerns. The primitives above solve execution. The registry solves **visibility**: + +### The Problem + +- The palette shows 23 hardcoded node types — that's all users see +- A "sentiment analyzer" code block created last week is invisible +- A workflow with `input_schema`/`output_schema` doesn't appear as a draggable node type +- Custom nodes exist but are **dark** — you must know they're there and manually configure a code/workflow node to reference them +- Same problem applies to credential types — only hardcoded types are visible + +### What the Registry Solves + +| Concern | Without Registry | With Registry | +|---|---|---| +| **Catalog** | Users must remember what exists | Browsable list of all reusable units | +| **Palette** | 23 hardcoded types | Built-in + custom types, merged | +| **Canvas rendering** | Custom nodes look generic | Proper icons, categories, port definitions | +| **Discovery** | `workflow_discover` returns workflows only | Returns all reusable units with schemas | +| **Credential types** | Hardcoded LLM/Telegram/webhook | Dynamic types, free-typing | + +### Design: Templates with Versioned References + +A dynamic registry entry is a **reusable type** (like a class), not an instance. Nodes in workflows are **instances** that reference a specific version of the type. + +Key distinction: +- **Code node instance** = "this specific Python code in this specific workflow" — one-off, not reusable +- **Registry entry** = "a reusable type that can be instantiated many times across many workflows" — a package + +#### The Versioning Pattern + +Instances pin to a specific version. The registry publishes new versions. Instances upgrade explicitly. This is the package manager pattern. + +The existing `CodeBlock` + `CodeBlockVersion` + `CodeBlockTest` model already provides the versioning infrastructure for code-backed types. Workflows already have versioning via their own update history. + +#### Registry Table: `node_type_templates` + +| Field | Purpose | +|---|---| +| `slug` | Unique identifier (e.g. `sentiment-analyzer`) | +| `name` | Display name | +| `description` | What it does | +| `category` | For palette grouping | +| `icon` | For canvas rendering | +| `input_schema` | JSON Schema for input ports | +| `output_schema` | JSON Schema for output ports | +| `implementation_type` | `code` / `workflow` / `agent` | +| `implementation_ref` | Code block ID, workflow slug, or agent config ID | +| `current_version` | Latest published version | +| `created_at` | When it was registered | +| `updated_at` | Last update | + +#### Instance Reference (on WorkflowNode) + +When a user drags a registry type onto the canvas, the node stores: + +| Field | Value | +|---|---| +| `component_type` | `code` / `workflow` / `agent` (the underlying primitive) | +| `node_type_template_slug` | `sentiment-analyzer` (which registry type this is) | +| `pinned_version` | `3` (which version of the template) | + +This means: +- **Execution layer unchanged** — the node runs as a regular code/workflow/agent node +- **Canvas knows the type** — renders with the registry's icon, ports, category +- **Version pinning** — updating the registry type doesn't break existing workflows +- **Explicit upgrade** — users choose when to upgrade instances to a new version + +#### Workflow-Backed Types + +Workflows with `input_schema`/`output_schema` populated can auto-register as node types. A workflow IS a registry entry — its slug is the template slug, its version history is the version. Instances are `workflow` nodes with `target_workflow` pointing at the slug. + +#### How It Composes + +``` +Registry (types) Workflows (instances) +┌───────────────────┐ ┌────────────────────────────┐ +│ sentiment-analyzer│ │ my-workflow │ +│ type: code │──────────► │ node: analyze │ +│ code_block: 42 │ creates │ type: code │ +│ version: 3 │ instance │ template: sent-analyzer│ +│ input: {text} │ │ pinned_version: 3 │ +│ output: {score}│ │ │ +└───────────────────┘ └────────────────────────────┘ +``` + +### Integration Points + +- `GET /workflows/node-types/` merges built-in `NODE_TYPE_REGISTRY` + `node_type_templates` from DB +- Frontend palette renders both built-in and custom nodes from the same API +- `workflow_discover` tool searches custom nodes alongside workflows +- Generator (#169) registers new node types in this table with version 1 +- DSL spec (#163) can reference both built-in and custom node type slugs +- Version upgrade UI — show when a node's pinned version is behind `current_version` diff --git a/platform/alembic/versions/758cd1ca2aad_add_backend_route_to_component_configs.py b/platform/alembic/versions/758cd1ca2aad_add_backend_route_to_component_configs.py new file mode 100644 index 00000000..8328da67 --- /dev/null +++ b/platform/alembic/versions/758cd1ca2aad_add_backend_route_to_component_configs.py @@ -0,0 +1,25 @@ +"""add backend_route to component_configs + +Revision ID: 758cd1ca2aad +Revises: c5d6e7f8a9b0 +Create Date: 2026-03-31 22:11:52.163452 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = '758cd1ca2aad' +down_revision: Union[str, Sequence[str], None] = 'c5d6e7f8a9b0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('component_configs', sa.Column('backend_route', sa.String(255), nullable=True)) + + +def downgrade() -> None: + op.drop_column('component_configs', 'backend_route') diff --git a/platform/api/__init__.py b/platform/api/__init__.py index 50cd1d74..74716b1a 100644 --- a/platform/api/__init__.py +++ b/platform/api/__init__.py @@ -10,8 +10,10 @@ from api.memory import router as memory_router from api.schedules import router as schedules_router from api.workspaces import router as workspaces_router +from api.providers import router as providers_router from api.settings import router as settings_router from api.users import router as users_router +from api.available_models import available_models_router api_router = APIRouter(prefix="/api/v1") @@ -25,3 +27,5 @@ api_router.include_router(schedules_router, prefix="/schedules", tags=["schedules"]) api_router.include_router(workspaces_router, prefix="/workspaces", tags=["workspaces"]) api_router.include_router(settings_router, prefix="/settings", tags=["settings"]) +api_router.include_router(providers_router, prefix="/providers", tags=["providers"]) +api_router.include_router(available_models_router, prefix="/available-models", tags=["available-models"]) diff --git a/platform/api/available_models.py b/platform/api/available_models.py new file mode 100644 index 00000000..2d5b1a0e --- /dev/null +++ b/platform/api/available_models.py @@ -0,0 +1,25 @@ +"""Available models endpoint — read-only list of models from agentgateway config.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from auth import get_current_user +from config import settings +from models.user import UserProfile + +available_models_router = APIRouter() + + +@available_models_router.get("/") +def list_available_models(profile: UserProfile = Depends(get_current_user)): + """Return available models across all configured providers. + + Reads directly from the agentgateway filesystem config. + Returns an empty list when AGENTGATEWAY_ENABLED is False. + """ + if not settings.AGENTGATEWAY_ENABLED: + return [] + from services.agentgateway_config import list_all_available_models + + return list_all_available_models() diff --git a/platform/api/credentials.py b/platform/api/credentials.py index a7edbd96..2ffce851 100644 --- a/platform/api/credentials.py +++ b/platform/api/credentials.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from auth import get_current_user +from config import settings from database import get_db from models.user import UserRole from models.credential import ( @@ -66,6 +67,7 @@ def _serialize_credential(cred: BaseCredential, db: Session) -> dict: "created_at": cred.created_at, "updated_at": cred.updated_at, "detail": {}, + "agentgateway_backend": None, } if cred.credential_type == "llm" and cred.llm_credential: llm = cred.llm_credential @@ -195,6 +197,7 @@ def create_credential( logger.warning("Failed to clean up gateway credential %s after DB error", payload.name) raise db.refresh(base) + return _serialize_credential(base, db) @@ -270,6 +273,7 @@ def update_credential( db.commit() db.refresh(cred) + return _serialize_credential(cred, db) @@ -285,6 +289,7 @@ def delete_credential( cred = query.first() if not cred: raise HTTPException(status_code=404, detail="Credential not found.") + if cred.gateway_credential: gw_cred = cred.gateway_credential try: @@ -324,6 +329,7 @@ def batch_delete_credentials( status_code=502, detail=f"Failed to delete gateway credentials: {failed_gw}. Database unchanged.", ) + for cred in creds: db.delete(cred) db.commit() @@ -400,6 +406,8 @@ def test_credential( if not cred.llm_credential: raise HTTPException(status_code=404, detail="LLM credential not found.") llm = cred.llm_credential + + # Direct provider test is_custom_base = llm.base_url and "anthropic.com" not in llm.base_url try: if llm.provider_type == "anthropic" and not is_custom_base: @@ -461,6 +469,7 @@ def list_credential_models( raise HTTPException(status_code=404, detail="LLM credential not found.") llm = cred.llm_credential + # Direct provider call is_custom_base = llm.base_url and "anthropic.com" not in llm.base_url if llm.provider_type == "anthropic" and not is_custom_base: try: diff --git a/platform/api/nodes.py b/platform/api/nodes.py index 128886b0..8694e341 100644 --- a/platform/api/nodes.py +++ b/platform/api/nodes.py @@ -192,7 +192,8 @@ def update_node( if config_data: cc = node.component_config model_fields = ( - "llm_credential_id", "model_name", "temperature", "max_tokens", + "llm_credential_id", "model_name", "backend_route", + "temperature", "max_tokens", "frequency_penalty", "presence_penalty", "top_p", "timeout", "max_retries", "response_format", ) diff --git a/platform/api/providers.py b/platform/api/providers.py new file mode 100644 index 00000000..ee5457ca --- /dev/null +++ b/platform/api/providers.py @@ -0,0 +1,350 @@ +"""Provider and model management API for agentgateway config.d/ structure. + +Admin-only endpoints for managing LLM providers and their models via +the agentgateway filesystem-based configuration. +""" + +from __future__ import annotations + +import logging +from urllib.parse import urlparse + +import httpx +from cryptography.fernet import Fernet +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from auth import require_admin +from config import settings +from models.user import UserProfile + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class CreateProviderIn(BaseModel): + provider: str + provider_type: str + api_key: str + base_url: str = "" + + +class CreateProviderOut(BaseModel): + provider: str + provider_type: str + models: list[dict] + + +class ProviderOut(BaseModel): + provider: str + provider_type: str + models: list[dict] + + +class AddModelsIn(BaseModel): + models: list[dict] + + +class AddModelsOut(BaseModel): + provider: str + models: list[dict] + + +class FetchedModel(BaseModel): + id: str + name: str + + +class ModelOut(BaseModel): + slug: str + model_name: str + route: str + + +# --------------------------------------------------------------------------- +# Guard: AGENTGATEWAY_ENABLED +# --------------------------------------------------------------------------- + + +def _require_agentgateway(): + """Raise 404 if agentgateway integration is disabled.""" + if not settings.AGENTGATEWAY_ENABLED: + raise HTTPException(status_code=404, detail="Agentgateway integration is not enabled.") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _parse_base_url(base_url: str, provider_type: str) -> tuple[str, str]: + """Parse base_url into (host_override, path_override) for _provider.yaml. + + Returns (host_override, path_override) with appropriate endpoint suffix. + """ + if not base_url: + return "", "" + + parsed = urlparse(base_url.rstrip("/")) + host = parsed.hostname or "" + port = parsed.port + if host and not port: + port = 443 if parsed.scheme == "https" else 80 + host_override = f"{host}:{port}" if host else "" + path_override = parsed.path or "" + + # Append endpoint suffix + if provider_type in ("openai_compatible", "glm", "openai"): + if path_override and not path_override.endswith("/chat/completions"): + path_override = path_override.rstrip("/") + "/chat/completions" + elif provider_type == "anthropic": + if path_override and not path_override.endswith("/messages"): + path_override = path_override.rstrip("/") + "/messages" + + return host_override, path_override + + +ANTHROPIC_MODELS = [ + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-sonnet-4-20250514", + "claude-opus-4-0-20250514", + "claude-haiku-4-5-20251001", + "claude-3-5-sonnet-20241022", +] + + +async def _fetch_provider_models(provider: str) -> list[dict]: + """Fetch models from a provider using its stored API key.""" + from pathlib import Path + + from services.agentgateway_config import get_provider_config + + agw_dir = Path(settings.AGENTGATEWAY_DIR) + key_path = agw_dir / "keys" / f"{provider}.key" + + if not key_path.exists(): + raise HTTPException(status_code=404, detail=f"No API key found for provider '{provider}'.") + + # Decrypt API key + fernet = Fernet(settings.FIELD_ENCRYPTION_KEY.encode()) + api_key = fernet.decrypt(key_path.read_bytes()).decode() + + # Read provider config for base URL + try: + config = get_provider_config(provider) + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Provider '{provider}' not found.") + + host = config.get("hostOverride", "").replace(":443", "").replace(":80", "") + + # Determine provider type from config + provider_cfg = config.get("provider", {}) + is_anthropic = "anthropic" in provider_cfg + + if is_anthropic: + # Anthropic: use hardcoded list (Anthropic /v1/models requires special auth) + return [{"id": m, "name": m} for m in ANTHROPIC_MODELS] + + # OpenAI-compatible: call /v1/models + base_host = host or f"api.{provider}.com" + async with httpx.AsyncClient() as client: + resp = await client.get( + f"https://{base_host}/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=10.0, + ) + resp.raise_for_status() + data = resp.json() + return [{"id": m["id"], "name": m.get("name", m["id"])} for m in data.get("data", [])] + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.post("/", status_code=201) +def create_provider( + payload: CreateProviderIn, + _admin: UserProfile = Depends(require_admin), +): + """Create a new provider: write API key, add provider config, reassemble.""" + _require_agentgateway() + + from services.agentgateway_config import ( + add_provider, + reassemble_config, + restart_agentgateway, + write_provider_key, + ) + + host_override, path_override = _parse_base_url(payload.base_url, payload.provider_type) + + try: + write_provider_key(payload.provider, payload.api_key) + add_provider( + provider=payload.provider, + provider_type=payload.provider_type, + host_override=host_override, + path_override=path_override, + ) + reassemble_config() + restart_agentgateway() # New key requires restart to load env var + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) + + return { + "provider": payload.provider, + "provider_type": payload.provider_type, + "models": [], + } + + +@router.get("/") +def list_providers_endpoint( + _admin: UserProfile = Depends(require_admin), +): + """List all configured providers with their models.""" + _require_agentgateway() + + from services.agentgateway_config import ( + get_provider_config, + list_models, + list_providers, + ) + + providers = list_providers() + result = [] + for p in providers: + try: + config = get_provider_config(p) + except FileNotFoundError: + continue + + # Determine provider_type from config + provider_cfg = config.get("provider", {}) + if "anthropic" in provider_cfg: + provider_type = "anthropic" + else: + provider_type = "openai" + + models = list_models(p) + result.append({ + "provider": p, + "provider_type": provider_type, + "models": models, + }) + + return result + + +@router.delete("/{provider}/", status_code=204) +def delete_provider( + provider: str, + _admin: UserProfile = Depends(require_admin), +): + """Remove a provider and all its models.""" + _require_agentgateway() + + from services.agentgateway_config import remove_provider, restart_agentgateway + + try: + remove_provider(provider) + restart_agentgateway() # Key removed, restart to clean env vars + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) + + +@router.get("/{provider}/fetch-models/") +async def fetch_models( + provider: str, + _admin: UserProfile = Depends(require_admin), +): + """Fetch available models from the provider's API directly.""" + _require_agentgateway() + + try: + models = await _fetch_provider_models(provider) + except httpx.HTTPStatusError as exc: + raise HTTPException( + status_code=502, + detail=f"Provider API returned {exc.response.status_code}: {exc.response.text[:500]}", + ) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Failed to fetch models: {exc}") + + return models + + +@router.post("/{provider}/models/", status_code=201) +def add_models( + provider: str, + payload: AddModelsIn, + _admin: UserProfile = Depends(require_admin), +): + """Add models to a provider (batch). Reassembles config once at the end.""" + _require_agentgateway() + + from services.agentgateway_config import ( + add_model, + list_models, + reassemble_config, + ) + + try: + for m in payload.models: + add_model( + provider=provider, + model_slug=m["slug"], + model_name=m["model_name"], + reassemble=False, + ) + + reassemble_config() + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) + + models = list_models(provider) + return { + "provider": provider, + "models": models, + } + + +@router.delete("/{provider}/models/{model_slug}/", status_code=204) +def delete_model( + provider: str, + model_slug: str, + _admin: UserProfile = Depends(require_admin), +): + """Remove a single model from a provider.""" + _require_agentgateway() + + from services.agentgateway_config import remove_model + + try: + remove_model(provider, model_slug) + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) + + +@router.get("/{provider}/models/") +def list_models_endpoint( + provider: str, + _admin: UserProfile = Depends(require_admin), +): + """List configured models for a provider.""" + _require_agentgateway() + + from services.agentgateway_config import list_models + + return list_models(provider) diff --git a/platform/cli/__main__.py b/platform/cli/__main__.py index 3b40880d..610c7d7e 100644 --- a/platform/cli/__main__.py +++ b/platform/cli/__main__.py @@ -281,6 +281,331 @@ def cmd_apply_fixture(args: argparse.Namespace) -> None: db.close() +def _resolve_provider_name(credential) -> str: + """Derive provider name from credential. Must NOT contain hyphens.""" + if credential.provider_type in ("openai", "anthropic", "glm"): + return credential.provider_type + # openai_compatible: sanitize the credential name + base_name = credential.base_credentials.name or f"custom{credential.base_credentials_id}" + # Remove hyphens (breaks route parsing), lowercase, replace spaces/underscores + sanitized = base_name.lower().replace(" ", "_").replace("-", "_") + # Remove non-alphanumeric except underscores + sanitized = "".join(c for c in sanitized if c.isalnum() or c == "_") + return sanitized or f"custom{credential.base_credentials_id}" + + +def _model_to_slug(model_name: str) -> str: + """Convert model name to a filesystem-safe slug.""" + slug = model_name.lower().replace(" ", "-").replace("/", "-").replace(":", "-") + # Keep only alphanumeric, hyphens, dots + slug = "".join(c for c in slug if c.isalnum() or c in "-.") + return slug or "default" + + +def _parse_base_url(base_url: str, provider_type: str = "") -> tuple[str, str]: + """Parse a base_url into (host:port, path) for agentgateway overrides. + + Always includes the port (443 for HTTPS, 80 for HTTP) even when not + explicitly present in the URL. Appends the appropriate endpoint suffix + based on provider_type. + """ + from urllib.parse import urlparse + + if not base_url or not base_url.strip(): + return "", "" + + parsed = urlparse(base_url.rstrip("/")) + host = parsed.hostname or "" + port = parsed.port + if host and not port: + port = 443 if parsed.scheme == "https" else 80 + host_override = f"{host}:{port}" if host else "" + path_override = parsed.path or "" + + # Append endpoint suffix + if provider_type in ("openai_compatible", "glm", "openai"): + if path_override and not path_override.endswith("/chat/completions"): + path_override = path_override.rstrip("/") + "/chat/completions" + elif provider_type == "anthropic": + if path_override and not path_override.endswith("/messages"): + path_override = path_override.rstrip("/") + "/messages" + + return host_override, path_override + + +def cmd_migrate_credentials(args: argparse.Namespace) -> None: + """Migrate existing LLM credentials from DB to agentgateway provider/model structure.""" + from pathlib import Path + + from database import SessionLocal + from models.credential import BaseCredential, LLMProviderCredential + from models.node import BaseComponentConfig + from services.agentgateway_config import ( + add_model, + add_provider, + list_providers, + reassemble_config, + remove_provider_key, + write_provider_key, + ) + + if args.rollback: + _rollback_migration(args) + return + + db = SessionLocal() + try: + # Query all LLM credentials with their base credential (for name) + credentials = ( + db.query(LLMProviderCredential) + .join(BaseCredential, LLMProviderCredential.base_credentials_id == BaseCredential.id) + .all() + ) + + if not credentials: + print(json.dumps({"migrated": 0, "providers": 0, "models": 0, "message": "No LLM credentials found"})) + return + + from config import settings + + agw_dir = Path(settings.AGENTGATEWAY_DIR) if settings.AGENTGATEWAY_DIR else None + if not agw_dir and not args.dry_run: + print(json.dumps({"error": "AGENTGATEWAY_DIR is not set"}), file=sys.stderr) + sys.exit(1) + + # Group credentials by provider + # Each provider gets one key + one _provider.yaml; each credential+model gets a model file + providers_seen: dict[str, list] = {} # provider_name -> [credential, ...] + for cred in credentials: + provider_name = _resolve_provider_name(cred) + providers_seen.setdefault(provider_name, []).append(cred) + + providers_created = 0 + models_created = 0 + skipped = 0 + + # Map base_credentials_id -> (provider_name, model_slug) for --populate-routes + route_map: dict[int, str] = {} + + for provider_name, creds in providers_seen.items(): + # Use the first credential for provider-level config (key, host, path) + first_cred = creds[0] + + # Check for existing provider dir/key + if agw_dir and not args.dry_run: + provider_dir_exists = (agw_dir / "config.d" / "backends" / provider_name).exists() + key_exists = (agw_dir / "keys" / f"{provider_name}.key").exists() + else: + provider_dir_exists = False + key_exists = False + + provider_already_exists = provider_dir_exists or key_exists + + if provider_already_exists and not args.force: + skipped += len(creds) + for c in creds: + print( + f"SKIP: {c.base_credentials.name} -> provider={provider_name} " + f"(already exists, use --force to overwrite)", + file=sys.stderr, + ) + continue + + # Parse base_url for host/path overrides + host_override, path_override = _parse_base_url( + first_cred.base_url or "", first_cred.provider_type + ) + + if args.dry_run: + print( + f"DRY-RUN: provider={provider_name}, " + f"type={first_cred.provider_type}, " + f"host_override={host_override!r}, path_override={path_override!r}", + file=sys.stderr, + ) + providers_created += 1 + for cred in creds: + model_name = cred.base_credentials.name or "default" + model_slug = _model_to_slug(model_name) + print( + f"DRY-RUN: model={model_slug} (name={model_name})", + file=sys.stderr, + ) + models_created += 1 + route_map[cred.base_credentials_id] = f"{provider_name}-{model_slug}" + continue + + # Write encrypted key file (one per provider, using first credential's key) + write_provider_key(provider_name, first_cred.api_key) + + # Write _provider.yaml + add_provider( + provider=provider_name, + provider_type=first_cred.provider_type, + host_override=host_override, + path_override=path_override, + ) + providers_created += 1 + + # Write model files for each credential + for cred in creds: + model_name = cred.base_credentials.name or "default" + model_slug = _model_to_slug(model_name) + + # Check if model file exists + if agw_dir: + model_file_exists = ( + agw_dir / "config.d" / "backends" / provider_name / f"{model_slug}.yaml" + ).exists() + else: + model_file_exists = False + + if model_file_exists and not args.force: + skipped += 1 + print( + f"SKIP: model {model_slug} in {provider_name} (already exists)", + file=sys.stderr, + ) + continue + + add_model( + provider=provider_name, + model_slug=model_slug, + model_name=model_name, + reassemble=False, + ) + models_created += 1 + route_map[cred.base_credentials_id] = f"{provider_name}-{model_slug}" + + # Single reassemble at the end + if not args.dry_run and (providers_created > 0 or models_created > 0): + reassemble_config() + + # Optionally populate backend_route on component_configs + routes_updated = 0 + if args.populate_routes and route_map and not args.dry_run: + configs = ( + db.query(BaseComponentConfig) + .filter(BaseComponentConfig.llm_credential_id.isnot(None)) + .all() + ) + for cfg in configs: + route = route_map.get(cfg.llm_credential_id) + if route: + cfg.backend_route = route + routes_updated += 1 + if routes_updated: + db.commit() + + result = { + "providers": providers_created, + "models": models_created, + "skipped": skipped, + "routes_updated": routes_updated, + } + print(json.dumps(result)) + summary_parts = [ + f"{providers_created} providers created", + f"{models_created} models created", + ] + if skipped: + summary_parts.append(f"{skipped} skipped") + if routes_updated: + summary_parts.append(f"{routes_updated} routes updated") + print(", ".join(summary_parts), file=sys.stderr) + except Exception: + raise + finally: + db.close() + + +def _rollback_migration(args: argparse.Namespace) -> None: + """Reverse a credential migration: delete provider dirs + keys, reassemble, disable.""" + import shutil + from pathlib import Path + + from config import settings + from database import SessionLocal + from models.node import BaseComponentConfig + from services.agentgateway_config import list_providers, reassemble_config, remove_provider_key + + agw_dir = Path(settings.AGENTGATEWAY_DIR) if settings.AGENTGATEWAY_DIR else None + if not agw_dir: + print(json.dumps({"error": "AGENTGATEWAY_DIR is not set"}), file=sys.stderr) + sys.exit(1) + + providers = list_providers() + if not providers: + print(json.dumps({"rolled_back": 0, "message": "No providers found"})) + return + + removed = 0 + for name in providers: + if args.dry_run: + print(f"DRY-RUN: would remove provider={name} (directory + key)", file=sys.stderr) + removed += 1 + continue + + provider_dir = agw_dir / "config.d" / "backends" / name + if provider_dir.exists(): + shutil.rmtree(provider_dir) + remove_provider_key(name) + removed += 1 + + if not args.dry_run and removed > 0: + reassemble_config() + + # Set AGENTGATEWAY_ENABLED=false in .env + env_path = Path(__file__).resolve().parent.parent.parent / ".env" + _set_env_var(env_path, "AGENTGATEWAY_ENABLED", "false") + + # Null out backend_route on all component_configs if --populate-routes was used + routes_cleared = 0 + if args.populate_routes and not args.dry_run: + db = SessionLocal() + try: + configs = ( + db.query(BaseComponentConfig) + .filter(BaseComponentConfig.backend_route.isnot(None)) + .all() + ) + for cfg in configs: + cfg.backend_route = None + routes_cleared += 1 + if routes_cleared: + db.commit() + finally: + db.close() + + result = {"rolled_back": removed, "routes_cleared": routes_cleared} + print(json.dumps(result)) + print(f"{removed} providers removed", file=sys.stderr) + if routes_cleared: + print(f"{routes_cleared} backend_route values cleared", file=sys.stderr) + if not args.dry_run: + print("Set AGENTGATEWAY_ENABLED=false in .env", file=sys.stderr) + print("Please restart Pipelit to apply changes.", file=sys.stderr) + + +def _set_env_var(env_path: Path, key: str, value: str) -> None: + """Set or update an environment variable in a .env file.""" + from pathlib import Path + + if env_path.exists(): + lines = env_path.read_text().splitlines() + found = False + for i, line in enumerate(lines): + if line.startswith(f"{key}=") or line.startswith(f"{key} ="): + lines[i] = f"{key}={value}" + found = True + break + if not found: + lines.append(f"{key}={value}") + env_path.write_text("\n".join(lines) + "\n") + else: + env_path.write_text(f"{key}={value}\n") + + def cmd_import_fixture(args: argparse.Namespace) -> None: """Import a workflow from a fixture JSON file.""" from database import SessionLocal @@ -465,6 +790,15 @@ def main() -> None: sp_import = sub.add_parser("import-fixture", help="Import a workflow from a fixture JSON file") sp_import.add_argument("file", help="Path to fixture JSON file") + sp_migrate = sub.add_parser( + "migrate-credentials", + help="Migrate LLM credentials from DB to agentgateway filesystem", + ) + sp_migrate.add_argument("--rollback", action="store_true", help="Reverse the migration") + sp_migrate.add_argument("--force", action="store_true", help="Overwrite existing provider dirs and key files") + sp_migrate.add_argument("--dry-run", action="store_true", help="Print what would be done without writing files") + sp_migrate.add_argument("--populate-routes", action="store_true", help="Set backend_route on existing component_configs") + args = parser.parse_args() if args.command == "setup" and not args.password: @@ -479,6 +813,8 @@ def main() -> None: cmd_apply_fixture(args) elif args.command == "import-fixture": cmd_import_fixture(args) + elif args.command == "migrate-credentials": + cmd_migrate_credentials(args) except SystemExit: raise except Exception as exc: diff --git a/platform/config.py b/platform/config.py index d8fa457b..4ed0b092 100644 --- a/platform/config.py +++ b/platform/config.py @@ -134,6 +134,12 @@ class Settings(BaseSettings): WORKSPACE_DIR: str = "" # default: ~/.config/pipelit/workspaces/default (resolved at runtime) ROOTFS_DIR: str = "" # default: {pipelit_dir}/rootfs/ (resolved at runtime) + # -- agentgateway integration -- + JWT_PRIVATE_KEY: str = "" # PEM-encoded EC private key, generated by `plit init` + AGENTGATEWAY_URL: str = "" # e.g. http://localhost:4000 + AGENTGATEWAY_ENABLED: bool = False # feature flag for gradual rollout + AGENTGATEWAY_DIR: str = "" # path to agentgateway installation directory + model_config = ConfigDict( env_file=str(BASE_DIR.parent / ".env"), env_file_encoding="utf-8", diff --git a/platform/frontend/src/App.tsx b/platform/frontend/src/App.tsx index 121c870c..c935995f 100644 --- a/platform/frontend/src/App.tsx +++ b/platform/frontend/src/App.tsx @@ -14,6 +14,7 @@ import SettingsPage from "@/features/settings/SettingsPage" import MemoriesPage from "@/features/memories/MemoriesPage" import WorkspacesPage from "@/features/workspaces/WorkspacesPage" import WorkspaceDetailPage from "@/features/workspaces/WorkspaceDetailPage" +import ProvidersPage from "@/features/providers/ProvidersPage" const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } }, @@ -36,6 +37,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/platform/frontend/src/api/available_models.ts b/platform/frontend/src/api/available_models.ts new file mode 100644 index 00000000..3b9a724a --- /dev/null +++ b/platform/frontend/src/api/available_models.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query" +import { apiFetch } from "./client" +import type { AvailableModel } from "@/types/models" + +export function useAvailableModels() { + return useQuery({ + queryKey: ["available-models"], + queryFn: () => apiFetch("/available-models/"), + }) +} diff --git a/platform/frontend/src/api/providers.ts b/platform/frontend/src/api/providers.ts new file mode 100644 index 00000000..4ab15512 --- /dev/null +++ b/platform/frontend/src/api/providers.ts @@ -0,0 +1,62 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { apiFetch } from "./client" +import type { Provider, FetchedModel, ProviderModel } from "@/types/models" + +export function useProviders() { + return useQuery({ + queryKey: ["providers"], + queryFn: () => apiFetch("/providers/"), + }) +} + +export function useCreateProvider() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (data: { provider: string; provider_type: string; api_key: string; base_url: string }) => + apiFetch("/providers/", { method: "POST", body: JSON.stringify(data) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["providers"] }), + }) +} + +export function useDeleteProvider() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (provider: string) => + apiFetch(`/providers/${provider}/`, { method: "DELETE" }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["providers"] }), + }) +} + +export function useFetchProviderModels(provider: string) { + return useQuery({ + queryKey: ["provider-models", provider], + queryFn: () => apiFetch(`/providers/${provider}/fetch-models/`), + enabled: false, // only fetch on demand + }) +} + +export function useProviderModels(provider: string) { + return useQuery({ + queryKey: ["provider-configured-models", provider], + queryFn: () => apiFetch(`/providers/${provider}/models/`), + enabled: !!provider, + }) +} + +export function useAddModels() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ provider, models }: { provider: string; models: { slug: string; model_name: string }[] }) => + apiFetch(`/providers/${provider}/models/`, { method: "POST", body: JSON.stringify({ models }) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["providers"] }), + }) +} + +export function useDeleteModel() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ provider, modelSlug }: { provider: string; modelSlug: string }) => + apiFetch(`/providers/${provider}/models/${modelSlug}/`, { method: "DELETE" }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["providers"] }), + }) +} diff --git a/platform/frontend/src/components/layout/AppLayout.tsx b/platform/frontend/src/components/layout/AppLayout.tsx index 26c69af7..c2df1a3b 100644 --- a/platform/frontend/src/components/layout/AppLayout.tsx +++ b/platform/frontend/src/components/layout/AppLayout.tsx @@ -17,11 +17,13 @@ import { ChevronRight, User, Settings, + Server, } from "lucide-react" const navItems = [ { to: "/", icon: LayoutDashboard, label: "Workflows" }, { to: "/credentials", icon: KeyRound, label: "Credentials" }, + { to: "/providers", icon: Server, label: "Providers" }, { to: "/workspaces", icon: FolderOpen, label: "Workspaces" }, { to: "/executions", icon: Activity, label: "Executions" }, { to: "/memories", icon: Brain, label: "Memories" }, diff --git a/platform/frontend/src/features/credentials/CredentialsPage.tsx b/platform/frontend/src/features/credentials/CredentialsPage.tsx index 4c4caca8..baee5336 100644 --- a/platform/frontend/src/features/credentials/CredentialsPage.tsx +++ b/platform/frontend/src/features/credentials/CredentialsPage.tsx @@ -1,5 +1,7 @@ import { useState } from "react" +import { Link } from "react-router-dom" import { useCredentials, useCreateCredential, useUpdateCredential, useDeleteCredential, useTestCredential, useBatchDeleteCredentials, useActivateCredential, useDeactivateCredential } from "@/api/credentials" +import { useAvailableModels } from "@/api/available_models" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" @@ -10,12 +12,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Badge } from "@/components/ui/badge" import { PaginationControls } from "@/components/ui/pagination-controls" -import { Plus, Trash2, CheckCircle, XCircle, Loader2, Star, Power, PowerOff } from "lucide-react" +import { Plus, Trash2, CheckCircle, XCircle, Loader2, Star, Power, PowerOff, Info } from "lucide-react" import { format } from "date-fns" import type { CredentialType } from "@/types/models" const PAGE_SIZE = 50 -const CREDENTIAL_TYPES: CredentialType[] = ["llm", "gateway", "git", "tool"] +const ALL_CREDENTIAL_TYPES: CredentialType[] = ["llm", "gateway", "git", "tool"] const PROVIDER_TYPES = [ { value: "openai", label: "OpenAI" }, { value: "anthropic", label: "Anthropic" }, @@ -26,6 +28,11 @@ const PROVIDER_TYPES = [ export default function CredentialsPage() { const [page, setPage] = useState(1) const { data, isLoading } = useCredentials({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }) + const { data: availableModels } = useAvailableModels() + const agentgatewayEnabled = (availableModels?.length ?? 0) > 0 + const credentialTypes = agentgatewayEnabled + ? ALL_CREDENTIAL_TYPES.filter((t) => t !== "llm") + : ALL_CREDENTIAL_TYPES const credentials = data?.items const total = data?.total ?? 0 const createCredential = useCreateCredential() @@ -74,7 +81,7 @@ export default function CredentialsPage() { else if (credType === "tool") detail = { tool_type: toolType, config: { url: toolUrl }, is_preferred: toolPreferred } await createCredential.mutateAsync({ name, credential_type: credType, detail }) setOpen(false) - setCredType("llm") + setCredType(agentgatewayEnabled ? "gateway" : "llm") setProviderType("openai_compatible") setName("") setApiKey("") @@ -135,10 +142,23 @@ export default function CredentialsPage() { Delete Selected ({selectedIds.size}) )} - + + {agentgatewayEnabled && ( +
+ + + LLM providers are managed on the{" "} + + Providers page + + . + +
+ )} + @@ -157,8 +177,9 @@ export default function CredentialsPage() { {credentials?.map((cred) => { const tr = testResults[cred.id] + const isLlmDimmed = agentgatewayEnabled && cred.credential_type === "llm" return ( - + toggleSelect(cred.id)} /> @@ -247,7 +268,7 @@ export default function CredentialsPage() { diff --git a/platform/frontend/src/features/providers/ProvidersPage.tsx b/platform/frontend/src/features/providers/ProvidersPage.tsx new file mode 100644 index 00000000..3ddba1fc --- /dev/null +++ b/platform/frontend/src/features/providers/ProvidersPage.tsx @@ -0,0 +1,329 @@ +import { useState } from "react" +import { useProviders, useCreateProvider, useDeleteProvider, useFetchProviderModels, useAddModels, useDeleteModel } from "@/api/providers" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Plus, Trash2, ChevronDown, ChevronRight, Loader2, Download } from "lucide-react" +import { toast } from "sonner" +import type { Provider, FetchedModel } from "@/types/models" + +const PROVIDER_TYPES = [ + { value: "openai", label: "OpenAI" }, + { value: "anthropic", label: "Anthropic" }, + { value: "openai_compatible", label: "OpenAI Compatible" }, + { value: "glm", label: "GLM (Z.AI)" }, +] + +function ProviderRow({ provider }: { provider: Provider }) { + const [expanded, setExpanded] = useState(false) + const [deleteConfirm, setDeleteConfirm] = useState(false) + const [addModelsOpen, setAddModelsOpen] = useState(false) + const [fetchedModels, setFetchedModels] = useState([]) + const [selectedModels, setSelectedModels] = useState>(new Set()) + const [fetching, setFetching] = useState(false) + + const deleteProvider = useDeleteProvider() + const deleteModel = useDeleteModel() + const addModels = useAddModels() + const fetchModels = useFetchProviderModels(provider.provider) + + async function handleFetchModels() { + setFetching(true) + try { + const result = await fetchModels.refetch() + if (result.data) { + setFetchedModels(result.data) + setSelectedModels(new Set()) + setAddModelsOpen(true) + } + } catch (err) { + toast.error("Failed to fetch models from provider") + } finally { + setFetching(false) + } + } + + function toggleModel(id: string) { + setSelectedModels((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + } + + async function handleAddModels() { + const models = fetchedModels + .filter((m) => selectedModels.has(m.id)) + .map((m) => ({ slug: m.id.replace(/[^a-zA-Z0-9_-]/g, "-"), model_name: m.id })) + if (models.length === 0) return + try { + await addModels.mutateAsync({ provider: provider.provider, models }) + setAddModelsOpen(false) + toast.success(`Added ${models.length} model(s)`) + } catch (err) { + toast.error("Failed to add models") + } + } + + function handleDeleteModel(modelSlug: string) { + deleteModel.mutate( + { provider: provider.provider, modelSlug }, + { onSuccess: () => toast.success("Model deleted") }, + ) + } + + function handleDeleteProvider() { + deleteProvider.mutate(provider.provider, { + onSuccess: () => { setDeleteConfirm(false); toast.success("Provider deleted") }, + }) + } + + return ( + <> + setExpanded(!expanded)}> + + {expanded ? : } + + {provider.provider} + {provider.provider_type} + {provider.models.length} model(s) + e.stopPropagation()}> +
+ + +
+
+
+ + {expanded && provider.models.length > 0 && ( + + + +
+
+ + + Model Name + Slug + Route + + + + + {provider.models.map((model) => ( + + {model.model_name} + {model.slug} + {model.route} + + + + + ))} + +
+ + + + )} + + {expanded && provider.models.length === 0 && ( + + + + No models configured. Use "Fetch Models" to discover and add models. + + + )} + + {/* Delete provider confirmation */} + + + Delete Provider +

Are you sure you want to delete provider "{provider.provider}" and all its models? This cannot be undone.

+ + + + +
+
+ + {/* Add models dialog */} + + + Add Models from {provider.provider} +
+ {fetchedModels.length === 0 && ( +

No models available from this provider.

+ )} + {fetchedModels.map((model) => { + const alreadyAdded = provider.models.some((m) => m.model_name === model.id) + return ( + + ) + })} +
+ + + + +
+
+ + ) +} + +export default function ProvidersPage() { + const { data: providers, isLoading, isError } = useProviders() + const createProvider = useCreateProvider() + const [open, setOpen] = useState(false) + const [providerName, setProviderName] = useState("") + const [providerType, setProviderType] = useState("openai") + const [apiKey, setApiKey] = useState("") + const [baseUrl, setBaseUrl] = useState("") + + async function handleCreate(e: React.FormEvent) { + e.preventDefault() + try { + await createProvider.mutateAsync({ + provider: providerName, + provider_type: providerType, + api_key: apiKey, + base_url: baseUrl, + }) + setOpen(false) + setProviderName("") + setProviderType("openai") + setApiKey("") + setBaseUrl("") + toast.success("Provider created") + } catch (err) { + toast.error("Failed to create provider") + } + } + + if (isLoading) { + return ( +
+
+
+ {[...Array(3)].map((_, i) =>
)} +
+
+ ) + } + + if (isError) { + return ( +
+

Providers & Models

+ + + Unable to load providers. The agentgateway may not be configured. + + +
+ ) + } + + return ( +
+
+

Providers & Models

+ +
+ + + + + + + + Provider + Type + Models + Actions + + + + {providers?.map((p) => ( + + ))} + {providers?.length === 0 && ( + + + No providers configured. Add a provider to get started. + + + )} + +
+
+
+ + {/* Add Provider dialog */} + + + Add Provider +
+
+ + setProviderName(e.target.value)} placeholder="my-openai" required /> +
+
+ + +
+
+ + setApiKey(e.target.value)} required /> +
+ {providerType === "openai_compatible" && ( +
+ + setBaseUrl(e.target.value)} placeholder="https://api.example.com/v1" /> +
+ )} + + + + +
+
+
+
+ ) +} diff --git a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx index 843790c1..7b8dd0e6 100644 --- a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx +++ b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query" import { useUpdateNode, useDeleteNode, useScheduleStart, useSchedulePause, useScheduleStop } from "@/api/nodes" import { useWorkflows } from "@/api/workflows" import { useCredentials, useCredentialModels } from "@/api/credentials" +import { useAvailableModels } from "@/api/available_models" import { useWorkspaces } from "@/api/workspaces" import { useManualExecute } from "@/api/executions" @@ -68,6 +69,7 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { const deleteNode = useDeleteNode(slug) const { data: credentials } = useCredentials() const { data: workspacesData } = useWorkspaces() + const { data: gatewayModels = [] } = useAvailableModels() const allCredentials = credentials?.items ?? [] const llmCredentials = allCredentials.filter((c) => c.credential_type === "llm") @@ -81,6 +83,7 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { const [extraConfig, setExtraConfig] = useState(JSON.stringify(node.config.extra_config, null, 2)) const [llmCredentialId, setLlmCredentialId] = useState(node.config.llm_credential_id?.toString() ?? "") const [modelName, setModelName] = useState(node.config.model_name ?? "") + const [backendRoute, setBackendRoute] = useState(node.config.backend_route ?? "") const [temperature, setTemperature] = useState(node.config.temperature?.toString() ?? "") const [maxTokens, setMaxTokens] = useState(node.config.max_tokens?.toString() ?? "") const [topP, setTopP] = useState(node.config.top_p?.toString() ?? "") @@ -244,7 +247,7 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { const manualExecute = useManualExecute(slug, node.node_id) const credId = llmCredentialId ? Number(llmCredentialId) : undefined - const { data: availableModels } = useCredentialModels(credId) + const { data: credentialModels } = useCredentialModels(credId) const isLLMNode = node.component_type === "ai_model" const isAgentNode = node.component_type === "agent" || node.component_type === "deep_agent" @@ -370,6 +373,7 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { extra_config: parsedExtra, llm_credential_id: llmCredentialId ? Number(llmCredentialId) : null, model_name: modelName, + backend_route: backendRoute || null, temperature: temperature ? Number(temperature) : null, max_tokens: maxTokens ? Number(maxTokens) : null, top_p: topP ? Number(topP) : null, @@ -615,34 +619,59 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { {isLLMNode && ( <> -
- - -
-
- - {availableModels && availableModels.length > 0 ? ( - { + const model = gatewayModels.find((m) => m.route === route) + setBackendRoute(route) + setModelName(model?.model_name ?? "") + }} + > - {availableModels.map((m) => ( - {m.name} + {gatewayModels.map((m) => ( + + {m.provider} / {m.model_name} + ))} - ) : ( - setModelName(e.target.value)} placeholder="e.g. gpt-4o" className="text-xs" /> - )} -
+
+ ) : ( + <> +
+ + +
+
+ + {credentialModels && credentialModels.length > 0 ? ( + + ) : ( + setModelName(e.target.value)} placeholder="e.g. gpt-4o" className="text-xs" /> + )} +
+ + )}
diff --git a/platform/frontend/src/types/models.ts b/platform/frontend/src/types/models.ts index 644d15c2..0ec19b5f 100644 --- a/platform/frontend/src/types/models.ts +++ b/platform/frontend/src/types/models.ts @@ -53,7 +53,7 @@ export interface WorkflowCreate { name: string; slug: string; description?: stri export interface WorkflowUpdate { name?: string; slug?: string; description?: string; is_active?: boolean; is_public?: boolean; is_default?: boolean; max_execution_seconds?: number } // Node -export interface ComponentConfigData { system_prompt: string; extra_config: Record; llm_credential_id: number | null; model_name: string; temperature: number | null; max_tokens: number | null; frequency_penalty: number | null; presence_penalty: number | null; top_p: number | null; timeout: number | null; max_retries: number | null; response_format: Record | null; credential_id: number | null; is_active: boolean; priority: number; trigger_config: Record } +export interface ComponentConfigData { system_prompt: string; extra_config: Record; llm_credential_id: number | null; model_name: string; backend_route: string | null; temperature: number | null; max_tokens: number | null; frequency_penalty: number | null; presence_penalty: number | null; top_p: number | null; timeout: number | null; max_retries: number | null; response_format: Record | null; credential_id: number | null; is_active: boolean; priority: number; trigger_config: Record } export interface ScheduleJobInfo { id: string; status: string; run_count: number; error_count: number; current_repeat: number; current_retry: number; total_repeats: number; max_retries: number; timeout_seconds: number; interval_seconds: number; last_run_at: string | null; next_run_at: string | null; last_error: string | null; created_at: string | null } export interface WorkflowNode { id: number; node_id: string; label: string | null; component_type: ComponentType; is_entry_point: boolean; interrupt_before: boolean; interrupt_after: boolean; position_x: number; position_y: number; config: ComponentConfigData; subworkflow_id: number | null; code_block_id: number | null; updated_at: string; schedule_job?: ScheduleJobInfo | null } // node_id is intentionally optional — backend auto-generates "{type}_{hex}" when omitted. @@ -121,3 +121,9 @@ export type FilterRule = Rule // Checkpoints export interface Checkpoint { thread_id: string; checkpoint_ns: string; checkpoint_id: string; parent_checkpoint_id: string | null; step: number | null; source: string | null; blob_size: number } +// Provider & Model types for agentgateway +export interface ProviderModel { slug: string; model_name: string; route: string } +export interface Provider { provider: string; provider_type: string; models: ProviderModel[] } +export interface FetchedModel { id: string; name: string } +export interface AvailableModel { route: string; provider: string; model_slug: string; model_name: string } + diff --git a/platform/models/node.py b/platform/models/node.py index 1be37950..adcb08da 100644 --- a/platform/models/node.py +++ b/platform/models/node.py @@ -36,6 +36,9 @@ class BaseComponentConfig(Base): DateTime, server_default=func.now(), onupdate=func.now() ) + # agentgateway route name (e.g. "venice-glm-4.7"); None = use llm_credential_id + backend_route: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + # STI fields — ModelComponentConfig llm_credential_id: Mapped[int | None] = mapped_column( ForeignKey("credentials.id", ondelete="SET NULL"), nullable=True diff --git a/platform/requirements.txt b/platform/requirements.txt index 5ef7d242..1857cda1 100644 --- a/platform/requirements.txt +++ b/platform/requirements.txt @@ -22,6 +22,7 @@ honcho>=2.0 deepagents>=0.4 requests>=2.31 jinja2>=3.1 +PyJWT>=2.8.0 pyotp>=2.9 gherkin-official>=29.0.0 diff --git a/platform/schemas/credential.py b/platform/schemas/credential.py index b5f3c18a..a32a5efe 100644 --- a/platform/schemas/credential.py +++ b/platform/schemas/credential.py @@ -26,6 +26,7 @@ class CredentialOut(BaseModel): detail: str | dict | None = None created_at: datetime updated_at: datetime + agentgateway_backend: str | None = None model_config = {"from_attributes": True} diff --git a/platform/schemas/node.py b/platform/schemas/node.py index 5b76b915..83ee7e90 100644 --- a/platform/schemas/node.py +++ b/platform/schemas/node.py @@ -57,6 +57,7 @@ class ComponentConfigData(BaseModel): extra_config: dict = {} llm_credential_id: int | None = None model_name: str = "" + backend_route: str | None = None temperature: float | None = None max_tokens: int | None = None frequency_penalty: float | None = None diff --git a/platform/services/agentgateway_client.py b/platform/services/agentgateway_client.py new file mode 100644 index 00000000..b0509862 --- /dev/null +++ b/platform/services/agentgateway_client.py @@ -0,0 +1,130 @@ +"""LangChain LLM client factory routing through agentgateway. + +Creates ``BaseChatModel`` instances whose ``base_url`` points at the +agentgateway LLM proxy (port 4000 by default). The JWT issued by +``jwt_issuer.mint_llm_token()`` is passed as the Bearer token. +agentgateway validates it, enforces CEL authorization rules, then +proxies the request to the upstream provider using the real API key +stored in ``config.d/backends/.yaml``. + +**Auth header behaviour by provider:** + +* **OpenAI / GLM / openai_compatible** — ``ChatOpenAI(api_key=jwt)`` + sends ``Authorization: Bearer ``. Works natively. + +* **Anthropic** — The Anthropic SDK sends the api_key as ``x-api-key``, + *not* ``Authorization: Bearer``. agentgateway validates the JWT from + the ``Authorization`` header. To work around this we pass the JWT via + ``default_headers={"Authorization": "Bearer "}`` and set + ``api_key`` to a dummy value (``"jwt-via-bearer"``). The upstream + ``x-api-key`` header is replaced by agentgateway's ``backendAuth`` + before proxying, so the dummy value never reaches the real API. +""" + +from __future__ import annotations + +import logging + +import httpx +from langchain_core.language_models import BaseChatModel + +logger = logging.getLogger(__name__) + +# Supported provider types — must match credential.provider_type values. +_OPENAI_LIKE_PROVIDERS = {"openai", "glm", "openai_compatible", "openai-compatible"} + + +def create_proxied_llm( + jwt_token: str, + provider_type: str, + backend_name: str, + model: str, + agentgateway_url: str, + **kwargs, +) -> BaseChatModel: + """Create a LangChain chat model that routes through agentgateway. + + Parameters + ---------- + jwt_token: + Short-lived ES256 JWT from ``jwt_issuer.mint_llm_token()``. + provider_type: + One of ``"openai"``, ``"anthropic"``, ``"glm"``, + ``"openai_compatible"``. + backend_name: + agentgateway backend name used for path-based routing. + URL becomes ``{agentgateway_url}/{backend_name}/...``. + model: + Model name, e.g. ``"gpt-4o"`` or ``"claude-sonnet-4-20250514"``. + agentgateway_url: + agentgateway LLM listener address, e.g. ``http://localhost:4000``. + **kwargs: + Forwarded to the LangChain constructor (``temperature``, + ``max_tokens``, etc.). + + Returns + ------- + BaseChatModel + A LangChain chat model pointing at the agentgateway proxy. + """ + base_url = f"{agentgateway_url.rstrip('/')}/{backend_name}" + + if provider_type == "anthropic": + from langchain_anthropic import ChatAnthropic + + # ChatAnthropic → Anthropic SDK sends api_key as ``x-api-key``. + # agentgateway expects the JWT in the ``Authorization: Bearer`` header. + # We inject the Bearer header via ``default_headers`` and use a + # placeholder api_key (the real key is injected by agentgateway). + return ChatAnthropic( + api_key="jwt-via-bearer", + base_url=base_url, + model=model, + default_headers={"Authorization": f"Bearer {jwt_token}"}, + **kwargs, + ) + + if provider_type not in _OPENAI_LIKE_PROVIDERS: + raise ValueError( + f"Unsupported provider type for agentgateway proxy: {provider_type!r}. " + f"Expected one of: openai, anthropic, glm, openai_compatible." + ) + + # OpenAI-like providers: ChatOpenAI sends api_key as Bearer natively. + from langchain_openai import ChatOpenAI + + return ChatOpenAI( + api_key=jwt_token, + base_url=base_url, + model=model, + **kwargs, + ) + + +async def check_agentgateway_health(agentgateway_url: str) -> tuple[bool, str]: + """Check whether agentgateway is reachable. + + Returns ``(ok, message)`` where *ok* is ``True`` when the service + responds (even with 401/403 — that means JWT auth is enforced, which + is the expected healthy state). + """ + try: + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get(f"{agentgateway_url.rstrip('/')}/") + # 401/403 means agentgateway is up and enforcing JWT auth — healthy. + if resp.status_code in (401, 403): + return True, "agentgateway is running (JWT auth enforced)" + return True, f"agentgateway responded with {resp.status_code}" + except httpx.ConnectError: + return ( + False, + f"agentgateway at {agentgateway_url} is unreachable. " + "Check that agentgateway is running.", + ) + except httpx.TimeoutException: + return ( + False, + f"agentgateway at {agentgateway_url} timed out after 5 seconds.", + ) + except Exception as exc: + return False, f"agentgateway health check failed: {exc}" diff --git a/platform/services/agentgateway_config.py b/platform/services/agentgateway_config.py new file mode 100644 index 00000000..0daf8e41 --- /dev/null +++ b/platform/services/agentgateway_config.py @@ -0,0 +1,416 @@ +"""Agentgateway config writer service -- provider/model structure. + +Manages the config.d/backends/ directory tree where each provider gets a +subdirectory containing a shared ``_provider.yaml`` and one YAML file per +model. Assembly (assemble-config.sh) merges these into routes automatically. + +Directory layout:: + + config.d/backends/ + +-- venice/ + | +-- _provider.yaml # shared: provider type, hostOverride, pathOverride, backendAuth, backendTLS + | +-- glm-4.7.yaml # model: zai-org-glm-4.7 + | +-- deepseek-r1.yaml # model: deepseek-r1-0528 + keys/ + +-- venice.key # one Fernet-encrypted key per provider + +Encryption flow: + Write path: Pipelit admin action -> write_provider_key() -> Fernet encrypt -> keys/.key + Load path: start.sh -> decrypt_keys.py -> Fernet decrypt -> export env var -> exec agentgateway + +Key file naming convention (LOAD-BEARING): + keys/venice.key -> $VENICE_API_KEY -> backendAuth.key: ${VENICE_API_KEY} + keys/openai.key -> $OPENAI_API_KEY -> backendAuth.key: ${OPENAI_API_KEY} +""" + +from __future__ import annotations + +import fcntl +import os +import shutil +import subprocess +from pathlib import Path + +import yaml +from cryptography.fernet import Fernet + +from config import settings + + +def _get_fernet() -> Fernet: + """Return a Fernet instance using the platform's FIELD_ENCRYPTION_KEY.""" + key = settings.FIELD_ENCRYPTION_KEY + if not key: + raise RuntimeError("FIELD_ENCRYPTION_KEY is not set") + return Fernet(key.encode()) + + +def _agw_dir() -> Path: + """Return the agentgateway installation directory.""" + d = settings.AGENTGATEWAY_DIR + if not d: + raise RuntimeError("AGENTGATEWAY_DIR is not set") + return Path(d) + + +def _name_to_env_var(name: str) -> str: + """Convert a provider name to the env var name used by start.sh. + + Examples: + venice -> VENICE_API_KEY + openai -> OPENAI_API_KEY + my-backend -> MY_BACKEND_API_KEY + """ + return name.upper().replace("-", "_").replace(".", "_") + "_API_KEY" + + +def _build_provider_type(provider_type: str) -> dict: + """Build the provider config dict for a _provider.yaml. + + Supported types: openai, anthropic, glm, openai_compatible. + """ + if provider_type == "anthropic": + return {"anthropic": {}} + # openai, openai_compatible, glm all use the openAI provider key + return {"openAI": {}} + + +# --------------------------------------------------------------------------- +# Provider key management +# --------------------------------------------------------------------------- + + +def write_provider_key(provider: str, api_key: str) -> None: + """Write Fernet-encrypted API key to AGENTGATEWAY_DIR/keys/.key. + + Atomic write: write to .tmp, then os.rename(). + File permissions: 0o600. + """ + keys_dir = _agw_dir() / "keys" + keys_dir.mkdir(parents=True, exist_ok=True) + + encrypted = _get_fernet().encrypt(api_key.encode()) + tmp_path = keys_dir / f"{provider}.key.tmp" + final_path = keys_dir / f"{provider}.key" + + tmp_path.write_bytes(encrypted) + os.chmod(tmp_path, 0o600) + os.rename(tmp_path, final_path) + + +def remove_provider_key(provider: str) -> None: + """Remove key file for a provider.""" + key_path = _agw_dir() / "keys" / f"{provider}.key" + key_path.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# Provider management +# --------------------------------------------------------------------------- + + +def add_provider( + provider: str, + provider_type: str, + host_override: str = "", + path_override: str = "", +) -> None: + """Write _provider.yaml to config.d/backends//. + + Creates the provider directory if needed. + Does NOT trigger reassembly (caller should add models first, then reassemble). + """ + provider_dir = _agw_dir() / "config.d" / "backends" / provider + provider_dir.mkdir(parents=True, exist_ok=True) + + env_var_ref = f"${{{_name_to_env_var(provider)}}}" + + fragment: dict = { + "provider": _build_provider_type(provider_type), + "backendAuth": {"key": env_var_ref}, + "backendTLS": {}, + } + + if host_override: + fragment["hostOverride"] = host_override + if path_override: + fragment["pathOverride"] = path_override + + content = f"# {provider} provider config\n" + yaml.dump( + fragment, default_flow_style=False, sort_keys=False + ) + + tmp_path = provider_dir / "_provider.yaml.tmp" + final_path = provider_dir / "_provider.yaml" + + tmp_path.write_text(content) + os.rename(tmp_path, final_path) + + +def remove_provider(provider: str) -> None: + """Remove entire config.d/backends// directory + keys/.key, then reassemble.""" + provider_dir = _agw_dir() / "config.d" / "backends" / provider + if provider_dir.exists(): + shutil.rmtree(provider_dir) + remove_provider_key(provider) + reassemble_config() + + +def list_providers() -> list[str]: + """List provider names from config.d/backends/*/ subdirectories.""" + backends_dir = _agw_dir() / "config.d" / "backends" + if not backends_dir.exists(): + return [] + return sorted(d.name for d in backends_dir.iterdir() if d.is_dir()) + + +def get_provider_config(provider: str) -> dict: + """Read and return the _provider.yaml contents for a provider.""" + provider_file = _agw_dir() / "config.d" / "backends" / provider / "_provider.yaml" + if not provider_file.exists(): + raise FileNotFoundError(f"Provider config not found: {provider}") + return yaml.safe_load(provider_file.read_text()) + + +# --------------------------------------------------------------------------- +# Model management +# --------------------------------------------------------------------------- + + +def add_model( + provider: str, + model_slug: str, + model_name: str, + reassemble: bool = True, +) -> None: + """Write model file to config.d/backends//.yaml. + + Content is just: model: + Optionally triggers reassembly (set reassemble=False for batch adds). + """ + provider_dir = _agw_dir() / "config.d" / "backends" / provider + provider_dir.mkdir(parents=True, exist_ok=True) + + content = yaml.dump({"model": model_name}, default_flow_style=False) + + tmp_path = provider_dir / f"{model_slug}.yaml.tmp" + final_path = provider_dir / f"{model_slug}.yaml" + + tmp_path.write_text(content) + os.rename(tmp_path, final_path) + + if reassemble: + reassemble_config() + + +def remove_model(provider: str, model_slug: str) -> None: + """Remove model file, then reassemble.""" + model_path = ( + _agw_dir() / "config.d" / "backends" / provider / f"{model_slug}.yaml" + ) + model_path.unlink(missing_ok=True) + reassemble_config() + + +def list_models(provider: str) -> list[dict]: + """List models for a provider. + + Returns [{slug, model_name, route}] by scanning + config.d/backends//*.yaml (excluding _provider.yaml). + """ + provider_dir = _agw_dir() / "config.d" / "backends" / provider + if not provider_dir.exists(): + return [] + + models = [] + for f in sorted(provider_dir.glob("*.yaml")): + if f.name == "_provider.yaml": + continue + slug = f.stem + data = yaml.safe_load(f.read_text()) + model_name = data.get("model", "") if data else "" + models.append({ + "slug": slug, + "model_name": model_name, + "route": f"{provider}-{slug}", + }) + return models + + +def list_all_available_models() -> list[dict]: + """List ALL available models across ALL providers. + + Only includes providers that have a matching keys/.key file. + Returns [{route, provider, model_slug, model_name}]. + """ + agw = _agw_dir() + backends_dir = agw / "config.d" / "backends" + keys_dir = agw / "keys" + + if not backends_dir.exists(): + return [] + + result = [] + for provider_dir in sorted(backends_dir.iterdir()): + if not provider_dir.is_dir(): + continue + provider = provider_dir.name + + # Only include providers with a key file + if not keys_dir.exists() or not (keys_dir / f"{provider}.key").exists(): + continue + + for f in sorted(provider_dir.glob("*.yaml")): + if f.name == "_provider.yaml": + continue + slug = f.stem + data = yaml.safe_load(f.read_text()) + model_name = data.get("model", "") if data else "" + result.append({ + "route": f"{provider}-{slug}", + "provider": provider, + "model_slug": slug, + "model_name": model_name, + }) + + return result + + +# --------------------------------------------------------------------------- +# MCP server management +# --------------------------------------------------------------------------- + + +def add_mcp_server(name: str, config: dict) -> None: + """Write an MCP server target to config.d/mcp_servers/.yaml.""" + mcp_dir = _agw_dir() / "config.d" / "mcp_servers" + mcp_dir.mkdir(parents=True, exist_ok=True) + + tmp_path = mcp_dir / f"{name}.yaml.tmp" + final_path = mcp_dir / f"{name}.yaml" + + content = f"# {name} MCP server\n" + yaml.dump( + config, default_flow_style=False, sort_keys=False + ) + tmp_path.write_text(content) + os.rename(tmp_path, final_path) + + reassemble_config() + + +def remove_mcp_server(name: str) -> None: + """Remove an MCP server target, then reassemble.""" + path = _agw_dir() / "config.d" / "mcp_servers" / f"{name}.yaml" + path.unlink(missing_ok=True) + reassemble_config() + + +# --------------------------------------------------------------------------- +# Authorization rules +# --------------------------------------------------------------------------- + + +def update_rules(role: str, rules: list[str]) -> None: + """Write CEL rules to config.d/rules/.yaml.""" + rules_dir = _agw_dir() / "config.d" / "rules" + rules_dir.mkdir(parents=True, exist_ok=True) + + tmp_path = rules_dir / f"{role}.yaml.tmp" + final_path = rules_dir / f"{role}.yaml" + + content = f"# {role} role authorization rules\n" + yaml.dump( + rules, default_flow_style=False, sort_keys=False + ) + tmp_path.write_text(content) + os.rename(tmp_path, final_path) + + reassemble_config() + + +# --------------------------------------------------------------------------- +# Config assembly +# --------------------------------------------------------------------------- + + +def reassemble_config() -> None: + """Call assemble-config.sh to regenerate config.yaml from fragments. + + Uses file locking to prevent concurrent reassembly. + agentgateway hot-reloads within ~250ms. + """ + agw_dir = _agw_dir() + lock_path = agw_dir / ".config.lock" + + with open(lock_path, "w") as lockfile: + fcntl.flock(lockfile, fcntl.LOCK_EX) + try: + result = subprocess.run( + [str(agw_dir / "assemble-config.sh")], + capture_output=True, + text=True, + cwd=str(agw_dir), + env={**os.environ, "YQ": os.environ.get("YQ", "yq")}, + ) + if result.returncode != 0: + raise RuntimeError(f"Config assembly failed: {result.stderr}") + finally: + fcntl.flock(lockfile, fcntl.LOCK_UN) + + +def restart_agentgateway() -> None: + """Restart agentgateway by killing existing process and re-running start.sh. + + Required when new provider keys are added or existing keys are updated, + because env vars (decrypted from key files) are only loaded at startup. + + Not needed for model add/remove — config hot-reload handles that. + """ + import signal + import time + + agw_dir = _agw_dir() + start_script = agw_dir / "start.sh" + + if not start_script.exists(): + raise RuntimeError(f"start.sh not found at {start_script}") + + # Find and kill existing agentgateway process + try: + result = subprocess.run( + ["pgrep", "-f", "agentgateway.*config.yaml"], + capture_output=True, text=True, + ) + for pid_str in result.stdout.strip().split("\n"): + if pid_str.strip(): + try: + os.kill(int(pid_str.strip()), signal.SIGTERM) + except (ProcessLookupError, ValueError): + pass + except Exception: + pass # No process found — that's OK + + time.sleep(2) # Wait for graceful shutdown + + # Build env for start.sh + env = {**os.environ} + env["YQ"] = os.environ.get("YQ", "yq") + + # Find Python with cryptography for decrypt_keys.py + from config import settings + pipelit_dir = str(Path(settings.AGENTGATEWAY_DIR).parent / "pipelit") + venv_python = Path(pipelit_dir) / ".venv" / "bin" / "python3" + if venv_python.exists(): + env["PYTHON"] = str(venv_python) + + if settings.FIELD_ENCRYPTION_KEY: + env["FIELD_ENCRYPTION_KEY"] = settings.FIELD_ENCRYPTION_KEY + + # Start in background (detached) + subprocess.Popen( + [str(start_script)], + cwd=str(agw_dir), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) diff --git a/platform/services/jwt_issuer.py b/platform/services/jwt_issuer.py new file mode 100644 index 00000000..06c189d9 --- /dev/null +++ b/platform/services/jwt_issuer.py @@ -0,0 +1,75 @@ +"""JWT issuer service — mints short-lived ES256 tokens for agentgateway.""" + +from __future__ import annotations + +import time +import uuid + +import jwt + +from config import settings + +_TOKEN_LIFETIME_SECONDS = 60 +_KID = "pipelit-001" + + +class JWTIssuerError(Exception): + """Raised when token minting fails (e.g. missing or invalid private key).""" + + +def mint_llm_token( + user_profile_id: int, + role: str, + credential_id: int, + allowed_credentials: list[int] | None = None, +) -> str: + """Mint a short-lived ES256 JWT for a single LLM request through agentgateway. + + Parameters + ---------- + user_profile_id: + ``UserProfile.id`` — becomes the ``sub`` claim (as string per JWT convention). + role: + User role, e.g. ``"admin"`` or ``"normal"``. + credential_id: + ``BaseCredential.id`` of the credential used for this request. + allowed_credentials: + All credential IDs accessible to this user. Omitted from the token when *None*. + + Returns + ------- + str + Encoded JWT string. + + Raises + ------ + JWTIssuerError + If ``JWT_PRIVATE_KEY`` is empty or the key cannot be loaded. + """ + private_key_pem = settings.JWT_PRIVATE_KEY + if not private_key_pem: + raise JWTIssuerError( + "JWT_PRIVATE_KEY is not configured. " + "Run 'plit init' to generate the ES256 key pair." + ) + + now = int(time.time()) + payload: dict = { + "iss": "pipelit", + "aud": "agentgateway", + "sub": str(user_profile_id), + "iat": now, + "exp": now + _TOKEN_LIFETIME_SECONDS, + "jti": str(uuid.uuid4()), + "role": role, + "credential_id": credential_id, + } + if allowed_credentials is not None: + payload["allowed_credentials"] = allowed_credentials + + headers = {"kid": _KID} + + try: + return jwt.encode(payload, private_key_pem, algorithm="ES256", headers=headers) + except Exception as exc: + raise JWTIssuerError(f"Failed to sign JWT: {exc}") from exc diff --git a/platform/services/llm.py b/platform/services/llm.py index f3ae90c7..2e1c769f 100644 --- a/platform/services/llm.py +++ b/platform/services/llm.py @@ -75,10 +75,63 @@ async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): return SanitizedChatOpenAI +def _route_provider_to_type(provider: str) -> str: + """Map a route prefix to a provider_type string. + + Used when ``backend_route`` is set and no DB credential is available. + """ + _map = {"openai": "openai", "anthropic": "anthropic", "glm": "glm"} + return _map.get(provider, "openai_compatible") + + +def _fake_credential_from_route(backend_route: str): + """Return a minimal credential-like object inferred from a backend route string. + + Used by ``resolve_credential_for_node()`` when only ``backend_route`` is + configured (no DB credential). Callers that inspect ``.provider_type`` + (e.g. web-search provider detection) will get a sensible value. + """ + from collections import namedtuple + + FakeCredential = namedtuple("FakeCredential", ["provider_type", "base_url", "api_key"]) + prefix = backend_route.split("-", 1)[0] if "-" in backend_route else backend_route + return FakeCredential( + provider_type=_route_provider_to_type(prefix), + base_url="", + api_key="", + ) + + +def _resolve_backend_name(credential=None, backend_route: str | None = None) -> str: + """Resolve the agentgateway backend route name. + + Priority: + 1. If *backend_route* is provided (from node config), use it directly. + 2. If *credential* is provided, derive from credential metadata (legacy). + """ + if backend_route: + return backend_route + + # Legacy fallback: derive from credential + if credential is None: + return "" + if credential.provider_type in ("openai", "anthropic", "glm"): + return credential.provider_type + # openai_compatible: use sanitized name from base credential + base_name = "" + if hasattr(credential, "base_credentials") and credential.base_credentials: + base_name = credential.base_credentials.name + base_name = base_name or f"custom-{credential.base_credentials_id}" + return base_name.lower().replace(" ", "-").replace("_", "-") + + def create_llm_from_db( credential, model_name: str, *, + user_profile_id: int | None = None, + user_role: str | None = None, + backend_route: str | None = None, temperature: float | None = None, max_tokens: int | None = None, frequency_penalty: float | None = None, @@ -88,6 +141,28 @@ def create_llm_from_db( max_retries: int | None = None, response_format: dict | None = None, ) -> BaseChatModel: + from config import settings + + # -- agentgateway proxy path -- + if settings.AGENTGATEWAY_ENABLED and settings.AGENTGATEWAY_URL: + resolved_route = _resolve_backend_name(credential, backend_route) + return _create_llm_via_agentgateway( + credential, + model_name, + backend_route=resolved_route, + user_profile_id=user_profile_id, + user_role=user_role, + temperature=temperature, + max_tokens=max_tokens, + frequency_penalty=frequency_penalty, + presence_penalty=presence_penalty, + top_p=top_p, + timeout=timeout, + max_retries=max_retries, + response_format=response_format, + ) + + # -- direct provider path (unchanged) -- provider_type = credential.provider_type api_key = credential.api_key @@ -141,6 +216,106 @@ def create_llm_from_db( raise ValueError(f"Unsupported provider type: {provider_type}") +def _create_llm_via_agentgateway( + credential, + model_name: str, + *, + backend_route: str | None = None, + user_profile_id: int | None = None, + user_role: str | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + top_p: float | None = None, + timeout: int | None = None, + max_retries: int | None = None, + response_format: dict | None = None, +) -> BaseChatModel: + """Create an LLM routed through agentgateway. + + When *backend_route* is provided the credential may be ``None`` — + the route is used directly and the provider type is inferred from the + route prefix (e.g. ``"anthropic-claude-sonnet"`` → ``"anthropic"``). + + Lazy-imports agentgateway services to avoid circular imports. + """ + import asyncio + + from config import settings + from services.agentgateway_client import check_agentgateway_health, create_proxied_llm + from services.jwt_issuer import mint_llm_token + + # Health check — run async in a sync context (RQ workers may lack a loop). + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + # Already inside an event loop (e.g. FastAPI endpoint) — create a + # new loop in a thread to avoid "cannot run nested event loop". + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + ok, msg = pool.submit( + asyncio.run, check_agentgateway_health(settings.AGENTGATEWAY_URL) + ).result() + else: + ok, msg = asyncio.run(check_agentgateway_health(settings.AGENTGATEWAY_URL)) + + if not ok: + logger.warning("agentgateway health check failed: %s", msg) + raise RuntimeError(f"agentgateway is unreachable: {msg}") + + # Derive backend_name and provider_type. + backend_name = backend_route or _resolve_backend_name(credential) + credential_id = getattr(credential, "base_credentials_id", 0) if credential else 0 + + if credential is not None: + provider_type = credential.provider_type + elif backend_route: + # Infer provider from route prefix: "venice-glm-4.7" → "venice" → "openai_compatible" + prefix = backend_route.split("-", 1)[0] if "-" in backend_route else backend_route + provider_type = _route_provider_to_type(prefix) + else: + provider_type = "openai_compatible" + + jwt_token = mint_llm_token( + user_profile_id=user_profile_id or 0, + role=user_role or "admin", + credential_id=credential_id, + ) + + # Build kwargs to forward to the LangChain constructor. + extra_kwargs: dict = {} + if temperature is not None: + extra_kwargs["temperature"] = temperature + if max_tokens is not None: + extra_kwargs["max_tokens"] = max_tokens + if frequency_penalty is not None: + extra_kwargs["frequency_penalty"] = frequency_penalty + if presence_penalty is not None: + extra_kwargs["presence_penalty"] = presence_penalty + if top_p is not None: + extra_kwargs["top_p"] = top_p + if timeout is not None: + extra_kwargs["timeout"] = timeout + if max_retries is not None: + extra_kwargs["max_retries"] = max_retries + if response_format is not None: + extra_kwargs["response_format"] = response_format + + return create_proxied_llm( + jwt_token=jwt_token, + provider_type=provider_type, + backend_name=backend_name, + model=model_name, + agentgateway_url=settings.AGENTGATEWAY_URL, + **extra_kwargs, + ) + + def resolve_llm_for_node(node, db: Session | None = None) -> BaseChatModel: from database import SessionLocal from models.credential import BaseCredential @@ -149,10 +324,45 @@ def resolve_llm_for_node(node, db: Session | None = None) -> BaseChatModel: if own_session: db = SessionLocal() + # Derive user context from the workflow owner for JWT minting. + # Falls back to safe defaults when the relationship is not loaded. + user_profile_id: int | None = None + user_role: str | None = None + try: + workflow = getattr(node, "workflow", None) + if workflow is not None: + user_profile_id = getattr(workflow, "owner_id", None) + except Exception: + pass # Relationship not loaded — defaults will be used. + try: cc = node.component_config + backend_route = getattr(cc, "backend_route", None) if cc.component_type == "ai_model": + # backend_route path: skip credential lookup entirely + if backend_route: + if not cc.model_name: + raise ValueError( + f"Node '{node.node_id}' has backend_route but no model_name." + ) + return create_llm_from_db( + None, + cc.model_name, + user_profile_id=user_profile_id, + user_role=user_role, + backend_route=backend_route, + temperature=cc.temperature, + max_tokens=cc.max_tokens, + frequency_penalty=cc.frequency_penalty, + presence_penalty=cc.presence_penalty, + top_p=cc.top_p, + timeout=cc.timeout, + max_retries=cc.max_retries, + response_format=cc.response_format, + ) + + # Legacy credential-based path if not cc.model_name or not cc.llm_credential_id: raise ValueError( f"Node '{node.node_id}' (ai_model) requires both " @@ -173,6 +383,8 @@ def resolve_llm_for_node(node, db: Session | None = None) -> BaseChatModel: return create_llm_from_db( llm_cred, cc.model_name, + user_profile_id=user_profile_id, + user_role=user_role, temperature=cc.temperature, max_tokens=cc.max_tokens, frequency_penalty=cc.frequency_penalty, @@ -187,31 +399,60 @@ def resolve_llm_for_node(node, db: Session | None = None) -> BaseChatModel: if cc.llm_model_config_id: from models.node import BaseComponentConfig as BCC tc = db.get(BCC, cc.llm_model_config_id) - if tc and tc.component_type == "ai_model" and tc.model_name and tc.llm_credential_id: - base_cred = db.query(BaseCredential).filter(BaseCredential.id == tc.llm_credential_id).first() - if not base_cred: - raise ValueError( - f"Credential ID {tc.llm_credential_id} not found for ai_model config " - f"linked to node '{node.node_id}'. It may have been deleted." + if tc and tc.component_type == "ai_model": + tc_backend_route = getattr(tc, "backend_route", None) + + # backend_route path on referenced config + if tc_backend_route: + if not tc.model_name: + raise ValueError( + f"ai_model config linked to node '{node.node_id}' " + "has backend_route but no model_name." + ) + return create_llm_from_db( + None, + tc.model_name, + user_profile_id=user_profile_id, + user_role=user_role, + backend_route=tc_backend_route, + temperature=tc.temperature, + max_tokens=tc.max_tokens, + frequency_penalty=tc.frequency_penalty, + presence_penalty=tc.presence_penalty, + top_p=tc.top_p, + timeout=tc.timeout, + max_retries=tc.max_retries, + response_format=tc.response_format, ) - llm_cred = base_cred.llm_credential - if not llm_cred: - raise ValueError( - f"Credential ID {tc.llm_credential_id} for ai_model config " - f"linked to node '{node.node_id}' has no LLM provider configuration." + + # Legacy credential-based path + if tc.model_name and tc.llm_credential_id: + base_cred = db.query(BaseCredential).filter(BaseCredential.id == tc.llm_credential_id).first() + if not base_cred: + raise ValueError( + f"Credential ID {tc.llm_credential_id} not found for ai_model config " + f"linked to node '{node.node_id}'. It may have been deleted." + ) + llm_cred = base_cred.llm_credential + if not llm_cred: + raise ValueError( + f"Credential ID {tc.llm_credential_id} for ai_model config " + f"linked to node '{node.node_id}' has no LLM provider configuration." + ) + return create_llm_from_db( + llm_cred, + tc.model_name, + user_profile_id=user_profile_id, + user_role=user_role, + temperature=tc.temperature, + max_tokens=tc.max_tokens, + frequency_penalty=tc.frequency_penalty, + presence_penalty=tc.presence_penalty, + top_p=tc.top_p, + timeout=tc.timeout, + max_retries=tc.max_retries, + response_format=tc.response_format, ) - return create_llm_from_db( - llm_cred, - tc.model_name, - temperature=tc.temperature, - max_tokens=tc.max_tokens, - frequency_penalty=tc.frequency_penalty, - presence_penalty=tc.presence_penalty, - top_p=tc.top_p, - timeout=tc.timeout, - max_retries=tc.max_retries, - response_format=tc.response_format, - ) raise ValueError( f"Node '{node.node_id}' has no connected ai_model node via edge_label='llm'." @@ -236,8 +477,13 @@ def resolve_credential_for_node(node, db: Session | None = None): try: cc = node.component_config + backend_route = getattr(cc, "backend_route", None) if cc.component_type == "ai_model": + # backend_route path: infer provider from route string + if backend_route and not cc.llm_credential_id: + return _fake_credential_from_route(backend_route) + if not cc.llm_credential_id: raise ValueError(f"Node '{node.node_id}' (ai_model) has no credential.") base_cred = db.query(BaseCredential).filter(BaseCredential.id == cc.llm_credential_id).first() @@ -248,11 +494,15 @@ def resolve_credential_for_node(node, db: Session | None = None): if cc.llm_model_config_id: from models.node import BaseComponentConfig as BCC tc = db.get(BCC, cc.llm_model_config_id) - if tc and tc.component_type == "ai_model" and tc.llm_credential_id: - base_cred = db.query(BaseCredential).filter(BaseCredential.id == tc.llm_credential_id).first() - if not base_cred or not base_cred.llm_credential: - raise ValueError(f"Credential not found for ai_model config linked to node '{node.node_id}'.") - return base_cred.llm_credential + if tc and tc.component_type == "ai_model": + tc_backend_route = getattr(tc, "backend_route", None) + if tc_backend_route and not tc.llm_credential_id: + return _fake_credential_from_route(tc_backend_route) + if tc.llm_credential_id: + base_cred = db.query(BaseCredential).filter(BaseCredential.id == tc.llm_credential_id).first() + if not base_cred or not base_cred.llm_credential: + raise ValueError(f"Credential not found for ai_model config linked to node '{node.node_id}'.") + return base_cred.llm_credential raise ValueError(f"Node '{node.node_id}' has no connected ai_model node.") finally: diff --git a/platform/tests/test_agentgateway_client.py b/platform/tests/test_agentgateway_client.py new file mode 100644 index 00000000..89ed5554 --- /dev/null +++ b/platform/tests/test_agentgateway_client.py @@ -0,0 +1,366 @@ +"""Tests for agentgateway LLM proxy client.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from services.agentgateway_client import ( + check_agentgateway_health, + create_proxied_llm, +) + +AGENTGATEWAY_URL = "http://localhost:4000" +JWT_TOKEN = "eyJ.test.jwt" +MODEL = "gpt-4o" + + +# =================================================================== +# create_proxied_llm — OpenAI provider +# =================================================================== + + +class TestCreateProxiedLlmOpenAI: + def test_returns_chat_openai(self) -> None: + from langchain_openai import ChatOpenAI + + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="openai", + backend_name="openai", + model=MODEL, + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert isinstance(llm, ChatOpenAI) + + def test_base_url_includes_backend_name(self) -> None: + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="openai", + backend_name="my-openai", + model=MODEL, + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert str(llm.openai_api_base) == f"{AGENTGATEWAY_URL}/my-openai" + + def test_jwt_token_set_as_api_key(self) -> None: + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="openai", + backend_name="openai", + model=MODEL, + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert llm.openai_api_key.get_secret_value() == JWT_TOKEN + + def test_extra_kwargs_forwarded(self) -> None: + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="openai", + backend_name="openai", + model=MODEL, + agentgateway_url=AGENTGATEWAY_URL, + temperature=0.5, + max_tokens=100, + ) + + assert llm.temperature == 0.5 + assert llm.max_tokens == 100 + + def test_trailing_slash_stripped_from_url(self) -> None: + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="openai", + backend_name="openai", + model=MODEL, + agentgateway_url="http://localhost:4000/", + ) + + assert str(llm.openai_api_base) == f"{AGENTGATEWAY_URL}/openai" + + +# =================================================================== +# create_proxied_llm — Anthropic provider +# =================================================================== + + +class TestCreateProxiedLlmAnthropic: + def test_returns_chat_anthropic(self) -> None: + from langchain_anthropic import ChatAnthropic + + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="anthropic", + backend_name="anthropic", + model="claude-sonnet-4-20250514", + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert isinstance(llm, ChatAnthropic) + + def test_base_url_includes_backend_name(self) -> None: + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="anthropic", + backend_name="my-anthropic", + model="claude-sonnet-4-20250514", + agentgateway_url=AGENTGATEWAY_URL, + ) + + # ChatAnthropic stores base_url as anthropic_api_url + assert str(llm.anthropic_api_url) == f"{AGENTGATEWAY_URL}/my-anthropic" + + def test_bearer_header_injected_via_default_headers(self) -> None: + """ChatAnthropic sends api_key as x-api-key, not Bearer. + + We inject Authorization: Bearer via default_headers to pass the + JWT to agentgateway. + """ + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="anthropic", + backend_name="anthropic", + model="claude-sonnet-4-20250514", + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert llm.default_headers is not None + assert llm.default_headers["Authorization"] == f"Bearer {JWT_TOKEN}" + + def test_api_key_is_placeholder(self) -> None: + """api_key is a dummy value — the real key is injected by agentgateway.""" + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="anthropic", + backend_name="anthropic", + model="claude-sonnet-4-20250514", + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert llm.anthropic_api_key.get_secret_value() == "jwt-via-bearer" + + def test_extra_kwargs_forwarded(self) -> None: + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="anthropic", + backend_name="anthropic", + model="claude-sonnet-4-20250514", + agentgateway_url=AGENTGATEWAY_URL, + temperature=0.7, + max_tokens=200, + ) + + assert llm.temperature == 0.7 + assert llm.max_tokens == 200 + + +# =================================================================== +# create_proxied_llm — GLM provider +# =================================================================== + + +class TestCreateProxiedLlmGLM: + def test_returns_chat_openai(self) -> None: + from langchain_openai import ChatOpenAI + + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="glm", + backend_name="glm", + model="glm-4", + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert isinstance(llm, ChatOpenAI) + + def test_base_url_correct(self) -> None: + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="glm", + backend_name="glm", + model="glm-4", + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert str(llm.openai_api_base) == f"{AGENTGATEWAY_URL}/glm" + + def test_jwt_token_as_api_key(self) -> None: + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="glm", + backend_name="glm", + model="glm-4", + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert llm.openai_api_key.get_secret_value() == JWT_TOKEN + + +# =================================================================== +# create_proxied_llm — openai_compatible provider +# =================================================================== + + +class TestCreateProxiedLlmOpenAICompatible: + def test_returns_chat_openai(self) -> None: + from langchain_openai import ChatOpenAI + + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="openai_compatible", + backend_name="venice", + model="llama-3.3-70b", + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert isinstance(llm, ChatOpenAI) + + def test_base_url_correct(self) -> None: + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="openai_compatible", + backend_name="venice", + model="llama-3.3-70b", + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert str(llm.openai_api_base) == f"{AGENTGATEWAY_URL}/venice" + + def test_jwt_token_as_api_key(self) -> None: + llm = create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="openai_compatible", + backend_name="venice", + model="llama-3.3-70b", + agentgateway_url=AGENTGATEWAY_URL, + ) + + assert llm.openai_api_key.get_secret_value() == JWT_TOKEN + + +# =================================================================== +# create_proxied_llm — unsupported provider +# =================================================================== + + +class TestCreateProxiedLlmUnsupported: + def test_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="Unsupported provider type"): + create_proxied_llm( + jwt_token=JWT_TOKEN, + provider_type="bedrock", + backend_name="aws", + model="some-model", + agentgateway_url=AGENTGATEWAY_URL, + ) + + +# =================================================================== +# check_agentgateway_health +# =================================================================== + + +class TestCheckAgentgatewayHealth: + @pytest.mark.asyncio + async def test_healthy_401_response(self) -> None: + """401 means JWT auth is enforced — agentgateway is healthy.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 401 + + with patch("services.agentgateway_client.httpx.AsyncClient") as MockClient: + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + MockClient.return_value = mock_client + + ok, msg = await check_agentgateway_health(AGENTGATEWAY_URL) + + assert ok is True + assert "JWT auth enforced" in msg + + @pytest.mark.asyncio + async def test_healthy_403_response(self) -> None: + """403 also means auth is working — healthy.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 403 + + with patch("services.agentgateway_client.httpx.AsyncClient") as MockClient: + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + MockClient.return_value = mock_client + + ok, msg = await check_agentgateway_health(AGENTGATEWAY_URL) + + assert ok is True + assert "JWT auth enforced" in msg + + @pytest.mark.asyncio + async def test_healthy_200_response(self) -> None: + """200 — agentgateway is up (maybe no auth configured yet).""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 200 + + with patch("services.agentgateway_client.httpx.AsyncClient") as MockClient: + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + MockClient.return_value = mock_client + + ok, msg = await check_agentgateway_health(AGENTGATEWAY_URL) + + assert ok is True + assert "200" in msg + + @pytest.mark.asyncio + async def test_connection_error(self) -> None: + """ConnectError means agentgateway is unreachable.""" + with patch("services.agentgateway_client.httpx.AsyncClient") as MockClient: + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.ConnectError("Connection refused") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + MockClient.return_value = mock_client + + ok, msg = await check_agentgateway_health(AGENTGATEWAY_URL) + + assert ok is False + assert "unreachable" in msg + + @pytest.mark.asyncio + async def test_timeout_error(self) -> None: + """TimeoutException means agentgateway did not respond in time.""" + with patch("services.agentgateway_client.httpx.AsyncClient") as MockClient: + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.TimeoutException("timed out") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + MockClient.return_value = mock_client + + ok, msg = await check_agentgateway_health(AGENTGATEWAY_URL) + + assert ok is False + assert "timed out" in msg + + @pytest.mark.asyncio + async def test_unexpected_error(self) -> None: + """Catch-all for unexpected exceptions.""" + with patch("services.agentgateway_client.httpx.AsyncClient") as MockClient: + mock_client = AsyncMock() + mock_client.get.side_effect = RuntimeError("something broke") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + MockClient.return_value = mock_client + + ok, msg = await check_agentgateway_health(AGENTGATEWAY_URL) + + assert ok is False + assert "health check failed" in msg diff --git a/platform/tests/test_agentgateway_config.py b/platform/tests/test_agentgateway_config.py new file mode 100644 index 00000000..8d2f1409 --- /dev/null +++ b/platform/tests/test_agentgateway_config.py @@ -0,0 +1,658 @@ +"""Tests for agentgateway config writer service -- provider/model structure.""" + +from __future__ import annotations + +import os +import stat +import threading +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml +from cryptography.fernet import Fernet + +# Generate a stable test key +_TEST_KEY = Fernet.generate_key().decode() + + +@pytest.fixture() +def agw_dir(tmp_path): + """Create a temporary agentgateway directory structure.""" + (tmp_path / "config.d" / "backends").mkdir(parents=True) + (tmp_path / "config.d" / "mcp_servers").mkdir(parents=True) + (tmp_path / "config.d" / "rules").mkdir(parents=True) + (tmp_path / "keys").mkdir() + + # Create a dummy assemble-config.sh that succeeds + script = tmp_path / "assemble-config.sh" + script.write_text("#!/bin/bash\nexit 0\n") + script.chmod(0o755) + + return tmp_path + + +@pytest.fixture(autouse=True) +def _patch_settings(agw_dir): + """Patch settings to use the temporary directory and test encryption key.""" + with patch("services.agentgateway_config.settings") as mock_settings: + mock_settings.AGENTGATEWAY_DIR = str(agw_dir) + mock_settings.FIELD_ENCRYPTION_KEY = _TEST_KEY + yield mock_settings + + +# --------------------------------------------------------------------------- +# Provider key management +# --------------------------------------------------------------------------- + + +class TestWriteProviderKey: + def test_writes_encrypted_file(self, agw_dir): + from services.agentgateway_config import write_provider_key + + write_provider_key("venice", "sk-secret-key-12345") + + key_path = agw_dir / "keys" / "venice.key" + assert key_path.exists() + + # File contents should be encrypted, not plaintext + raw = key_path.read_bytes() + assert b"sk-secret-key-12345" not in raw + + # Decrypt and verify + fernet = Fernet(_TEST_KEY.encode()) + decrypted = fernet.decrypt(raw).decode() + assert decrypted == "sk-secret-key-12345" + + def test_file_permissions_are_0600(self, agw_dir): + from services.agentgateway_config import write_provider_key + + write_provider_key("openai", "sk-openai-key") + + key_path = agw_dir / "keys" / "openai.key" + mode = stat.S_IMODE(key_path.stat().st_mode) + assert mode == 0o600 + + def test_atomic_write_no_tmp_left(self, agw_dir): + from services.agentgateway_config import write_provider_key + + write_provider_key("test", "key-value") + + # .tmp file should not remain + tmp_path = agw_dir / "keys" / "test.key.tmp" + assert not tmp_path.exists() + + def test_creates_keys_dir_if_missing(self, tmp_path, _patch_settings): + """keys/ directory is created automatically.""" + _patch_settings.AGENTGATEWAY_DIR = str(tmp_path) + + # Remove keys dir if it exists + keys_dir = tmp_path / "keys" + if keys_dir.exists(): + keys_dir.rmdir() + assert not keys_dir.exists() + + from services.agentgateway_config import write_provider_key + + write_provider_key("new", "key-value") + assert (tmp_path / "keys" / "new.key").exists() + + def test_overwrites_existing_key(self, agw_dir): + from services.agentgateway_config import write_provider_key + + write_provider_key("venice", "old-key") + write_provider_key("venice", "new-key") + + fernet = Fernet(_TEST_KEY.encode()) + decrypted = fernet.decrypt( + (agw_dir / "keys" / "venice.key").read_bytes() + ).decode() + assert decrypted == "new-key" + + +class TestRemoveProviderKey: + def test_removes_existing_key(self, agw_dir): + from services.agentgateway_config import remove_provider_key, write_provider_key + + write_provider_key("test", "value") + assert (agw_dir / "keys" / "test.key").exists() + + remove_provider_key("test") + assert not (agw_dir / "keys" / "test.key").exists() + + def test_no_error_if_missing(self, agw_dir): + from services.agentgateway_config import remove_provider_key + + # Should not raise + remove_provider_key("nonexistent") + + +# --------------------------------------------------------------------------- +# Provider management +# --------------------------------------------------------------------------- + + +class TestAddProvider: + def test_creates_directory_and_provider_yaml(self, agw_dir): + from services.agentgateway_config import add_provider + + add_provider( + provider="venice", + provider_type="openai_compatible", + host_override="api.venice.ai:443", + path_override="/api/v1/chat/completions", + ) + + provider_dir = agw_dir / "config.d" / "backends" / "venice" + assert provider_dir.is_dir() + + provider_file = provider_dir / "_provider.yaml" + assert provider_file.exists() + + content = provider_file.read_text() + assert content.startswith("# venice provider config") + + parsed = yaml.safe_load(content) + assert parsed["provider"] == {"openAI": {}} + assert parsed["backendAuth"]["key"] == "${VENICE_API_KEY}" + assert parsed["backendTLS"] == {} + assert parsed["hostOverride"] == "api.venice.ai:443" + assert parsed["pathOverride"] == "/api/v1/chat/completions" + + def test_anthropic_provider(self, agw_dir): + from services.agentgateway_config import add_provider + + add_provider(provider="anthropic", provider_type="anthropic") + + parsed = yaml.safe_load( + (agw_dir / "config.d" / "backends" / "anthropic" / "_provider.yaml").read_text() + ) + assert parsed["provider"] == {"anthropic": {}} + assert parsed["backendAuth"]["key"] == "${ANTHROPIC_API_KEY}" + # No hostOverride/pathOverride when not provided + assert "hostOverride" not in parsed + assert "pathOverride" not in parsed + + def test_does_not_trigger_reassembly(self, agw_dir): + """add_provider should NOT call reassemble_config.""" + from services.agentgateway_config import add_provider + + with patch("services.agentgateway_config.reassemble_config") as mock_reassemble: + add_provider(provider="test", provider_type="openai") + mock_reassemble.assert_not_called() + + def test_hyphenated_provider_name(self, agw_dir): + from services.agentgateway_config import add_provider + + add_provider(provider="my-provider", provider_type="openai") + + parsed = yaml.safe_load( + (agw_dir / "config.d" / "backends" / "my-provider" / "_provider.yaml").read_text() + ) + assert parsed["backendAuth"]["key"] == "${MY_PROVIDER_API_KEY}" + + def test_atomic_write_no_tmp_left(self, agw_dir): + from services.agentgateway_config import add_provider + + add_provider(provider="test", provider_type="openai") + + tmp_path = agw_dir / "config.d" / "backends" / "test" / "_provider.yaml.tmp" + assert not tmp_path.exists() + + +class TestRemoveProvider: + @patch("services.agentgateway_config.reassemble_config") + def test_removes_directory_and_key(self, mock_reassemble, agw_dir): + from services.agentgateway_config import ( + add_provider, + remove_provider, + write_provider_key, + ) + + add_provider(provider="test", provider_type="openai") + write_provider_key("test", "secret") + + assert (agw_dir / "config.d" / "backends" / "test").is_dir() + assert (agw_dir / "keys" / "test.key").exists() + + remove_provider("test") + + assert not (agw_dir / "config.d" / "backends" / "test").exists() + assert not (agw_dir / "keys" / "test.key").exists() + mock_reassemble.assert_called_once() + + @patch("services.agentgateway_config.reassemble_config") + def test_no_error_if_missing(self, mock_reassemble, agw_dir): + from services.agentgateway_config import remove_provider + + # Should not raise + remove_provider("nonexistent") + + +class TestListProviders: + def test_lists_provider_subdirectories(self, agw_dir): + from services.agentgateway_config import add_provider, list_providers + + add_provider(provider="anthropic", provider_type="anthropic") + add_provider(provider="openai", provider_type="openai") + add_provider(provider="venice", provider_type="openai_compatible") + + result = list_providers() + assert result == ["anthropic", "openai", "venice"] + + def test_empty_when_no_providers(self, agw_dir): + from services.agentgateway_config import list_providers + + result = list_providers() + assert result == [] + + def test_empty_when_backends_dir_missing(self, tmp_path, _patch_settings): + _patch_settings.AGENTGATEWAY_DIR = str(tmp_path) + + from services.agentgateway_config import list_providers + + result = list_providers() + assert result == [] + + +class TestGetProviderConfig: + def test_reads_provider_yaml(self, agw_dir): + from services.agentgateway_config import add_provider, get_provider_config + + add_provider( + provider="venice", + provider_type="openai_compatible", + host_override="api.venice.ai:443", + path_override="/api/v1/chat/completions", + ) + + config = get_provider_config("venice") + assert config["provider"] == {"openAI": {}} + assert config["hostOverride"] == "api.venice.ai:443" + assert config["backendAuth"]["key"] == "${VENICE_API_KEY}" + + def test_raises_if_not_found(self, agw_dir): + from services.agentgateway_config import get_provider_config + + with pytest.raises(FileNotFoundError, match="Provider config not found"): + get_provider_config("nonexistent") + + +# --------------------------------------------------------------------------- +# Model management +# --------------------------------------------------------------------------- + + +class TestAddModel: + @patch("services.agentgateway_config.reassemble_config") + def test_creates_model_yaml(self, mock_reassemble, agw_dir): + from services.agentgateway_config import add_model, add_provider + + add_provider(provider="venice", provider_type="openai_compatible") + add_model(provider="venice", model_slug="glm-4.7", model_name="zai-org-glm-4.7") + + model_file = agw_dir / "config.d" / "backends" / "venice" / "glm-4.7.yaml" + assert model_file.exists() + + parsed = yaml.safe_load(model_file.read_text()) + assert parsed == {"model": "zai-org-glm-4.7"} + mock_reassemble.assert_called_once() + + @patch("services.agentgateway_config.reassemble_config") + def test_reassemble_false_suppresses_reassembly(self, mock_reassemble, agw_dir): + from services.agentgateway_config import add_model, add_provider + + add_provider(provider="venice", provider_type="openai_compatible") + add_model( + provider="venice", + model_slug="glm-4.7", + model_name="zai-org-glm-4.7", + reassemble=False, + ) + + # Model file created but no reassembly + assert (agw_dir / "config.d" / "backends" / "venice" / "glm-4.7.yaml").exists() + mock_reassemble.assert_not_called() + + @patch("services.agentgateway_config.reassemble_config") + def test_atomic_write_no_tmp_left(self, mock_reassemble, agw_dir): + from services.agentgateway_config import add_model, add_provider + + add_provider(provider="venice", provider_type="openai_compatible") + add_model(provider="venice", model_slug="test", model_name="test-model") + + tmp_path = agw_dir / "config.d" / "backends" / "venice" / "test.yaml.tmp" + assert not tmp_path.exists() + + @patch("services.agentgateway_config.reassemble_config") + def test_creates_provider_dir_if_missing(self, mock_reassemble, agw_dir): + from services.agentgateway_config import add_model + + # Provider dir does not exist yet + add_model(provider="newprovider", model_slug="test", model_name="test-model") + + assert (agw_dir / "config.d" / "backends" / "newprovider" / "test.yaml").exists() + + +class TestRemoveModel: + @patch("services.agentgateway_config.reassemble_config") + def test_removes_model_file(self, mock_reassemble, agw_dir): + from services.agentgateway_config import add_model, add_provider, remove_model + + add_provider(provider="venice", provider_type="openai_compatible") + with patch("services.agentgateway_config.reassemble_config"): + add_model(provider="venice", model_slug="glm-4.7", model_name="zai-org-glm-4.7") + + assert (agw_dir / "config.d" / "backends" / "venice" / "glm-4.7.yaml").exists() + + remove_model("venice", "glm-4.7") + + assert not (agw_dir / "config.d" / "backends" / "venice" / "glm-4.7.yaml").exists() + # Provider directory still exists + assert (agw_dir / "config.d" / "backends" / "venice" / "_provider.yaml").exists() + mock_reassemble.assert_called_once() + + @patch("services.agentgateway_config.reassemble_config") + def test_no_error_if_missing(self, mock_reassemble, agw_dir): + from services.agentgateway_config import remove_model + + # Should not raise + remove_model("nonexistent", "no-model") + + +class TestListModels: + def test_lists_models_excluding_provider_yaml(self, agw_dir): + from services.agentgateway_config import add_model, add_provider, list_models + + add_provider(provider="venice", provider_type="openai_compatible") + with patch("services.agentgateway_config.reassemble_config"): + add_model(provider="venice", model_slug="glm-4.7", model_name="zai-org-glm-4.7") + add_model(provider="venice", model_slug="deepseek-r1", model_name="deepseek-r1-0528") + + result = list_models("venice") + assert len(result) == 2 + assert result[0] == { + "slug": "deepseek-r1", + "model_name": "deepseek-r1-0528", + "route": "venice-deepseek-r1", + } + assert result[1] == { + "slug": "glm-4.7", + "model_name": "zai-org-glm-4.7", + "route": "venice-glm-4.7", + } + + def test_empty_when_no_models(self, agw_dir): + from services.agentgateway_config import add_provider, list_models + + add_provider(provider="venice", provider_type="openai_compatible") + result = list_models("venice") + assert result == [] + + def test_empty_when_provider_missing(self, agw_dir): + from services.agentgateway_config import list_models + + result = list_models("nonexistent") + assert result == [] + + +class TestListAllAvailableModels: + def test_returns_models_only_for_providers_with_keys(self, agw_dir): + from services.agentgateway_config import ( + add_model, + add_provider, + list_all_available_models, + write_provider_key, + ) + + # Provider with key + add_provider(provider="venice", provider_type="openai_compatible") + write_provider_key("venice", "sk-venice") + with patch("services.agentgateway_config.reassemble_config"): + add_model(provider="venice", model_slug="glm-4.7", model_name="zai-org-glm-4.7") + + # Provider WITHOUT key + add_provider(provider="openai", provider_type="openai") + with patch("services.agentgateway_config.reassemble_config"): + add_model(provider="openai", model_slug="gpt-4o", model_name="gpt-4o") + + result = list_all_available_models() + assert len(result) == 1 + assert result[0] == { + "route": "venice-glm-4.7", + "provider": "venice", + "model_slug": "glm-4.7", + "model_name": "zai-org-glm-4.7", + } + + def test_multiple_providers_with_keys(self, agw_dir): + from services.agentgateway_config import ( + add_model, + add_provider, + list_all_available_models, + write_provider_key, + ) + + add_provider(provider="openai", provider_type="openai") + write_provider_key("openai", "sk-openai") + add_provider(provider="venice", provider_type="openai_compatible") + write_provider_key("venice", "sk-venice") + + with patch("services.agentgateway_config.reassemble_config"): + add_model(provider="openai", model_slug="gpt-4o", model_name="gpt-4o") + add_model(provider="venice", model_slug="glm-4.7", model_name="zai-org-glm-4.7") + add_model(provider="venice", model_slug="deepseek-r1", model_name="deepseek-r1-0528") + + result = list_all_available_models() + assert len(result) == 3 + providers = [r["provider"] for r in result] + assert providers == ["openai", "venice", "venice"] + + def test_empty_when_no_backends(self, agw_dir): + from services.agentgateway_config import list_all_available_models + + result = list_all_available_models() + assert result == [] + + def test_empty_when_no_keys_dir(self, tmp_path, _patch_settings): + """No keys/ directory at all.""" + _patch_settings.AGENTGATEWAY_DIR = str(tmp_path) + (tmp_path / "config.d" / "backends" / "venice").mkdir(parents=True) + (tmp_path / "config.d" / "backends" / "venice" / "_provider.yaml").write_text( + "provider:\n openAI: {}\n" + ) + (tmp_path / "config.d" / "backends" / "venice" / "glm.yaml").write_text( + "model: glm-4\n" + ) + + from services.agentgateway_config import list_all_available_models + + result = list_all_available_models() + assert result == [] + + +# --------------------------------------------------------------------------- +# Build provider type +# --------------------------------------------------------------------------- + + +class TestBuildProviderType: + def test_anthropic(self): + from services.agentgateway_config import _build_provider_type + + assert _build_provider_type("anthropic") == {"anthropic": {}} + + def test_openai(self): + from services.agentgateway_config import _build_provider_type + + assert _build_provider_type("openai") == {"openAI": {}} + + def test_openai_compatible(self): + from services.agentgateway_config import _build_provider_type + + assert _build_provider_type("openai_compatible") == {"openAI": {}} + + def test_glm(self): + from services.agentgateway_config import _build_provider_type + + assert _build_provider_type("glm") == {"openAI": {}} + + def test_unknown_defaults_to_openai(self): + from services.agentgateway_config import _build_provider_type + + assert _build_provider_type("unknown") == {"openAI": {}} + + +# --------------------------------------------------------------------------- +# MCP, Rules, Assembly (unchanged) +# --------------------------------------------------------------------------- + + +class TestAddMcpServer: + @patch("services.agentgateway_config.reassemble_config") + def test_writes_config(self, mock_reassemble, agw_dir): + from services.agentgateway_config import add_mcp_server + + config = {"mcp": {"name": "test-mcp", "command": "npx test-server"}} + add_mcp_server("test-mcp", config) + + path = agw_dir / "config.d" / "mcp_servers" / "test-mcp.yaml" + assert path.exists() + + content = path.read_text() + assert content.startswith("# test-mcp MCP server") + + parsed = yaml.safe_load(content) + assert parsed == config + mock_reassemble.assert_called_once() + + +class TestRemoveMcpServer: + @patch("services.agentgateway_config.reassemble_config") + def test_removes_config(self, mock_reassemble, agw_dir): + from services.agentgateway_config import add_mcp_server, remove_mcp_server + + with patch("services.agentgateway_config.reassemble_config"): + add_mcp_server("test", {"name": "test"}) + + assert (agw_dir / "config.d" / "mcp_servers" / "test.yaml").exists() + + remove_mcp_server("test") + assert not (agw_dir / "config.d" / "mcp_servers" / "test.yaml").exists() + + +class TestUpdateRules: + @patch("services.agentgateway_config.reassemble_config") + def test_writes_cel_rules(self, mock_reassemble, agw_dir): + from services.agentgateway_config import update_rules + + rules = ['jwt.role == "admin"', 'jwt.role == "user"'] + update_rules("admin", rules) + + path = agw_dir / "config.d" / "rules" / "admin.yaml" + assert path.exists() + + content = path.read_text() + assert content.startswith("# admin role authorization rules") + + parsed = yaml.safe_load(content) + assert parsed == rules + mock_reassemble.assert_called_once() + + +class TestReassembleConfig: + def test_calls_assemble_script(self, agw_dir): + from services.agentgateway_config import reassemble_config + + reassemble_config() + + # Verify the lock file was created + assert (agw_dir / ".config.lock").exists() + + def test_raises_on_script_failure(self, agw_dir): + from services.agentgateway_config import reassemble_config + + # Replace script with one that fails + script = agw_dir / "assemble-config.sh" + script.write_text("#!/bin/bash\necho 'error' >&2\nexit 1\n") + + with pytest.raises(RuntimeError, match="Config assembly failed"): + reassemble_config() + + def test_concurrent_calls_serialize(self, agw_dir): + """File locking prevents concurrent reassembly.""" + from services.agentgateway_config import reassemble_config + + # Replace script with one that takes a moment + script = agw_dir / "assemble-config.sh" + counter_file = agw_dir / "counter" + counter_file.write_text("0") + script.write_text( + f"#!/bin/bash\n" + f"val=$(cat '{counter_file}')\n" + f"echo $((val + 1)) > '{counter_file}'\n" + f"exit 0\n" + ) + + errors = [] + + def run(): + try: + reassemble_config() + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=run) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors + # All 5 calls completed (counter should be 5) + assert int(counter_file.read_text().strip()) == 5 + + +# --------------------------------------------------------------------------- +# Utility functions +# --------------------------------------------------------------------------- + + +class TestNameToEnvVar: + def test_simple(self): + from services.agentgateway_config import _name_to_env_var + + assert _name_to_env_var("venice") == "VENICE_API_KEY" + + def test_hyphen(self): + from services.agentgateway_config import _name_to_env_var + + assert _name_to_env_var("my-backend") == "MY_BACKEND_API_KEY" + + def test_dot(self): + from services.agentgateway_config import _name_to_env_var + + assert _name_to_env_var("open.ai") == "OPEN_AI_API_KEY" + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestErrorHandling: + def test_no_encryption_key_raises(self, agw_dir, _patch_settings): + _patch_settings.FIELD_ENCRYPTION_KEY = "" + + from services.agentgateway_config import write_provider_key + + with pytest.raises(RuntimeError, match="FIELD_ENCRYPTION_KEY is not set"): + write_provider_key("test", "value") + + def test_no_agw_dir_raises(self, _patch_settings): + _patch_settings.AGENTGATEWAY_DIR = "" + + from services.agentgateway_config import list_providers + + with pytest.raises(RuntimeError, match="AGENTGATEWAY_DIR is not set"): + list_providers() diff --git a/platform/tests/test_available_models_api.py b/platform/tests/test_available_models_api.py new file mode 100644 index 00000000..62557689 --- /dev/null +++ b/platform/tests/test_available_models_api.py @@ -0,0 +1,153 @@ +"""Tests for GET /api/v1/available-models/.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +_platform_dir = str(Path(__file__).resolve().parent.parent) +if _platform_dir not in sys.path: + sys.path.insert(0, _platform_dir) + +MOCK_MODELS = [ + { + "route": "venice-glm-4.7", + "provider": "venice", + "model_slug": "glm-4.7", + "model_name": "zai-org-glm-4.7", + }, + { + "route": "venice-deepseek-r1", + "provider": "venice", + "model_slug": "deepseek-r1", + "model_name": "deepseek-r1-0528", + }, +] + + +@pytest.fixture +def app(db): + from main import app as _app + from database import get_db + + def _override_get_db(): + yield db + + _app.dependency_overrides[get_db] = _override_get_db + yield _app + _app.dependency_overrides.clear() + + +@pytest.fixture +def client(app): + return TestClient(app) + + +@pytest.fixture +def auth_client(client, api_key): + client.headers["Authorization"] = f"Bearer {api_key.key}" + return client + + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + + +def test_requires_authentication(client): + """GET /available-models/ without a token returns 401 or 403.""" + resp = client.get("/api/v1/available-models/") + assert resp.status_code in (401, 403) + + +# --------------------------------------------------------------------------- +# AGENTGATEWAY_ENABLED=True +# --------------------------------------------------------------------------- + + +def test_returns_models_when_enabled(auth_client): + """Returns model list from list_all_available_models() when enabled.""" + with ( + patch("config.settings.AGENTGATEWAY_ENABLED", True), + patch( + "services.agentgateway_config.list_all_available_models", + return_value=MOCK_MODELS, + ), + ): + resp = auth_client.get("/api/v1/available-models/") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) == 2 + + +def test_response_structure(auth_client): + """Each item has route, provider, model_slug, model_name keys.""" + with ( + patch("config.settings.AGENTGATEWAY_ENABLED", True), + patch( + "services.agentgateway_config.list_all_available_models", + return_value=MOCK_MODELS, + ), + ): + resp = auth_client.get("/api/v1/available-models/") + + assert resp.status_code == 200 + for item in resp.json(): + assert "route" in item + assert "provider" in item + assert "model_slug" in item + assert "model_name" in item + + +def test_response_values(auth_client): + """Response values match what list_all_available_models returns.""" + with ( + patch("config.settings.AGENTGATEWAY_ENABLED", True), + patch( + "services.agentgateway_config.list_all_available_models", + return_value=MOCK_MODELS, + ), + ): + resp = auth_client.get("/api/v1/available-models/") + + assert resp.status_code == 200 + data = resp.json() + assert data[0]["route"] == "venice-glm-4.7" + assert data[0]["provider"] == "venice" + assert data[0]["model_slug"] == "glm-4.7" + assert data[0]["model_name"] == "zai-org-glm-4.7" + assert data[1]["route"] == "venice-deepseek-r1" + + +# --------------------------------------------------------------------------- +# AGENTGATEWAY_ENABLED=False +# --------------------------------------------------------------------------- + + +def test_returns_empty_list_when_disabled(auth_client): + """Returns [] when AGENTGATEWAY_ENABLED is False.""" + with patch("config.settings.AGENTGATEWAY_ENABLED", False): + resp = auth_client.get("/api/v1/available-models/") + + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_does_not_call_filesystem_when_disabled(auth_client): + """list_all_available_models is never called when gateway is disabled.""" + with ( + patch("config.settings.AGENTGATEWAY_ENABLED", False), + patch( + "services.agentgateway_config.list_all_available_models" + ) as mock_list, + ): + resp = auth_client.get("/api/v1/available-models/") + + assert resp.status_code == 200 + mock_list.assert_not_called() diff --git a/platform/tests/test_credentials_agentgateway.py b/platform/tests/test_credentials_agentgateway.py new file mode 100644 index 00000000..3d0f20a2 --- /dev/null +++ b/platform/tests/test_credentials_agentgateway.py @@ -0,0 +1,293 @@ +"""Tests verifying agentgateway dual-write logic has been removed from credentials.py. + +After T6a, LLM credential CRUD is DB-only. These tests confirm: +- No agentgateway writes happen on create/delete +- The agentgateway_backend field exists in the schema but is always None +- Removed functions are no longer importable +""" + +from __future__ import annotations + +import uuid +from unittest.mock import patch + +import bcrypt +import pytest +from fastapi.testclient import TestClient + +from models.credential import BaseCredential, LLMProviderCredential +from models.user import APIKey, UserProfile, UserRole + + +# -- Fixtures ---------------------------------------------------------------- + + +@pytest.fixture +def app(db): + from main import app as _app + from database import get_db + + def _override_get_db(): + try: + yield db + finally: + pass + + _app.dependency_overrides[get_db] = _override_get_db + yield _app + _app.dependency_overrides.clear() + + +@pytest.fixture +def client(app): + return TestClient(app) + + +@pytest.fixture +def admin_user(db): + user = UserProfile( + username="agw-admin", + password_hash=bcrypt.hashpw(b"adminpass", bcrypt.gensalt()).decode(), + role=UserRole.ADMIN, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture +def admin_key(db, admin_user): + raw = str(uuid.uuid4()) + key = APIKey(user_id=admin_user.id, key=raw, name="admin-key", prefix=raw[:8]) + db.add(key) + db.commit() + return raw + + +@pytest.fixture +def admin_headers(admin_key): + return {"Authorization": f"Bearer {admin_key}"} + + +@pytest.fixture +def llm_credential(db, admin_user): + """Create an LLM credential directly in DB for update/delete tests.""" + base = BaseCredential( + user_profile_id=admin_user.id, + name="Test OpenAI", + credential_type="llm", + ) + db.add(base) + db.flush() + llm = LLMProviderCredential( + base_credentials_id=base.id, + provider_type="openai", + api_key="sk-test-key-1234567890", + base_url="", + organization_id="", + custom_headers={}, + ) + db.add(llm) + db.commit() + db.refresh(base) + return base + + +# -- No agentgateway writes on create ---------------------------------------- + + +class TestCreateLLMCredentialNoAgentgatewayWrites: + """Verify that creating an LLM credential does NOT write to agentgateway.""" + + def test_create_llm_credential_no_agentgateway_writes(self, client, admin_headers): + """Creating an LLM credential should succeed with DB-only, no agentgateway imports.""" + resp = client.post( + "/api/v1/credentials/", + json={ + "name": "Test Anthropic", + "credential_type": "llm", + "detail": { + "provider_type": "anthropic", + "api_key": "sk-ant-test-key-12345678", + }, + }, + headers=admin_headers, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["credential_type"] == "llm" + assert data["detail"]["provider_type"] == "anthropic" + # agentgateway_backend is always None now + assert data["agentgateway_backend"] is None + + def test_create_llm_credential_with_agentgateway_enabled_still_no_writes( + self, client, admin_headers + ): + """Even with AGENTGATEWAY_ENABLED=True, no agentgateway writes should happen.""" + with patch("api.credentials.settings") as mock_settings: + from config import settings as real_settings + for attr in dir(real_settings): + if attr.isupper(): + setattr(mock_settings, attr, getattr(real_settings, attr)) + mock_settings.AGENTGATEWAY_ENABLED = True + + resp = client.post( + "/api/v1/credentials/", + json={ + "name": "Enabled Test", + "credential_type": "llm", + "detail": { + "provider_type": "openai", + "api_key": "sk-enabled-test-12345678", + }, + }, + headers=admin_headers, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["agentgateway_backend"] is None + + +# -- No agentgateway writes on delete ---------------------------------------- + + +class TestDeleteLLMCredentialNoAgentgatewayWrites: + """Verify that deleting an LLM credential does NOT touch agentgateway.""" + + def test_delete_llm_credential_no_agentgateway_writes( + self, client, admin_headers, llm_credential + ): + """Deleting an LLM credential should succeed without any agentgateway calls.""" + resp = client.delete( + f"/api/v1/credentials/{llm_credential.id}/", + headers=admin_headers, + ) + assert resp.status_code == 204 + + def test_delete_llm_credential_with_agentgateway_enabled_still_no_writes( + self, client, admin_headers, llm_credential + ): + """Even with AGENTGATEWAY_ENABLED=True, delete should not call agentgateway.""" + with patch("api.credentials.settings") as mock_settings: + from config import settings as real_settings + for attr in dir(real_settings): + if attr.isupper(): + setattr(mock_settings, attr, getattr(real_settings, attr)) + mock_settings.AGENTGATEWAY_ENABLED = True + + resp = client.delete( + f"/api/v1/credentials/{llm_credential.id}/", + headers=admin_headers, + ) + assert resp.status_code == 204 + + +# -- agentgateway_backend field is always None -------------------------------- + + +class TestAgentgatewayBackendFieldIsNone: + """Verify the agentgateway_backend field exists in output but is always None.""" + + def test_agentgateway_backend_field_is_none_on_create( + self, client, admin_headers + ): + resp = client.post( + "/api/v1/credentials/", + json={ + "name": "Field Test", + "credential_type": "llm", + "detail": { + "provider_type": "openai", + "api_key": "sk-field-test-12345678", + }, + }, + headers=admin_headers, + ) + assert resp.status_code == 201 + data = resp.json() + assert "agentgateway_backend" in data + assert data["agentgateway_backend"] is None + + def test_agentgateway_backend_field_is_none_on_get( + self, client, admin_headers, llm_credential + ): + resp = client.get( + f"/api/v1/credentials/{llm_credential.id}/", + headers=admin_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert "agentgateway_backend" in data + assert data["agentgateway_backend"] is None + + def test_agentgateway_backend_field_is_none_even_when_enabled( + self, client, admin_headers, llm_credential + ): + """The field should be None regardless of AGENTGATEWAY_ENABLED setting.""" + with patch("api.credentials.settings") as mock_settings: + from config import settings as real_settings + for attr in dir(real_settings): + if attr.isupper(): + setattr(mock_settings, attr, getattr(real_settings, attr)) + mock_settings.AGENTGATEWAY_ENABLED = True + + resp = client.get( + f"/api/v1/credentials/{llm_credential.id}/", + headers=admin_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["agentgateway_backend"] is None + + +# -- Schema tests ------------------------------------------------------------ + + +class TestCredentialOutSchema: + + def test_agentgateway_backend_field_present(self): + from schemas.credential import CredentialOut + from datetime import datetime + + out = CredentialOut( + id=1, + name="Test", + credential_type="llm", + created_at=datetime.now(), + updated_at=datetime.now(), + agentgateway_backend="openai", + ) + assert out.agentgateway_backend == "openai" + + def test_agentgateway_backend_field_defaults_to_none(self): + from schemas.credential import CredentialOut + from datetime import datetime + + out = CredentialOut( + id=1, + name="Test", + credential_type="llm", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + assert out.agentgateway_backend is None + + +# -- No removed functions importable ------------------------------------------ + + +class TestRemovedFunctions: + """Verify that removed functions are no longer importable from credentials.""" + + def test_resolve_backend_name_removed(self): + import importlib + import api.credentials as mod + importlib.reload(mod) + assert not hasattr(mod, "_resolve_backend_name") + + def test_run_async_removed(self): + import importlib + import api.credentials as mod + importlib.reload(mod) + assert not hasattr(mod, "_run_async") diff --git a/platform/tests/test_jwt_issuer.py b/platform/tests/test_jwt_issuer.py new file mode 100644 index 00000000..69c6cbc7 --- /dev/null +++ b/platform/tests/test_jwt_issuer.py @@ -0,0 +1,151 @@ +"""Tests for services.jwt_issuer — ES256 JWT minting for agentgateway.""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization + +from services.jwt_issuer import JWTIssuerError, mint_llm_token + +# --------------------------------------------------------------------------- +# Fixtures: generate a fresh EC P-256 key pair per test session +# --------------------------------------------------------------------------- + +_ec_private_key = ec.generate_private_key(ec.SECP256R1()) +_PRIVATE_PEM = _ec_private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), +).decode() +_PUBLIC_KEY = _ec_private_key.public_key() + + +@pytest.fixture(autouse=True) +def _configure_private_key(): + """Inject a test EC private key into settings for every test.""" + with patch("services.jwt_issuer.settings") as mock_settings: + mock_settings.JWT_PRIVATE_KEY = _PRIVATE_PEM + yield mock_settings + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _decode(token: str) -> dict: + """Decode and validate a token using the test public key.""" + return jwt.decode( + token, + _PUBLIC_KEY, + algorithms=["ES256"], + audience="agentgateway", + issuer="pipelit", + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestMintLLMToken: + """Valid token generation.""" + + def test_produces_decodable_token(self): + token = mint_llm_token(user_profile_id=42, role="admin", credential_id=7) + claims = _decode(token) + assert claims["sub"] == "42" + + def test_required_claims_present(self): + token = mint_llm_token( + user_profile_id=42, role="normal", credential_id=7, + allowed_credentials=[3, 7, 12], + ) + claims = _decode(token) + + assert claims["iss"] == "pipelit" + assert claims["aud"] == "agentgateway" + assert claims["sub"] == "42" + assert claims["role"] == "normal" + assert claims["credential_id"] == 7 + assert "iat" in claims + assert "exp" in claims + + def test_token_expires_after_60_seconds(self): + before = int(time.time()) + token = mint_llm_token(user_profile_id=1, role="admin", credential_id=1) + after = int(time.time()) + + claims = _decode(token) + lifetime = claims["exp"] - claims["iat"] + assert lifetime == 60 + + # exp should be roughly now + 60 + assert before + 60 <= claims["exp"] <= after + 60 + + def test_es256_algorithm_used(self): + token = mint_llm_token(user_profile_id=1, role="admin", credential_id=1) + header = jwt.get_unverified_header(token) + assert header["alg"] == "ES256" + + def test_kid_header_present(self): + token = mint_llm_token(user_profile_id=1, role="admin", credential_id=1) + header = jwt.get_unverified_header(token) + assert header["kid"] == "pipelit-001" + + def test_jti_is_uuid(self): + import uuid + + token = mint_llm_token(user_profile_id=1, role="admin", credential_id=1) + claims = _decode(token) + # Should not raise + uuid.UUID(claims["jti"]) + + def test_allowed_credentials_included_when_provided(self): + token = mint_llm_token( + user_profile_id=42, role="normal", credential_id=7, + allowed_credentials=[3, 7, 12], + ) + claims = _decode(token) + assert claims["allowed_credentials"] == [3, 7, 12] + + def test_allowed_credentials_absent_when_not_provided(self): + token = mint_llm_token(user_profile_id=42, role="admin", credential_id=7) + claims = _decode(token) + assert "allowed_credentials" not in claims + + def test_sub_is_string(self): + """JWT sub claim should be a string per RFC 7519.""" + token = mint_llm_token(user_profile_id=999, role="admin", credential_id=1) + claims = _decode(token) + assert isinstance(claims["sub"], str) + assert claims["sub"] == "999" + + +class TestMintLLMTokenErrors: + """Error handling for missing or invalid configuration.""" + + def test_empty_private_key_raises(self, _configure_private_key): + _configure_private_key.JWT_PRIVATE_KEY = "" + with pytest.raises(JWTIssuerError, match="JWT_PRIVATE_KEY is not configured"): + mint_llm_token(user_profile_id=1, role="admin", credential_id=1) + + def test_invalid_private_key_raises(self, _configure_private_key): + _configure_private_key.JWT_PRIVATE_KEY = "not-a-real-key" + with pytest.raises(JWTIssuerError, match="Failed to sign JWT"): + mint_llm_token(user_profile_id=1, role="admin", credential_id=1) + + def test_expired_token_rejected(self): + """A token with exp in the past should fail validation.""" + with patch("services.jwt_issuer.time") as mock_time: + mock_time.time.return_value = time.time() - 120 # 2 minutes ago + token = mint_llm_token(user_profile_id=1, role="admin", credential_id=1) + + with pytest.raises(jwt.ExpiredSignatureError): + _decode(token) diff --git a/platform/tests/test_llm_agentgateway.py b/platform/tests/test_llm_agentgateway.py new file mode 100644 index 00000000..1ff8a656 --- /dev/null +++ b/platform/tests/test_llm_agentgateway.py @@ -0,0 +1,750 @@ +"""Tests for agentgateway routing in services/llm.py.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_credential(provider_type: str, base_credentials_id: int = 42): + """Build a mock LLMProviderCredential.""" + cred = MagicMock() + cred.provider_type = provider_type + cred.api_key = "sk-test-key" + cred.base_url = "" + cred.base_credentials_id = base_credentials_id + cred.base_credentials = None # No eager-loaded base credential + return cred + + +def _gw_enabled_patches(health_ok=True, health_msg="ok"): + """Return a dict of patch context managers for the agentgateway-enabled path. + + Patches are applied at the *source* modules since ``_create_llm_via_agentgateway`` + uses local imports (``from services.jwt_issuer import ...``). + """ + return { + "settings": patch("config.settings", **{ + "AGENTGATEWAY_ENABLED": True, + "AGENTGATEWAY_URL": "http://localhost:4000", + }), + "health": patch( + "services.agentgateway_client.check_agentgateway_health", + new_callable=AsyncMock, + return_value=(health_ok, health_msg), + ), + "mint": patch( + "services.jwt_issuer.mint_llm_token", + return_value="jwt-test-token", + ), + "proxy": patch( + "services.agentgateway_client.create_proxied_llm", + return_value=MagicMock(name="proxied_llm"), + ), + } + + +# --------------------------------------------------------------------------- +# _resolve_backend_name +# --------------------------------------------------------------------------- + + +class TestResolveBackendName: + def test_backend_route_takes_priority(self): + from services.llm import _resolve_backend_name + + cred = _make_credential("openai") + assert _resolve_backend_name(cred, backend_route="venice-glm-4.7") == "venice-glm-4.7" + + def test_fallback_to_credential_openai(self): + from services.llm import _resolve_backend_name + + cred = _make_credential("openai") + assert _resolve_backend_name(cred) == "openai" + + def test_fallback_to_credential_anthropic(self): + from services.llm import _resolve_backend_name + + cred = _make_credential("anthropic") + assert _resolve_backend_name(cred) == "anthropic" + + def test_fallback_to_credential_glm(self): + from services.llm import _resolve_backend_name + + cred = _make_credential("glm") + assert _resolve_backend_name(cred) == "glm" + + def test_fallback_to_credential_openai_compatible(self): + from services.llm import _resolve_backend_name + + cred = _make_credential("openai_compatible", base_credentials_id=99) + assert _resolve_backend_name(cred) == "custom-99" + + def test_empty_backend_route_falls_back(self): + from services.llm import _resolve_backend_name + + cred = _make_credential("anthropic") + assert _resolve_backend_name(cred, backend_route="") == "anthropic" + + def test_no_credential_no_route_returns_empty(self): + from services.llm import _resolve_backend_name + + assert _resolve_backend_name() == "" + + +# --------------------------------------------------------------------------- +# create_llm_from_db — agentgateway enabled +# --------------------------------------------------------------------------- + + +class TestCreateLlmFromDbAgentgateway: + """When AGENTGATEWAY_ENABLED=True and AGENTGATEWAY_URL is set.""" + + def _call(self, provider_type, user_profile_id=7, user_role="normal", + backend_route=None, credential=None, **extra_kwargs): + """Call create_llm_from_db with agentgateway enabled, all deps mocked.""" + from services.llm import create_llm_from_db + + cred = credential if credential is not None else _make_credential(provider_type) + patches = _gw_enabled_patches() + + with ( + patches["settings"], + patches["health"] as mock_health, + patches["mint"] as mock_mint, + patches["proxy"] as mock_proxy, + ): + result = create_llm_from_db( + cred, + "gpt-4o", + user_profile_id=user_profile_id, + user_role=user_role, + backend_route=backend_route, + **extra_kwargs, + ) + + return result, mock_mint, mock_proxy, mock_health + + def test_backend_route_used_directly(self): + """When backend_route is provided, it is used as-is for routing.""" + from services.llm import create_llm_from_db + + patches = _gw_enabled_patches() + + with ( + patches["settings"], + patches["health"], + patches["mint"], + patches["proxy"] as mock_proxy, + ): + create_llm_from_db( + None, + "glm-4.7", + backend_route="venice-glm-4.7", + user_profile_id=7, + user_role="normal", + ) + + mock_proxy.assert_called_once() + kw = mock_proxy.call_args.kwargs + assert kw["backend_name"] == "venice-glm-4.7" + # Provider inferred from route prefix + assert kw["provider_type"] == "openai_compatible" + + def test_backend_route_none_falls_back_to_credential(self): + """Without backend_route, uses credential-based resolution.""" + _, _, mock_proxy, _ = self._call("openai", backend_route=None) + + kw = mock_proxy.call_args.kwargs + assert kw["backend_name"] == "openai" + assert kw["provider_type"] == "openai" + + def test_openai_routes_through_proxy(self): + result, mock_mint, mock_proxy, _ = self._call("openai", temperature=0.5) + + mock_mint.assert_called_once_with( + user_profile_id=7, + role="normal", + credential_id=42, + ) + mock_proxy.assert_called_once() + kw = mock_proxy.call_args.kwargs + assert kw["jwt_token"] == "jwt-test-token" + assert kw["provider_type"] == "openai" + assert kw["backend_name"] == "openai" + assert kw["model"] == "gpt-4o" + assert kw["agentgateway_url"] == "http://localhost:4000" + assert kw["temperature"] == 0.5 + + def test_anthropic_routes_through_proxy(self): + _, _, mock_proxy, _ = self._call("anthropic") + + kw = mock_proxy.call_args.kwargs + assert kw["provider_type"] == "anthropic" + assert kw["backend_name"] == "anthropic" + + def test_glm_routes_through_proxy(self): + _, _, mock_proxy, _ = self._call("glm") + + kw = mock_proxy.call_args.kwargs + assert kw["provider_type"] == "glm" + assert kw["backend_name"] == "glm" + + def test_openai_compatible_routes_through_proxy(self): + _, _, mock_proxy, _ = self._call("openai_compatible") + + kw = mock_proxy.call_args.kwargs + assert kw["provider_type"] == "openai_compatible" + assert kw["backend_name"] == "custom-42" + + def test_user_context_defaults_when_not_provided(self): + _, mock_mint, _, _ = self._call( + "openai", user_profile_id=None, user_role=None + ) + + # Should default to user_profile_id=0, role="admin" + mock_mint.assert_called_once_with( + user_profile_id=0, + role="admin", + credential_id=42, + ) + + def test_all_kwargs_forwarded(self): + _, _, mock_proxy, _ = self._call( + "openai", + temperature=0.3, + max_tokens=100, + frequency_penalty=0.1, + presence_penalty=0.2, + top_p=0.9, + timeout=30, + max_retries=2, + ) + + kw = mock_proxy.call_args.kwargs + assert kw["temperature"] == 0.3 + assert kw["max_tokens"] == 100 + assert kw["frequency_penalty"] == 0.1 + assert kw["presence_penalty"] == 0.2 + assert kw["top_p"] == 0.9 + assert kw["timeout"] == 30 + assert kw["max_retries"] == 2 + + def test_backend_route_with_credential_none_mints_jwt_with_zero_id(self): + """When credential is None (backend_route path), credential_id=0 in JWT.""" + from services.llm import create_llm_from_db + + patches = _gw_enabled_patches() + + with ( + patches["settings"], + patches["health"], + patches["mint"] as mock_mint, + patches["proxy"], + ): + create_llm_from_db( + None, + "glm-4.7", + backend_route="venice-glm-4.7", + user_profile_id=5, + user_role="normal", + ) + + mock_mint.assert_called_once_with( + user_profile_id=5, + role="normal", + credential_id=0, + ) + + def test_backend_route_anthropic_prefix_infers_provider(self): + """Route starting with 'anthropic' infers provider_type='anthropic'.""" + from services.llm import create_llm_from_db + + patches = _gw_enabled_patches() + + with ( + patches["settings"], + patches["health"], + patches["mint"], + patches["proxy"] as mock_proxy, + ): + create_llm_from_db( + None, + "claude-sonnet-4-6", + backend_route="anthropic-claude-sonnet-4-6", + ) + + kw = mock_proxy.call_args.kwargs + assert kw["provider_type"] == "anthropic" + assert kw["backend_name"] == "anthropic-claude-sonnet-4-6" + + +# --------------------------------------------------------------------------- +# create_llm_from_db — agentgateway disabled (direct provider path) +# --------------------------------------------------------------------------- + + +class TestCreateLlmFromDbDirect: + """When AGENTGATEWAY_ENABLED=False, the direct provider path is used.""" + + @patch("services.llm._make_sanitized_chat_openai") + def test_openai_direct_when_disabled(self, mock_make_sanitized): + from services.llm import create_llm_from_db + + mock_cls = MagicMock() + mock_make_sanitized.return_value = mock_cls + + cred = _make_credential("openai") + with patch("config.settings") as mock_settings: + mock_settings.AGENTGATEWAY_ENABLED = False + mock_settings.AGENTGATEWAY_URL = "" + create_llm_from_db(cred, "gpt-4o", temperature=0.7) + + mock_cls.assert_called_once() + call_args = mock_cls.call_args + assert call_args.kwargs["api_key"] == "sk-test-key" + assert call_args.kwargs["model"] == "gpt-4o" + + @patch("services.llm._make_sanitized_chat_openai") + def test_new_params_ignored_when_disabled(self, mock_make_sanitized): + """user_profile_id and user_role are accepted but unused in direct path.""" + from services.llm import create_llm_from_db + + mock_cls = MagicMock() + mock_make_sanitized.return_value = mock_cls + + cred = _make_credential("openai") + with patch("config.settings") as mock_settings: + mock_settings.AGENTGATEWAY_ENABLED = False + mock_settings.AGENTGATEWAY_URL = "" + # Should not raise + create_llm_from_db( + cred, "gpt-4o", user_profile_id=5, user_role="admin" + ) + + mock_cls.assert_called_once() + + +# --------------------------------------------------------------------------- +# Health check failure +# --------------------------------------------------------------------------- + + +class TestHealthCheckFailure: + def test_raises_runtime_error_when_unhealthy(self): + from services.llm import create_llm_from_db + + cred = _make_credential("openai") + patches = _gw_enabled_patches(health_ok=False, health_msg="connection refused") + + with patches["settings"], patches["health"], patches["mint"]: + with pytest.raises(RuntimeError, match="agentgateway is unreachable"): + create_llm_from_db(cred, "gpt-4o", user_profile_id=1) + + def test_error_message_includes_detail(self): + from services.llm import create_llm_from_db + + cred = _make_credential("openai") + patches = _gw_enabled_patches(health_ok=False, health_msg="timed out after 5 seconds") + + with patches["settings"], patches["health"], patches["mint"]: + with pytest.raises(RuntimeError, match="timed out after 5 seconds"): + create_llm_from_db(cred, "gpt-4o") + + def test_does_not_fall_back_to_direct_provider(self): + """When agentgateway is enabled but unhealthy, must NOT silently use direct path.""" + from services.llm import create_llm_from_db + + cred = _make_credential("openai") + patches = _gw_enabled_patches(health_ok=False, health_msg="unreachable") + + with ( + patches["settings"], + patches["health"], + patches["mint"], + patch("services.llm._make_sanitized_chat_openai") as mock_sanitized, + ): + with pytest.raises(RuntimeError): + create_llm_from_db(cred, "gpt-4o") + + # Direct provider path must NOT have been called + mock_sanitized.assert_not_called() + + +# --------------------------------------------------------------------------- +# resolve_llm_for_node — user context threading +# --------------------------------------------------------------------------- + + +class TestResolveLlmForNodeUserContext: + """Verify user_profile_id is extracted from node.workflow.owner_id.""" + + @patch("services.llm.create_llm_from_db") + def test_threads_owner_id_from_workflow(self, mock_create): + from services.llm import resolve_llm_for_node + + mock_create.return_value = MagicMock(name="llm_instance") + + # Build mock node with workflow.owner_id + node = MagicMock() + node.node_id = "agent_abc" + node.workflow.owner_id = 42 + node.component_config.component_type = "ai_model" + node.component_config.model_name = "gpt-4o" + node.component_config.llm_credential_id = 10 + node.component_config.backend_route = None + node.component_config.temperature = None + node.component_config.max_tokens = None + node.component_config.frequency_penalty = None + node.component_config.presence_penalty = None + node.component_config.top_p = None + node.component_config.timeout = None + node.component_config.max_retries = None + node.component_config.response_format = None + + # Mock DB session + mock_db = MagicMock() + mock_base_cred = MagicMock() + mock_base_cred.llm_credential = _make_credential("openai") + mock_db.query.return_value.filter.return_value.first.return_value = mock_base_cred + + resolve_llm_for_node(node, db=mock_db) + + mock_create.assert_called_once() + call_kwargs = mock_create.call_args + assert call_kwargs.kwargs["user_profile_id"] == 42 + assert call_kwargs.kwargs["user_role"] is None + + @patch("services.llm.create_llm_from_db") + def test_defaults_when_workflow_not_loaded(self, mock_create): + from services.llm import resolve_llm_for_node + + mock_create.return_value = MagicMock(name="llm_instance") + + # Node without workflow relationship + node = MagicMock(spec=["node_id", "component_config"]) + node.node_id = "agent_xyz" + node.component_config.component_type = "ai_model" + node.component_config.model_name = "gpt-4o" + node.component_config.llm_credential_id = 10 + node.component_config.backend_route = None + node.component_config.temperature = None + node.component_config.max_tokens = None + node.component_config.frequency_penalty = None + node.component_config.presence_penalty = None + node.component_config.top_p = None + node.component_config.timeout = None + node.component_config.max_retries = None + node.component_config.response_format = None + + mock_db = MagicMock() + mock_base_cred = MagicMock() + mock_base_cred.llm_credential = _make_credential("openai") + mock_db.query.return_value.filter.return_value.first.return_value = mock_base_cred + + resolve_llm_for_node(node, db=mock_db) + + mock_create.assert_called_once() + call_kwargs = mock_create.call_args + assert call_kwargs.kwargs["user_profile_id"] is None + assert call_kwargs.kwargs["user_role"] is None + + @patch("services.llm.create_llm_from_db") + def test_threads_context_via_llm_model_config_path(self, mock_create): + """When resolved via llm_model_config_id FK, user context is still threaded.""" + from services.llm import resolve_llm_for_node + + mock_create.return_value = MagicMock(name="llm_instance") + + node = MagicMock() + node.node_id = "categorizer_abc" + node.workflow.owner_id = 99 + + # Not an ai_model node — uses llm_model_config_id path + node.component_config.component_type = "categorizer" + node.component_config.llm_model_config_id = 50 + node.component_config.backend_route = None + + # Mock the linked ai_model config + mock_tc = MagicMock() + mock_tc.component_type = "ai_model" + mock_tc.model_name = "gpt-4o" + mock_tc.llm_credential_id = 10 + mock_tc.backend_route = None + mock_tc.temperature = 0.5 + mock_tc.max_tokens = None + mock_tc.frequency_penalty = None + mock_tc.presence_penalty = None + mock_tc.top_p = None + mock_tc.timeout = None + mock_tc.max_retries = None + mock_tc.response_format = None + + mock_db = MagicMock() + mock_db.get.return_value = mock_tc + + mock_base_cred = MagicMock() + mock_base_cred.llm_credential = _make_credential("anthropic") + mock_db.query.return_value.filter.return_value.first.return_value = mock_base_cred + + resolve_llm_for_node(node, db=mock_db) + + mock_create.assert_called_once() + call_kwargs = mock_create.call_args + assert call_kwargs.kwargs["user_profile_id"] == 99 + assert call_kwargs.kwargs["user_role"] is None + + +# --------------------------------------------------------------------------- +# _route_provider_to_type +# --------------------------------------------------------------------------- + + +class TestRouteProviderToType: + def test_known_providers(self): + from services.llm import _route_provider_to_type + + assert _route_provider_to_type("openai") == "openai" + assert _route_provider_to_type("anthropic") == "anthropic" + assert _route_provider_to_type("glm") == "glm" + + def test_unknown_defaults_to_openai_compatible(self): + from services.llm import _route_provider_to_type + + assert _route_provider_to_type("venice") == "openai_compatible" + assert _route_provider_to_type("custom") == "openai_compatible" + + +# --------------------------------------------------------------------------- +# resolve_llm_for_node — backend_route +# --------------------------------------------------------------------------- + + +class TestResolveLlmForNodeBackendRoute: + """Verify backend_route is extracted and passed through.""" + + @patch("services.llm.create_llm_from_db") + def test_resolve_llm_for_node_uses_backend_route(self, mock_create): + """ai_model node with backend_route set skips credential lookup.""" + from services.llm import resolve_llm_for_node + + mock_create.return_value = MagicMock(name="llm_instance") + + node = MagicMock() + node.node_id = "model_abc" + node.workflow.owner_id = 10 + node.component_config.component_type = "ai_model" + node.component_config.model_name = "glm-4.7" + node.component_config.backend_route = "venice-glm-4.7" + node.component_config.llm_credential_id = None + node.component_config.temperature = 0.7 + node.component_config.max_tokens = None + node.component_config.frequency_penalty = None + node.component_config.presence_penalty = None + node.component_config.top_p = None + node.component_config.timeout = None + node.component_config.max_retries = None + node.component_config.response_format = None + + mock_db = MagicMock() + + resolve_llm_for_node(node, db=mock_db) + + mock_create.assert_called_once() + call_kwargs = mock_create.call_args + # credential is None, backend_route is passed + assert call_kwargs.args[0] is None # credential + assert call_kwargs.args[1] == "glm-4.7" # model_name + assert call_kwargs.kwargs["backend_route"] == "venice-glm-4.7" + assert call_kwargs.kwargs["temperature"] == 0.7 + # DB was NOT queried for credentials + mock_db.query.assert_not_called() + + @patch("services.llm.create_llm_from_db") + def test_resolve_llm_for_node_backend_route_via_fk(self, mock_create): + """Agent node references ai_model config with backend_route.""" + from services.llm import resolve_llm_for_node + + mock_create.return_value = MagicMock(name="llm_instance") + + node = MagicMock() + node.node_id = "agent_xyz" + node.workflow.owner_id = 20 + + node.component_config.component_type = "agent" + node.component_config.llm_model_config_id = 50 + node.component_config.backend_route = None + + # Mock the linked ai_model config with backend_route + mock_tc = MagicMock() + mock_tc.component_type = "ai_model" + mock_tc.model_name = "deepseek-r1" + mock_tc.backend_route = "venice-deepseek-r1" + mock_tc.llm_credential_id = None + mock_tc.temperature = None + mock_tc.max_tokens = 4096 + mock_tc.frequency_penalty = None + mock_tc.presence_penalty = None + mock_tc.top_p = None + mock_tc.timeout = None + mock_tc.max_retries = None + mock_tc.response_format = None + + mock_db = MagicMock() + mock_db.get.return_value = mock_tc + + resolve_llm_for_node(node, db=mock_db) + + mock_create.assert_called_once() + call_kwargs = mock_create.call_args + assert call_kwargs.args[0] is None # credential + assert call_kwargs.args[1] == "deepseek-r1" # model_name + assert call_kwargs.kwargs["backend_route"] == "venice-deepseek-r1" + assert call_kwargs.kwargs["max_tokens"] == 4096 + # DB was NOT queried for credentials + mock_db.query.assert_not_called() + + @patch("services.llm.create_llm_from_db") + def test_resolve_llm_for_node_fallback_when_no_backend_route(self, mock_create): + """ai_model node without backend_route uses credential path.""" + from services.llm import resolve_llm_for_node + + mock_create.return_value = MagicMock(name="llm_instance") + + node = MagicMock() + node.node_id = "model_legacy" + node.workflow.owner_id = 5 + node.component_config.component_type = "ai_model" + node.component_config.model_name = "gpt-4o" + node.component_config.backend_route = None + node.component_config.llm_credential_id = 10 + node.component_config.temperature = None + node.component_config.max_tokens = None + node.component_config.frequency_penalty = None + node.component_config.presence_penalty = None + node.component_config.top_p = None + node.component_config.timeout = None + node.component_config.max_retries = None + node.component_config.response_format = None + + mock_db = MagicMock() + mock_base_cred = MagicMock() + mock_base_cred.llm_credential = _make_credential("openai") + mock_db.query.return_value.filter.return_value.first.return_value = mock_base_cred + + resolve_llm_for_node(node, db=mock_db) + + mock_create.assert_called_once() + # Credential was looked up from DB + mock_db.query.assert_called_once() + call_kwargs = mock_create.call_args + assert call_kwargs.args[0] is not None # credential passed + + +# --------------------------------------------------------------------------- +# resolve_credential_for_node — backend_route provider inference +# --------------------------------------------------------------------------- + + +class TestResolveCredentialForNodeBackendRoute: + """Verify backend_route provider inference in resolve_credential_for_node.""" + + def test_resolve_credential_for_node_infers_provider_from_route(self): + """Route 'venice-glm-4.7' infers provider_type='openai_compatible'.""" + from services.llm import resolve_credential_for_node + + node = MagicMock() + node.node_id = "model_venice" + node.component_config.component_type = "ai_model" + node.component_config.backend_route = "venice-glm-4.7" + node.component_config.llm_credential_id = None + node.component_config.llm_model_config_id = None + + mock_db = MagicMock() + result = resolve_credential_for_node(node, db=mock_db) + + assert result.provider_type == "openai_compatible" + mock_db.query.assert_not_called() + + def test_resolve_credential_for_node_known_provider_anthropic(self): + """Route 'anthropic-claude-sonnet' infers provider_type='anthropic'.""" + from services.llm import resolve_credential_for_node + + node = MagicMock() + node.node_id = "model_anth" + node.component_config.component_type = "ai_model" + node.component_config.backend_route = "anthropic-claude-sonnet" + node.component_config.llm_credential_id = None + node.component_config.llm_model_config_id = None + + mock_db = MagicMock() + result = resolve_credential_for_node(node, db=mock_db) + + assert result.provider_type == "anthropic" + + def test_resolve_credential_for_node_known_provider_openai(self): + """Route 'openai-gpt-4o' infers provider_type='openai'.""" + from services.llm import resolve_credential_for_node + + node = MagicMock() + node.node_id = "model_oai" + node.component_config.component_type = "ai_model" + node.component_config.backend_route = "openai-gpt-4o" + node.component_config.llm_credential_id = None + node.component_config.llm_model_config_id = None + + mock_db = MagicMock() + result = resolve_credential_for_node(node, db=mock_db) + + assert result.provider_type == "openai" + + def test_resolve_credential_for_node_via_fk_infers_provider(self): + """Agent node referencing ai_model config with backend_route.""" + from services.llm import resolve_credential_for_node + + node = MagicMock() + node.node_id = "agent_abc" + node.component_config.component_type = "agent" + node.component_config.backend_route = None + node.component_config.llm_credential_id = None + node.component_config.llm_model_config_id = 50 + + mock_tc = MagicMock() + mock_tc.component_type = "ai_model" + mock_tc.backend_route = "glm-some-model" + mock_tc.llm_credential_id = None + + mock_db = MagicMock() + mock_db.get.return_value = mock_tc + + result = resolve_credential_for_node(node, db=mock_db) + + assert result.provider_type == "glm" + + def test_resolve_credential_for_node_prefers_real_credential(self): + """When both backend_route and llm_credential_id are set, use real credential.""" + from services.llm import resolve_credential_for_node + + node = MagicMock() + node.node_id = "model_both" + node.component_config.component_type = "ai_model" + node.component_config.backend_route = "venice-glm-4.7" + node.component_config.llm_credential_id = 10 + node.component_config.llm_model_config_id = None + + mock_db = MagicMock() + mock_base_cred = MagicMock() + mock_base_cred.llm_credential = _make_credential("openai") + mock_db.query.return_value.filter.return_value.first.return_value = mock_base_cred + + result = resolve_credential_for_node(node, db=mock_db) + + # Should use the real credential, not infer from route + assert result.provider_type == "openai" + mock_db.query.assert_called_once() diff --git a/platform/tests/test_migrate_credentials.py b/platform/tests/test_migrate_credentials.py new file mode 100644 index 00000000..2e6ef7bc --- /dev/null +++ b/platform/tests/test_migrate_credentials.py @@ -0,0 +1,759 @@ +"""Tests for the migrate-credentials CLI command (provider/model structure).""" + +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml +from cryptography.fernet import Fernet + +from conftest import TestSession + +_TEST_KEY = Fernet.generate_key().decode() + + +def _run_cli(args: list[str]) -> tuple[int, str, str]: + """Run CLI main() with the given args, capturing stdout/stderr and exit code.""" + from cli.__main__ import main + + captured_out = [] + captured_err = [] + exit_code = 0 + + def mock_stdout_write(s): + captured_out.append(s) + return len(s) + + def mock_stderr_write(s): + captured_err.append(s) + return len(s) + + with patch.object(sys, "argv", ["cli"] + args), \ + patch.object(sys.stdout, "write", mock_stdout_write), \ + patch.object(sys.stderr, "write", mock_stderr_write): + try: + main() + except SystemExit as e: + try: + exit_code = int(e.code) if e.code is not None else 0 + except (TypeError, ValueError): + exit_code = 1 + + return exit_code, "".join(captured_out), "".join(captured_err) + + +@pytest.fixture +def agw_dir(tmp_path): + """Create a temporary agentgateway directory structure.""" + (tmp_path / "config.d" / "backends").mkdir(parents=True) + (tmp_path / "keys").mkdir() + # Create a dummy assemble-config.sh that succeeds + script = tmp_path / "assemble-config.sh" + script.write_text("#!/bin/bash\nexit 0\n") + script.chmod(0o755) + return tmp_path + + +@pytest.fixture +def cli_db(): + """Patch SessionLocal so CLI commands use the test database.""" + session = TestSession() + try: + with patch("database.SessionLocal", return_value=session): + yield session + finally: + session.close() + + +@pytest.fixture +def _patch_agw_settings(agw_dir): + """Patch settings for agentgateway config writer.""" + with patch("services.agentgateway_config.settings") as mock_settings: + mock_settings.AGENTGATEWAY_DIR = str(agw_dir) + mock_settings.FIELD_ENCRYPTION_KEY = _TEST_KEY + yield mock_settings + + +@pytest.fixture +def _patch_config_settings(agw_dir): + """Patch config.settings for the CLI command that reads AGENTGATEWAY_DIR.""" + with patch("config.settings") as mock_settings: + mock_settings.AGENTGATEWAY_DIR = str(agw_dir) + mock_settings.FIELD_ENCRYPTION_KEY = _TEST_KEY + yield mock_settings + + +def _create_llm_credential(db, user_profile, *, name, provider_type, api_key, base_url=""): + """Helper to create an LLM credential in the test DB.""" + from models.credential import BaseCredential, LLMProviderCredential + + base = BaseCredential( + user_profile_id=user_profile.id, + name=name, + credential_type="llm", + ) + db.add(base) + db.flush() + + llm = LLMProviderCredential( + base_credentials_id=base.id, + provider_type=provider_type, + api_key=api_key, + base_url=base_url, + ) + db.add(llm) + db.commit() + return llm + + +# --------------------------------------------------------------------------- +# TestResolveProviderName +# --------------------------------------------------------------------------- + + +class TestResolveProviderName: + def test_openai(self): + from cli.__main__ import _resolve_provider_name + + cred = MagicMock(provider_type="openai") + assert _resolve_provider_name(cred) == "openai" + + def test_anthropic(self): + from cli.__main__ import _resolve_provider_name + + cred = MagicMock(provider_type="anthropic") + assert _resolve_provider_name(cred) == "anthropic" + + def test_glm(self): + from cli.__main__ import _resolve_provider_name + + cred = MagicMock(provider_type="glm") + assert _resolve_provider_name(cred) == "glm" + + def test_openai_compatible_spaces_to_underscores(self): + from cli.__main__ import _resolve_provider_name + + cred = MagicMock(provider_type="openai_compatible") + cred.base_credentials.name = "Venice AI" + assert _resolve_provider_name(cred) == "venice_ai" + + def test_openai_compatible_hyphens_sanitized(self): + from cli.__main__ import _resolve_provider_name + + cred = MagicMock(provider_type="openai_compatible") + cred.base_credentials.name = "my-custom-llm" + assert _resolve_provider_name(cred) == "my_custom_llm" + + def test_openai_compatible_special_chars_removed(self): + from cli.__main__ import _resolve_provider_name + + cred = MagicMock(provider_type="openai_compatible") + cred.base_credentials.name = "LLM Provider (v2)!" + assert _resolve_provider_name(cred) == "llm_provider_v2" + + def test_openai_compatible_fallback_to_custom_id(self): + from cli.__main__ import _resolve_provider_name + + cred = MagicMock(provider_type="openai_compatible") + cred.base_credentials.name = "" + cred.base_credentials_id = 42 + assert _resolve_provider_name(cred) == "custom42" + + +# --------------------------------------------------------------------------- +# TestModelToSlug +# --------------------------------------------------------------------------- + + +class TestModelToSlug: + def test_standard_model(self): + from cli.__main__ import _model_to_slug + + assert _model_to_slug("gpt-4o") == "gpt-4o" + + def test_model_with_slashes(self): + from cli.__main__ import _model_to_slug + + assert _model_to_slug("meta/llama-3.1-70b") == "meta-llama-3.1-70b" + + def test_model_with_colons(self): + from cli.__main__ import _model_to_slug + + assert _model_to_slug("ollama:llama3") == "ollama-llama3" + + def test_model_with_spaces(self): + from cli.__main__ import _model_to_slug + + assert _model_to_slug("Claude Sonnet 4") == "claude-sonnet-4" + + def test_model_special_chars(self): + from cli.__main__ import _model_to_slug + + assert _model_to_slug("model@v2#beta") == "modelv2beta" + + def test_empty_model(self): + from cli.__main__ import _model_to_slug + + assert _model_to_slug("") == "default" + + def test_dots_preserved(self): + from cli.__main__ import _model_to_slug + + assert _model_to_slug("glm-4.7") == "glm-4.7" + + +# --------------------------------------------------------------------------- +# TestParseBaseUrl +# --------------------------------------------------------------------------- + + +class TestParseBaseUrl: + def test_https_default_port(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("https://api.openai.com/v1") + assert host == "api.openai.com:443" + assert path == "/v1" + + def test_http_default_port(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("http://localhost/api") + assert host == "localhost:80" + assert path == "/api" + + def test_explicit_port(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("http://localhost:11434/v1") + assert host == "localhost:11434" + assert path == "/v1" + + def test_trailing_slash_stripped(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("https://api.venice.ai/api/v1/") + assert host == "api.venice.ai:443" + assert path == "/api/v1" + + def test_empty_url(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("") + assert host == "" + assert path == "" + + def test_none_like_url(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url(" ") + assert host == "" + assert path == "" + + def test_https_no_path(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("https://api.anthropic.com") + assert host == "api.anthropic.com:443" + assert path == "" + + def test_openai_appends_chat_completions(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("https://api.openai.com/v1", provider_type="openai") + assert path == "/v1/chat/completions" + + def test_openai_compatible_appends_chat_completions(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("https://api.venice.ai/api/v1", provider_type="openai_compatible") + assert path == "/api/v1/chat/completions" + + def test_anthropic_appends_messages(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("https://api.anthropic.com/v1", provider_type="anthropic") + assert path == "/v1/messages" + + def test_glm_appends_chat_completions(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("https://api.example.com/v1", provider_type="glm") + assert path == "/v1/chat/completions" + + def test_no_duplicate_suffix(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url( + "https://api.openai.com/v1/chat/completions", provider_type="openai" + ) + assert path == "/v1/chat/completions" + + def test_no_suffix_without_provider_type(self): + from cli.__main__ import _parse_base_url + + host, path = _parse_base_url("https://api.openai.com/v1") + assert path == "/v1" + + +# --------------------------------------------------------------------------- +# TestMigrateCredentials +# --------------------------------------------------------------------------- + + +class TestMigrateCredentials: + def test_migrate_single_openai( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + _create_llm_credential( + cli_db, user_profile, + name="OpenAI (default)", + provider_type="openai", + api_key="sk-openai-test-key", + ) + + code, out, err = _run_cli(["migrate-credentials"]) + + assert code == 0 + data = json.loads(out) + assert data["providers"] == 1 + assert data["models"] == 1 + + # Key file written and encrypted + key_path = agw_dir / "keys" / "openai.key" + assert key_path.exists() + fernet = Fernet(_TEST_KEY.encode()) + decrypted = fernet.decrypt(key_path.read_bytes()).decode() + assert decrypted == "sk-openai-test-key" + + # Provider dir created with _provider.yaml + provider_yaml = agw_dir / "config.d" / "backends" / "openai" / "_provider.yaml" + assert provider_yaml.exists() + parsed = yaml.safe_load(provider_yaml.read_text()) + assert parsed["backendAuth"]["key"] == "${OPENAI_API_KEY}" + assert "openAI" in parsed["provider"] + + # Model file created + model_yaml = agw_dir / "config.d" / "backends" / "openai" / "openai-default.yaml" + assert model_yaml.exists() + model_data = yaml.safe_load(model_yaml.read_text()) + assert model_data["model"] == "OpenAI (default)" + + def test_migrate_single_anthropic( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + _create_llm_credential( + cli_db, user_profile, + name="Anthropic", + provider_type="anthropic", + api_key="sk-ant-test", + ) + + code, out, err = _run_cli(["migrate-credentials"]) + + assert code == 0 + data = json.loads(out) + assert data["providers"] == 1 + + provider_yaml = agw_dir / "config.d" / "backends" / "anthropic" / "_provider.yaml" + assert provider_yaml.exists() + parsed = yaml.safe_load(provider_yaml.read_text()) + assert "anthropic" in parsed["provider"] + + def test_migrate_openai_compatible_with_base_url( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + _create_llm_credential( + cli_db, user_profile, + name="Venice AI", + provider_type="openai_compatible", + api_key="sk-venice-key", + base_url="https://api.venice.ai/api/v1", + ) + + code, out, err = _run_cli(["migrate-credentials"]) + + assert code == 0 + data = json.loads(out) + assert data["providers"] == 1 + + # Provider name sanitized: no hyphens + provider_yaml = agw_dir / "config.d" / "backends" / "venice_ai" / "_provider.yaml" + assert provider_yaml.exists() + parsed = yaml.safe_load(provider_yaml.read_text()) + assert parsed["hostOverride"] == "api.venice.ai:443" + assert parsed["pathOverride"] == "/api/v1/chat/completions" + + def test_multiple_credentials_same_provider_share_key( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + """Multiple credentials for the same provider should share one key and one _provider.yaml.""" + _create_llm_credential( + cli_db, user_profile, + name="OpenAI GPT-4o", + provider_type="openai", + api_key="sk-openai-key", + ) + _create_llm_credential( + cli_db, user_profile, + name="OpenAI o1", + provider_type="openai", + api_key="sk-openai-key", + ) + + code, out, err = _run_cli(["migrate-credentials"]) + + assert code == 0 + data = json.loads(out) + assert data["providers"] == 1 + assert data["models"] == 2 + + # One key file + assert (agw_dir / "keys" / "openai.key").exists() + + # Two model files + openai_dir = agw_dir / "config.d" / "backends" / "openai" + model_files = sorted(f.name for f in openai_dir.glob("*.yaml") if f.name != "_provider.yaml") + assert len(model_files) == 2 + + def test_different_providers_get_separate_dirs( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + _create_llm_credential( + cli_db, user_profile, + name="OpenAI", + provider_type="openai", + api_key="sk-openai", + ) + _create_llm_credential( + cli_db, user_profile, + name="Anthropic", + provider_type="anthropic", + api_key="sk-ant", + ) + + code, out, err = _run_cli(["migrate-credentials"]) + + assert code == 0 + data = json.loads(out) + assert data["providers"] == 2 + + assert (agw_dir / "config.d" / "backends" / "openai" / "_provider.yaml").exists() + assert (agw_dir / "config.d" / "backends" / "anthropic" / "_provider.yaml").exists() + assert (agw_dir / "keys" / "openai.key").exists() + assert (agw_dir / "keys" / "anthropic.key").exists() + + def test_skip_already_migrated_idempotent( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + _create_llm_credential( + cli_db, user_profile, + name="OpenAI", + provider_type="openai", + api_key="sk-openai-test", + ) + + # Pre-create provider dir to simulate previous migration + provider_dir = agw_dir / "config.d" / "backends" / "openai" + provider_dir.mkdir(parents=True, exist_ok=True) + (provider_dir / "_provider.yaml").write_text("existing") + + code, out, err = _run_cli(["migrate-credentials"]) + + assert code == 0 + data = json.loads(out) + assert data["providers"] == 0 + assert data["skipped"] == 1 + + # Files unchanged + assert (provider_dir / "_provider.yaml").read_text() == "existing" + + def test_force_overwrites_existing( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + _create_llm_credential( + cli_db, user_profile, + name="OpenAI", + provider_type="openai", + api_key="sk-new-key", + ) + + # Pre-create files + provider_dir = agw_dir / "config.d" / "backends" / "openai" + provider_dir.mkdir(parents=True, exist_ok=True) + (provider_dir / "_provider.yaml").write_text("old-config") + (agw_dir / "keys" / "openai.key").write_text("old-data") + + code, out, err = _run_cli(["migrate-credentials", "--force"]) + + assert code == 0 + data = json.loads(out) + assert data["providers"] == 1 + assert data["skipped"] == 0 + + # Key file overwritten with encrypted content + fernet = Fernet(_TEST_KEY.encode()) + decrypted = fernet.decrypt((agw_dir / "keys" / "openai.key").read_bytes()).decode() + assert decrypted == "sk-new-key" + + def test_dry_run_no_files_written( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + _create_llm_credential( + cli_db, user_profile, + name="OpenAI", + provider_type="openai", + api_key="sk-test", + ) + + code, out, err = _run_cli(["migrate-credentials", "--dry-run"]) + + assert code == 0 + data = json.loads(out) + assert data["providers"] == 1 + assert data["models"] == 1 + + # No files created + assert not (agw_dir / "keys" / "openai.key").exists() + assert not (agw_dir / "config.d" / "backends" / "openai").exists() + + # Dry-run output visible in stderr + assert "DRY-RUN" in err + + def test_single_reassemble_at_end( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + """Multiple providers should trigger reassemble_config exactly once at the end.""" + _create_llm_credential( + cli_db, user_profile, + name="OpenAI", + provider_type="openai", + api_key="sk-openai", + ) + _create_llm_credential( + cli_db, user_profile, + name="Anthropic", + provider_type="anthropic", + api_key="sk-ant", + ) + _create_llm_credential( + cli_db, user_profile, + name="Venice AI", + provider_type="openai_compatible", + api_key="sk-venice", + base_url="https://api.venice.ai/api/v1", + ) + + # Track reassemble_config calls + reassemble_calls = [] + + import services.agentgateway_config as agw_mod + original_reassemble = agw_mod.reassemble_config + + def counting_reassemble(): + reassemble_calls.append(1) + return original_reassemble() + + with patch.object(agw_mod, "reassemble_config", counting_reassemble): + code, out, err = _run_cli(["migrate-credentials"]) + + assert code == 0 + data = json.loads(out) + assert data["providers"] == 3 + + # reassemble_config called exactly once + assert len(reassemble_calls) == 1 + + def test_populate_routes_sets_backend_route( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + """--populate-routes should set backend_route on component_configs.""" + from models.credential import BaseCredential + from models.node import BaseComponentConfig + + cred = _create_llm_credential( + cli_db, user_profile, + name="OpenAI GPT-4o", + provider_type="openai", + api_key="sk-openai", + ) + + # Create a component config pointing to this credential + base_cred = cli_db.query(BaseCredential).filter(BaseCredential.id == cred.base_credentials_id).one() + cfg = BaseComponentConfig( + component_type="ai_model", + llm_credential_id=base_cred.id, + model_name="gpt-4o", + ) + cli_db.add(cfg) + cli_db.commit() + + code, out, err = _run_cli(["migrate-credentials", "--populate-routes"]) + + assert code == 0 + data = json.loads(out) + assert data["routes_updated"] == 1 + + # Re-query from the same session (CLI uses its own SessionLocal instance) + cli_db.expire_all() + updated_cfg = cli_db.query(BaseComponentConfig).filter(BaseComponentConfig.id == cfg.id).one() + assert updated_cfg.backend_route == "openai-openai-gpt-4o" + + def test_no_credentials_found( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + code, out, err = _run_cli(["migrate-credentials"]) + + assert code == 0 + data = json.loads(out) + assert data["providers"] == 0 + assert data["message"] == "No LLM credentials found" + + +# --------------------------------------------------------------------------- +# TestRollback +# --------------------------------------------------------------------------- + + +class TestRollback: + def test_rollback_deletes_dirs_keys_and_reassembles( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings, tmp_path + ): + # Create provider directories and keys to roll back + openai_dir = agw_dir / "config.d" / "backends" / "openai" + openai_dir.mkdir(parents=True) + (openai_dir / "_provider.yaml").write_text("provider: openAI") + (openai_dir / "gpt-4o.yaml").write_text("model: gpt-4o") + (agw_dir / "keys" / "openai.key").write_text("encrypted-key") + + anthropic_dir = agw_dir / "config.d" / "backends" / "anthropic" + anthropic_dir.mkdir(parents=True) + (anthropic_dir / "_provider.yaml").write_text("provider: anthropic") + (agw_dir / "keys" / "anthropic.key").write_text("encrypted-key") + + with patch("cli.__main__._set_env_var") as mock_set_env: + code, out, err = _run_cli(["migrate-credentials", "--rollback"]) + + assert code == 0 + data = json.loads(out) + assert data["rolled_back"] == 2 + + # Directories deleted + assert not openai_dir.exists() + assert not anthropic_dir.exists() + assert not (agw_dir / "keys" / "openai.key").exists() + assert not (agw_dir / "keys" / "anthropic.key").exists() + + # _set_env_var was called to disable agentgateway + mock_set_env.assert_called_once() + call_args = mock_set_env.call_args + assert call_args[0][1] == "AGENTGATEWAY_ENABLED" + assert call_args[0][2] == "false" + + def test_rollback_no_providers( + self, cli_db, agw_dir, _patch_agw_settings, _patch_config_settings + ): + code, out, err = _run_cli(["migrate-credentials", "--rollback"]) + + assert code == 0 + data = json.loads(out) + assert data["rolled_back"] == 0 + + def test_rollback_dry_run( + self, cli_db, agw_dir, _patch_agw_settings, _patch_config_settings + ): + openai_dir = agw_dir / "config.d" / "backends" / "openai" + openai_dir.mkdir(parents=True) + (openai_dir / "_provider.yaml").write_text("provider: openAI") + (agw_dir / "keys" / "openai.key").write_text("encrypted") + + code, out, err = _run_cli(["migrate-credentials", "--rollback", "--dry-run"]) + + assert code == 0 + data = json.loads(out) + assert data["rolled_back"] == 1 + + # Files NOT deleted (dry run) + assert openai_dir.exists() + assert "DRY-RUN" in err + + def test_rollback_clears_backend_routes( + self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings + ): + """--populate-routes on rollback should null out backend_route values.""" + from models.node import BaseComponentConfig + + # Create a component config with a backend_route + cfg = BaseComponentConfig( + component_type="ai_model", + backend_route="openai-gpt-4o", + ) + cli_db.add(cfg) + cli_db.commit() + + # Create a provider dir so rollback has something to remove + openai_dir = agw_dir / "config.d" / "backends" / "openai" + openai_dir.mkdir(parents=True) + (openai_dir / "_provider.yaml").write_text("provider: openAI") + + with patch("cli.__main__._set_env_var"): + code, out, err = _run_cli(["migrate-credentials", "--rollback", "--populate-routes"]) + + assert code == 0 + data = json.loads(out) + assert data["rolled_back"] == 1 + assert data["routes_cleared"] == 1 + + # Re-query from the same session (CLI uses its own SessionLocal instance) + cli_db.expire_all() + updated_cfg = cli_db.query(BaseComponentConfig).filter(BaseComponentConfig.id == cfg.id).one() + assert updated_cfg.backend_route is None + + +# --------------------------------------------------------------------------- +# TestSetEnvVar +# --------------------------------------------------------------------------- + + +class TestSetEnvVar: + def test_updates_existing_var(self, tmp_path): + from cli.__main__ import _set_env_var + + env_file = tmp_path / ".env" + env_file.write_text("FOO=bar\nAGENTGATEWAY_ENABLED=true\nBAZ=qux\n") + + _set_env_var(env_file, "AGENTGATEWAY_ENABLED", "false") + + content = env_file.read_text() + assert "AGENTGATEWAY_ENABLED=false" in content + assert "AGENTGATEWAY_ENABLED=true" not in content + assert "FOO=bar" in content + assert "BAZ=qux" in content + + def test_appends_new_var(self, tmp_path): + from cli.__main__ import _set_env_var + + env_file = tmp_path / ".env" + env_file.write_text("FOO=bar\n") + + _set_env_var(env_file, "AGENTGATEWAY_ENABLED", "false") + + content = env_file.read_text() + assert "AGENTGATEWAY_ENABLED=false" in content + assert "FOO=bar" in content + + def test_creates_file_if_missing(self, tmp_path): + from cli.__main__ import _set_env_var + + env_file = tmp_path / ".env" + assert not env_file.exists() + + _set_env_var(env_file, "AGENTGATEWAY_ENABLED", "false") + + assert env_file.exists() + assert env_file.read_text().strip() == "AGENTGATEWAY_ENABLED=false" diff --git a/platform/tests/test_providers_api.py b/platform/tests/test_providers_api.py new file mode 100644 index 00000000..26cab59f --- /dev/null +++ b/platform/tests/test_providers_api.py @@ -0,0 +1,491 @@ +"""Tests for the Providers API endpoints (/api/v1/providers/).""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +# Ensure platform/ is importable +_platform_dir = str(Path(__file__).resolve().parent.parent) +if _platform_dir not in sys.path: + sys.path.insert(0, _platform_dir) + +# Module path for patching agentgateway_config service functions. +# Endpoint functions use local imports so we patch on the service module. +_AGW = "services.agentgateway_config" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(db): + """Create a test FastAPI app with DB overridden to use test session.""" + from main import app as _app + from database import get_db + + def _override_get_db(): + yield db + + _app.dependency_overrides[get_db] = _override_get_db + yield _app + _app.dependency_overrides.clear() + + +@pytest.fixture +def client(app): + return TestClient(app) + + +@pytest.fixture +def auth_client(client, api_key): + """Authenticated client (admin user from conftest).""" + client.headers["Authorization"] = f"Bearer {api_key.key}" + return client + + +@pytest.fixture +def normal_user(db): + """Create a non-admin user with an API key.""" + import uuid + + import bcrypt + from models.user import APIKey, UserProfile, UserRole + + profile = UserProfile( + username="normaluser", + password_hash=bcrypt.hashpw(b"pass", bcrypt.gensalt()).decode(), + external_user_id="normal-999", + role=UserRole.NORMAL, + ) + db.add(profile) + db.commit() + db.refresh(profile) + + raw = str(uuid.uuid4()) + key = APIKey(user_id=profile.id, key=raw, name="normal-key", prefix=raw[:8]) + db.add(key) + db.commit() + db.refresh(key) + return profile, key + + +@pytest.fixture +def normal_client(client, normal_user): + """Authenticated client for a normal (non-admin) user.""" + _, key = normal_user + client.headers["Authorization"] = f"Bearer {key.key}" + return client + + +# --------------------------------------------------------------------------- +# Mocks for agentgateway config service +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_agw_enabled(): + """Mock settings.AGENTGATEWAY_ENABLED = True.""" + with patch("api.providers.settings") as mock_settings: + mock_settings.AGENTGATEWAY_ENABLED = True + mock_settings.AGENTGATEWAY_DIR = "/tmp/fake-agw" + mock_settings.FIELD_ENCRYPTION_KEY = "test-key" + yield mock_settings + + +@pytest.fixture +def mock_agw_disabled(): + """Mock settings.AGENTGATEWAY_ENABLED = False.""" + with patch("api.providers.settings") as mock_settings: + mock_settings.AGENTGATEWAY_ENABLED = False + yield mock_settings + + +# --------------------------------------------------------------------------- +# AGENTGATEWAY_ENABLED guard +# --------------------------------------------------------------------------- + + +class TestAgentgatewayGuard: + """All endpoints should return 404 when AGENTGATEWAY_ENABLED is False.""" + + def test_create_provider_disabled(self, auth_client, mock_agw_disabled): + resp = auth_client.post("/api/v1/providers/", json={ + "provider": "openai", "provider_type": "openai", + "api_key": "sk-test", "base_url": "", + }) + assert resp.status_code == 404 + + def test_list_providers_disabled(self, auth_client, mock_agw_disabled): + resp = auth_client.get("/api/v1/providers/") + assert resp.status_code == 404 + + def test_delete_provider_disabled(self, auth_client, mock_agw_disabled): + resp = auth_client.delete("/api/v1/providers/openai/") + assert resp.status_code == 404 + + def test_fetch_models_disabled(self, auth_client, mock_agw_disabled): + resp = auth_client.get("/api/v1/providers/openai/fetch-models/") + assert resp.status_code == 404 + + def test_add_models_disabled(self, auth_client, mock_agw_disabled): + resp = auth_client.post("/api/v1/providers/openai/models/", json={"models": []}) + assert resp.status_code == 404 + + def test_delete_model_disabled(self, auth_client, mock_agw_disabled): + resp = auth_client.delete("/api/v1/providers/openai/models/gpt-4o/") + assert resp.status_code == 404 + + def test_list_models_disabled(self, auth_client, mock_agw_disabled): + resp = auth_client.get("/api/v1/providers/openai/models/") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Admin-only enforcement +# --------------------------------------------------------------------------- + + +class TestAdminOnly: + """Non-admin users should get 403 on all endpoints.""" + + def test_create_provider_non_admin(self, normal_client, mock_agw_enabled): + resp = normal_client.post("/api/v1/providers/", json={ + "provider": "openai", "provider_type": "openai", + "api_key": "sk-test", "base_url": "", + }) + assert resp.status_code == 403 + + def test_list_providers_non_admin(self, normal_client, mock_agw_enabled): + resp = normal_client.get("/api/v1/providers/") + assert resp.status_code == 403 + + def test_delete_provider_non_admin(self, normal_client, mock_agw_enabled): + resp = normal_client.delete("/api/v1/providers/openai/") + assert resp.status_code == 403 + + def test_fetch_models_non_admin(self, normal_client, mock_agw_enabled): + resp = normal_client.get("/api/v1/providers/openai/fetch-models/") + assert resp.status_code == 403 + + def test_add_models_non_admin(self, normal_client, mock_agw_enabled): + resp = normal_client.post("/api/v1/providers/openai/models/", json={"models": []}) + assert resp.status_code == 403 + + def test_delete_model_non_admin(self, normal_client, mock_agw_enabled): + resp = normal_client.delete("/api/v1/providers/openai/models/gpt-4o/") + assert resp.status_code == 403 + + def test_list_models_non_admin(self, normal_client, mock_agw_enabled): + resp = normal_client.get("/api/v1/providers/openai/models/") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Create provider +# --------------------------------------------------------------------------- + + +class TestCreateProvider: + @patch(f"{_AGW}.reassemble_config") + @patch(f"{_AGW}.add_provider") + @patch(f"{_AGW}.write_provider_key") + def test_create_provider_calls_services( + self, mock_write_key, mock_add_provider, mock_reassemble, + auth_client, mock_agw_enabled, + ): + resp = auth_client.post("/api/v1/providers/", json={ + "provider": "venice", + "provider_type": "openai_compatible", + "api_key": "sk-venice-123", + "base_url": "https://api.venice.ai/api/v1", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["provider"] == "venice" + assert data["provider_type"] == "openai_compatible" + assert data["models"] == [] + + mock_write_key.assert_called_once_with("venice", "sk-venice-123") + mock_add_provider.assert_called_once() + call_kwargs = mock_add_provider.call_args + assert call_kwargs[1]["provider"] == "venice" or call_kwargs[0][0] == "venice" + mock_reassemble.assert_called_once() + + @patch(f"{_AGW}.reassemble_config") + @patch(f"{_AGW}.add_provider") + @patch(f"{_AGW}.write_provider_key") + def test_create_provider_url_parsing( + self, mock_write_key, mock_add_provider, mock_reassemble, + auth_client, mock_agw_enabled, + ): + resp = auth_client.post("/api/v1/providers/", json={ + "provider": "custom", + "provider_type": "openai_compatible", + "api_key": "sk-test", + "base_url": "https://api.example.com:8443/v1", + }) + assert resp.status_code == 201 + + call_kwargs = mock_add_provider.call_args[1] + assert call_kwargs["host_override"] == "api.example.com:8443" + assert "/chat/completions" in call_kwargs["path_override"] + + @patch(f"{_AGW}.reassemble_config") + @patch(f"{_AGW}.add_provider") + @patch(f"{_AGW}.write_provider_key") + def test_create_anthropic_provider_path_suffix( + self, mock_write_key, mock_add_provider, mock_reassemble, + auth_client, mock_agw_enabled, + ): + resp = auth_client.post("/api/v1/providers/", json={ + "provider": "anthropic", + "provider_type": "anthropic", + "api_key": "sk-ant-test", + "base_url": "https://api.anthropic.com/v1", + }) + assert resp.status_code == 201 + + call_kwargs = mock_add_provider.call_args[1] + assert call_kwargs["path_override"].endswith("/messages") + + @patch(f"{_AGW}.reassemble_config") + @patch(f"{_AGW}.add_provider") + @patch(f"{_AGW}.write_provider_key") + def test_create_provider_no_base_url( + self, mock_write_key, mock_add_provider, mock_reassemble, + auth_client, mock_agw_enabled, + ): + resp = auth_client.post("/api/v1/providers/", json={ + "provider": "openai", + "provider_type": "openai", + "api_key": "sk-openai", + "base_url": "", + }) + assert resp.status_code == 201 + call_kwargs = mock_add_provider.call_args[1] + assert call_kwargs["host_override"] == "" + assert call_kwargs["path_override"] == "" + + +# --------------------------------------------------------------------------- +# List providers +# --------------------------------------------------------------------------- + + +class TestListProviders: + @patch(f"{_AGW}.list_models", return_value=[ + {"slug": "gpt-4o", "model_name": "gpt-4o", "route": "openai-gpt-4o"}, + ]) + @patch(f"{_AGW}.get_provider_config", return_value={ + "provider": {"openAI": {}}, + "hostOverride": "api.openai.com:443", + }) + @patch(f"{_AGW}.list_providers", return_value=["openai"]) + def test_list_providers_returns_structure( + self, mock_list, mock_config, mock_models, + auth_client, mock_agw_enabled, + ): + resp = auth_client.get("/api/v1/providers/") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["provider"] == "openai" + assert data[0]["provider_type"] == "openai" + assert len(data[0]["models"]) == 1 + + @patch(f"{_AGW}.list_providers", return_value=[]) + def test_list_providers_empty(self, mock_list, auth_client, mock_agw_enabled): + resp = auth_client.get("/api/v1/providers/") + assert resp.status_code == 200 + assert resp.json() == [] + + +# --------------------------------------------------------------------------- +# Delete provider +# --------------------------------------------------------------------------- + + +class TestDeleteProvider: + @patch(f"{_AGW}.remove_provider") + def test_delete_provider_calls_service( + self, mock_remove, auth_client, mock_agw_enabled, + ): + resp = auth_client.delete("/api/v1/providers/venice/") + assert resp.status_code == 204 + mock_remove.assert_called_once_with("venice") + + +# --------------------------------------------------------------------------- +# Add models (batch) +# --------------------------------------------------------------------------- + + +class TestAddModels: + @patch(f"{_AGW}.list_models", return_value=[ + {"slug": "gpt-4o", "model_name": "gpt-4o", "route": "openai-gpt-4o"}, + {"slug": "gpt-4o-mini", "model_name": "gpt-4o-mini", "route": "openai-gpt-4o-mini"}, + ]) + @patch(f"{_AGW}.reassemble_config") + @patch(f"{_AGW}.add_model") + def test_add_models_batch( + self, mock_add_model, mock_reassemble, mock_list_models, + auth_client, mock_agw_enabled, + ): + resp = auth_client.post("/api/v1/providers/openai/models/", json={ + "models": [ + {"slug": "gpt-4o", "model_name": "gpt-4o"}, + {"slug": "gpt-4o-mini", "model_name": "gpt-4o-mini"}, + ], + }) + assert resp.status_code == 201 + data = resp.json() + assert data["provider"] == "openai" + assert len(data["models"]) == 2 + + # Each model added with reassemble=False + assert mock_add_model.call_count == 2 + for call in mock_add_model.call_args_list: + assert call[1]["reassemble"] is False + + # Single reassemble at the end + mock_reassemble.assert_called_once() + + +# --------------------------------------------------------------------------- +# Delete model +# --------------------------------------------------------------------------- + + +class TestDeleteModel: + @patch(f"{_AGW}.remove_model") + def test_delete_model_calls_service( + self, mock_remove, auth_client, mock_agw_enabled, + ): + resp = auth_client.delete("/api/v1/providers/openai/models/gpt-4o/") + assert resp.status_code == 204 + mock_remove.assert_called_once_with("openai", "gpt-4o") + + +# --------------------------------------------------------------------------- +# List models +# --------------------------------------------------------------------------- + + +class TestListModels: + @patch(f"{_AGW}.list_models", return_value=[ + {"slug": "gpt-4o", "model_name": "gpt-4o", "route": "openai-gpt-4o"}, + ]) + def test_list_models_returns_list( + self, mock_list, auth_client, mock_agw_enabled, + ): + resp = auth_client.get("/api/v1/providers/openai/models/") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["slug"] == "gpt-4o" + + +# --------------------------------------------------------------------------- +# Fetch models (from provider API) +# --------------------------------------------------------------------------- + + +class TestFetchModels: + @patch("api.providers._fetch_provider_models") + def test_fetch_models_returns_list( + self, mock_fetch, auth_client, mock_agw_enabled, + ): + mock_fetch.return_value = [ + {"id": "gpt-4o", "name": "gpt-4o"}, + {"id": "gpt-4o-mini", "name": "gpt-4o-mini"}, + ] + resp = auth_client.get("/api/v1/providers/openai/fetch-models/") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 + assert data[0]["id"] == "gpt-4o" + + @patch("api.providers._fetch_provider_models") + def test_fetch_models_provider_error( + self, mock_fetch, auth_client, mock_agw_enabled, + ): + mock_fetch.side_effect = Exception("Connection refused") + resp = auth_client.get("/api/v1/providers/openai/fetch-models/") + assert resp.status_code == 502 + + +# --------------------------------------------------------------------------- +# URL parsing unit tests +# --------------------------------------------------------------------------- + + +class TestParseBaseUrl: + def test_empty_url(self): + from api.providers import _parse_base_url + + host, path = _parse_base_url("", "openai") + assert host == "" + assert path == "" + + def test_openai_standard(self): + from api.providers import _parse_base_url + + host, path = _parse_base_url("https://api.openai.com/v1", "openai") + assert host == "api.openai.com:443" + assert path == "/v1/chat/completions" + + def test_anthropic_standard(self): + from api.providers import _parse_base_url + + host, path = _parse_base_url("https://api.anthropic.com/v1", "anthropic") + assert host == "api.anthropic.com:443" + assert path == "/v1/messages" + + def test_custom_port(self): + from api.providers import _parse_base_url + + host, path = _parse_base_url("https://api.example.com:8443/v1", "openai_compatible") + assert host == "api.example.com:8443" + assert path == "/v1/chat/completions" + + def test_http_default_port(self): + from api.providers import _parse_base_url + + host, path = _parse_base_url("http://localhost/v1", "openai_compatible") + assert host == "localhost:80" + assert path == "/v1/chat/completions" + + def test_already_has_suffix_openai(self): + from api.providers import _parse_base_url + + host, path = _parse_base_url( + "https://api.example.com/v1/chat/completions", "openai_compatible", + ) + assert path == "/v1/chat/completions" + + def test_already_has_suffix_anthropic(self): + from api.providers import _parse_base_url + + host, path = _parse_base_url( + "https://api.anthropic.com/v1/messages", "anthropic", + ) + assert path == "/v1/messages" + + def test_glm_gets_chat_completions(self): + from api.providers import _parse_base_url + + host, path = _parse_base_url("https://api.z.ai/api/paas/v4", "glm") + assert path == "/api/paas/v4/chat/completions" + + def test_trailing_slash_stripped(self): + from api.providers import _parse_base_url + + host, path = _parse_base_url("https://api.openai.com/v1/", "openai") + assert path == "/v1/chat/completions" From a3ad092bc05f3ffc29d160a679c31b44f68369df Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Fri, 3 Jul 2026 14:34:48 +0930 Subject: [PATCH 02/10] fix(agentgateway): model pass-through, fail-loud key collisions, HTTP-upstream TLS - add_model no longer writes a display name into provider..model (a hard outbound override) -> leave unset for caller model pass-through; strip "null" sentinels post-assembly. - migrate/config: multiple keys for one provider now fail loud instead of silently keeping the first. - _parse_base_url carries the URL scheme -> add_provider(use_tls); backendTLS emitted only for https so plain-HTTP upstreams (LAN Qwen, Ollama) are reachable. Co-Authored-By: Claude Opus 4.8 --- platform/api/providers.py | 19 +++-- platform/services/agentgateway_config.py | 71 +++++++++++++++++-- platform/tests/test_agentgateway_config.py | 51 ++++++++++++++ platform/tests/test_providers_api.py | 80 ++++++++++++++++++---- 4 files changed, 197 insertions(+), 24 deletions(-) diff --git a/platform/api/providers.py b/platform/api/providers.py index ee5457ca..4e8534d8 100644 --- a/platform/api/providers.py +++ b/platform/api/providers.py @@ -83,15 +83,19 @@ def _require_agentgateway(): # --------------------------------------------------------------------------- -def _parse_base_url(base_url: str, provider_type: str) -> tuple[str, str]: - """Parse base_url into (host_override, path_override) for _provider.yaml. +def _parse_base_url(base_url: str, provider_type: str) -> tuple[str, str, bool]: + """Parse base_url into (host_override, path_override, use_tls) for _provider.yaml. - Returns (host_override, path_override) with appropriate endpoint suffix. + Returns (host_override, path_override, use_tls) with appropriate endpoint + suffix. ``use_tls`` is False only for explicit ``http://`` upstreams + (e.g. a LAN Qwen box or local Ollama); anything else — https or no + base_url at all (provider default hosts are https) — keeps TLS on. """ if not base_url: - return "", "" + return "", "", True parsed = urlparse(base_url.rstrip("/")) + use_tls = parsed.scheme != "http" host = parsed.hostname or "" port = parsed.port if host and not port: @@ -107,7 +111,7 @@ def _parse_base_url(base_url: str, provider_type: str) -> tuple[str, str]: if path_override and not path_override.endswith("/messages"): path_override = path_override.rstrip("/") + "/messages" - return host_override, path_override + return host_override, path_override, use_tls ANTHROPIC_MODELS = [ @@ -185,7 +189,9 @@ def create_provider( write_provider_key, ) - host_override, path_override = _parse_base_url(payload.base_url, payload.provider_type) + host_override, path_override, use_tls = _parse_base_url( + payload.base_url, payload.provider_type + ) try: write_provider_key(payload.provider, payload.api_key) @@ -194,6 +200,7 @@ def create_provider( provider_type=payload.provider_type, host_override=host_override, path_override=path_override, + use_tls=use_tls, ) reassemble_config() restart_agentgateway() # New key requires restart to load env var diff --git a/platform/services/agentgateway_config.py b/platform/services/agentgateway_config.py index 0daf8e41..8dc4eae4 100644 --- a/platform/services/agentgateway_config.py +++ b/platform/services/agentgateway_config.py @@ -114,11 +114,19 @@ def add_provider( provider_type: str, host_override: str = "", path_override: str = "", + use_tls: bool = True, ) -> None: """Write _provider.yaml to config.d/backends//. Creates the provider directory if needed. Does NOT trigger reassembly (caller should add models first, then reassemble). + + ``use_tls`` controls the ``backendTLS`` policy. agentgateway treats the + mere PRESENCE of ``backendTLS`` (even ``{}``) as "speak TLS to the + upstream", so for plain-http upstreams (``use_tls=False``, e.g. a LAN + Qwen box or local Ollama) the key must be omitted entirely or requests + fail with a TLS InvalidContentType error. Defaults to True (https), + the correct setting for hosted providers. """ provider_dir = _agw_dir() / "config.d" / "backends" / provider provider_dir.mkdir(parents=True, exist_ok=True) @@ -128,8 +136,9 @@ def add_provider( fragment: dict = { "provider": _build_provider_type(provider_type), "backendAuth": {"key": env_var_ref}, - "backendTLS": {}, } + if use_tls: + fragment["backendTLS"] = {} if host_override: fragment["hostOverride"] = host_override @@ -180,18 +189,34 @@ def get_provider_config(provider: str) -> dict: def add_model( provider: str, model_slug: str, - model_name: str, + model_name: str | None = None, reassemble: bool = True, ) -> None: """Write model file to config.d/backends//.yaml. - Content is just: model: + ``model_name`` must be a REAL upstream model id (e.g. ``gpt-4o``): + agentgateway treats it as a hard outbound override, rewriting every + proxied request's model to it verbatim. Omit it (the default) to leave + the override unset so the caller's own model name passes through. + + NEVER put a friendly/display label (e.g. a credential name like + "OpenAI (default)") here: if a client-facing alias is ever wanted, it + belongs in agentgateway's ``policies.ai.modelAliases`` (client-model -> + real-model rewrite), never in ``provider..model``. + + Content is ``model: ``, or a pass-through marker when omitted. Optionally triggers reassembly (set reassemble=False for batch adds). """ provider_dir = _agw_dir() / "config.d" / "backends" / provider provider_dir.mkdir(parents=True, exist_ok=True) - content = yaml.dump({"model": model_name}, default_flow_style=False) + if model_name: + content = yaml.dump({"model": model_name}, default_flow_style=False) + else: + # No model: key -> no override. assemble-config.sh folds the missing + # key into the literal string "null"; reassemble_config() strips that + # sentinel from the assembled config to restore pass-through. + content = "# pass-through: no model override\n" tmp_path = provider_dir / f"{model_slug}.yaml.tmp" final_path = provider_dir / f"{model_slug}.yaml" @@ -332,6 +357,43 @@ def update_rules(role: str, rules: list[str]) -> None: # --------------------------------------------------------------------------- +def _strip_null_model_overrides(config_path: Path) -> None: + """Remove sentinel model overrides from the assembled config.yaml. + + assemble-config.sh unconditionally sets ``provider..model`` from + each model file; files without a ``model:`` key (pass-through models) + make yq emit the literal string "null". agentgateway treats ANY present + model value as a hard outbound override, so drop those sentinels to let + the caller's own model name pass through. + """ + if not config_path.exists(): + return + + data = yaml.safe_load(config_path.read_text()) + if not data: + return + + changed = False + for bind in data.get("binds") or []: + for listener in bind.get("listeners") or []: + for route in listener.get("routes") or []: + for backend in route.get("backends") or []: + provider = (backend.get("ai") or {}).get("provider") or {} + for pconf in provider.values(): + if ( + isinstance(pconf, dict) + and "model" in pconf + and pconf["model"] in ("null", "", None) + ): + del pconf["model"] + changed = True + + if changed: + tmp_path = config_path.with_name(config_path.name + ".tmp") + tmp_path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False)) + os.rename(tmp_path, config_path) + + def reassemble_config() -> None: """Call assemble-config.sh to regenerate config.yaml from fragments. @@ -353,6 +415,7 @@ def reassemble_config() -> None: ) if result.returncode != 0: raise RuntimeError(f"Config assembly failed: {result.stderr}") + _strip_null_model_overrides(agw_dir / "config.yaml") finally: fcntl.flock(lockfile, fcntl.LOCK_UN) diff --git a/platform/tests/test_agentgateway_config.py b/platform/tests/test_agentgateway_config.py index 8d2f1409..fe235caa 100644 --- a/platform/tests/test_agentgateway_config.py +++ b/platform/tests/test_agentgateway_config.py @@ -159,6 +159,57 @@ def test_creates_directory_and_provider_yaml(self, agw_dir): assert parsed["hostOverride"] == "api.venice.ai:443" assert parsed["pathOverride"] == "/api/v1/chat/completions" + def test_http_upstream_omits_backend_tls(self, agw_dir): + """Plain-http upstreams (use_tls=False) must NOT get a backendTLS key. + + agentgateway treats the mere presence of backendTLS (even {}) as + "speak TLS to the upstream", which breaks http backends like a LAN + Qwen box with a TLS InvalidContentType error. + """ + from services.agentgateway_config import add_provider + + add_provider( + provider="qwen", + provider_type="openai_compatible", + host_override="192.168.0.73:8080", + path_override="/v1/chat/completions", + use_tls=False, + ) + + parsed = yaml.safe_load( + (agw_dir / "config.d" / "backends" / "qwen" / "_provider.yaml").read_text() + ) + assert "backendTLS" not in parsed + assert parsed["hostOverride"] == "192.168.0.73:8080" + + def test_https_upstream_includes_backend_tls(self, agw_dir): + """https upstreams (use_tls=True) DO get backendTLS: {}.""" + from services.agentgateway_config import add_provider + + add_provider( + provider="openai", + provider_type="openai", + host_override="api.openai.com:443", + path_override="/v1/chat/completions", + use_tls=True, + ) + + parsed = yaml.safe_load( + (agw_dir / "config.d" / "backends" / "openai" / "_provider.yaml").read_text() + ) + assert parsed["backendTLS"] == {} + + def test_use_tls_defaults_to_true(self, agw_dir): + """Callers that don't pass use_tls keep the safe https behavior.""" + from services.agentgateway_config import add_provider + + add_provider(provider="venice", provider_type="openai_compatible") + + parsed = yaml.safe_load( + (agw_dir / "config.d" / "backends" / "venice" / "_provider.yaml").read_text() + ) + assert parsed["backendTLS"] == {} + def test_anthropic_provider(self, agw_dir): from services.agentgateway_config import add_provider diff --git a/platform/tests/test_providers_api.py b/platform/tests/test_providers_api.py index 26cab59f..54b53b2d 100644 --- a/platform/tests/test_providers_api.py +++ b/platform/tests/test_providers_api.py @@ -193,11 +193,12 @@ def test_list_models_non_admin(self, normal_client, mock_agw_enabled): class TestCreateProvider: + @patch(f"{_AGW}.restart_agentgateway") @patch(f"{_AGW}.reassemble_config") @patch(f"{_AGW}.add_provider") @patch(f"{_AGW}.write_provider_key") def test_create_provider_calls_services( - self, mock_write_key, mock_add_provider, mock_reassemble, + self, mock_write_key, mock_add_provider, mock_reassemble, mock_restart, auth_client, mock_agw_enabled, ): resp = auth_client.post("/api/v1/providers/", json={ @@ -218,11 +219,12 @@ def test_create_provider_calls_services( assert call_kwargs[1]["provider"] == "venice" or call_kwargs[0][0] == "venice" mock_reassemble.assert_called_once() + @patch(f"{_AGW}.restart_agentgateway") @patch(f"{_AGW}.reassemble_config") @patch(f"{_AGW}.add_provider") @patch(f"{_AGW}.write_provider_key") def test_create_provider_url_parsing( - self, mock_write_key, mock_add_provider, mock_reassemble, + self, mock_write_key, mock_add_provider, mock_reassemble, mock_restart, auth_client, mock_agw_enabled, ): resp = auth_client.post("/api/v1/providers/", json={ @@ -236,12 +238,35 @@ def test_create_provider_url_parsing( call_kwargs = mock_add_provider.call_args[1] assert call_kwargs["host_override"] == "api.example.com:8443" assert "/chat/completions" in call_kwargs["path_override"] + assert call_kwargs["use_tls"] is True + @patch(f"{_AGW}.restart_agentgateway") + @patch(f"{_AGW}.reassemble_config") + @patch(f"{_AGW}.add_provider") + @patch(f"{_AGW}.write_provider_key") + def test_create_provider_http_upstream_disables_tls( + self, mock_write_key, mock_add_provider, mock_reassemble, mock_restart, + auth_client, mock_agw_enabled, + ): + """http:// base_url (e.g. LAN Qwen) must thread use_tls=False through.""" + resp = auth_client.post("/api/v1/providers/", json={ + "provider": "qwen", + "provider_type": "openai_compatible", + "api_key": "sk-qwen", + "base_url": "http://192.168.0.73:8080/v1", + }) + assert resp.status_code == 201 + + call_kwargs = mock_add_provider.call_args[1] + assert call_kwargs["host_override"] == "192.168.0.73:8080" + assert call_kwargs["use_tls"] is False + + @patch(f"{_AGW}.restart_agentgateway") @patch(f"{_AGW}.reassemble_config") @patch(f"{_AGW}.add_provider") @patch(f"{_AGW}.write_provider_key") def test_create_anthropic_provider_path_suffix( - self, mock_write_key, mock_add_provider, mock_reassemble, + self, mock_write_key, mock_add_provider, mock_reassemble, mock_restart, auth_client, mock_agw_enabled, ): resp = auth_client.post("/api/v1/providers/", json={ @@ -255,11 +280,12 @@ def test_create_anthropic_provider_path_suffix( call_kwargs = mock_add_provider.call_args[1] assert call_kwargs["path_override"].endswith("/messages") + @patch(f"{_AGW}.restart_agentgateway") @patch(f"{_AGW}.reassemble_config") @patch(f"{_AGW}.add_provider") @patch(f"{_AGW}.write_provider_key") def test_create_provider_no_base_url( - self, mock_write_key, mock_add_provider, mock_reassemble, + self, mock_write_key, mock_add_provider, mock_reassemble, mock_restart, auth_client, mock_agw_enabled, ): resp = auth_client.post("/api/v1/providers/", json={ @@ -272,6 +298,8 @@ def test_create_provider_no_base_url( call_kwargs = mock_add_provider.call_args[1] assert call_kwargs["host_override"] == "" assert call_kwargs["path_override"] == "" + # Provider default hosts are https — TLS stays on + assert call_kwargs["use_tls"] is True # --------------------------------------------------------------------------- @@ -313,9 +341,10 @@ def test_list_providers_empty(self, mock_list, auth_client, mock_agw_enabled): class TestDeleteProvider: + @patch(f"{_AGW}.restart_agentgateway") @patch(f"{_AGW}.remove_provider") def test_delete_provider_calls_service( - self, mock_remove, auth_client, mock_agw_enabled, + self, mock_remove, mock_restart, auth_client, mock_agw_enabled, ): resp = auth_client.delete("/api/v1/providers/venice/") assert resp.status_code == 204 @@ -430,42 +459,65 @@ class TestParseBaseUrl: def test_empty_url(self): from api.providers import _parse_base_url - host, path = _parse_base_url("", "openai") + host, path, use_tls = _parse_base_url("", "openai") assert host == "" assert path == "" + # No base_url means the provider's default (https) host — keep TLS + assert use_tls is True def test_openai_standard(self): from api.providers import _parse_base_url - host, path = _parse_base_url("https://api.openai.com/v1", "openai") + host, path, use_tls = _parse_base_url("https://api.openai.com/v1", "openai") assert host == "api.openai.com:443" assert path == "/v1/chat/completions" + assert use_tls is True def test_anthropic_standard(self): from api.providers import _parse_base_url - host, path = _parse_base_url("https://api.anthropic.com/v1", "anthropic") + host, path, use_tls = _parse_base_url("https://api.anthropic.com/v1", "anthropic") assert host == "api.anthropic.com:443" assert path == "/v1/messages" def test_custom_port(self): from api.providers import _parse_base_url - host, path = _parse_base_url("https://api.example.com:8443/v1", "openai_compatible") + host, path, use_tls = _parse_base_url("https://api.example.com:8443/v1", "openai_compatible") assert host == "api.example.com:8443" assert path == "/v1/chat/completions" def test_http_default_port(self): from api.providers import _parse_base_url - host, path = _parse_base_url("http://localhost/v1", "openai_compatible") + host, path, use_tls = _parse_base_url("http://localhost/v1", "openai_compatible") assert host == "localhost:80" assert path == "/v1/chat/completions" + assert use_tls is False + + def test_http_upstream_disables_tls(self): + """http:// upstreams (e.g. a LAN Qwen box) must report use_tls=False.""" + from api.providers import _parse_base_url + + host, path, use_tls = _parse_base_url( + "http://192.168.0.73:8080/v1", "openai_compatible", + ) + assert host == "192.168.0.73:8080" + assert path == "/v1/chat/completions" + assert use_tls is False + + def test_https_upstream_enables_tls(self): + from api.providers import _parse_base_url + + host, path, use_tls = _parse_base_url( + "https://api.example.com:8443/v1", "openai_compatible", + ) + assert use_tls is True def test_already_has_suffix_openai(self): from api.providers import _parse_base_url - host, path = _parse_base_url( + host, path, use_tls = _parse_base_url( "https://api.example.com/v1/chat/completions", "openai_compatible", ) assert path == "/v1/chat/completions" @@ -473,7 +525,7 @@ def test_already_has_suffix_openai(self): def test_already_has_suffix_anthropic(self): from api.providers import _parse_base_url - host, path = _parse_base_url( + host, path, use_tls = _parse_base_url( "https://api.anthropic.com/v1/messages", "anthropic", ) assert path == "/v1/messages" @@ -481,11 +533,11 @@ def test_already_has_suffix_anthropic(self): def test_glm_gets_chat_completions(self): from api.providers import _parse_base_url - host, path = _parse_base_url("https://api.z.ai/api/paas/v4", "glm") + host, path, use_tls = _parse_base_url("https://api.z.ai/api/paas/v4", "glm") assert path == "/api/paas/v4/chat/completions" def test_trailing_slash_stripped(self): from api.providers import _parse_base_url - host, path = _parse_base_url("https://api.openai.com/v1/", "openai") + host, path, use_tls = _parse_base_url("https://api.openai.com/v1/", "openai") assert path == "/v1/chat/completions" From 3e6d1fca1f291b7adc8881051aba683b5f542b22 Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Fri, 3 Jul 2026 14:34:48 +0930 Subject: [PATCH 03/10] feat(agentgateway): make the gateway the sole holder of LLM keys (Phase 1b) Removes every raw-key code path from pipelit; workflow code holds only a 60s ES256 JWT that agentgateway swaps for the real key. - llm.py: delete direct-provider fallback (agentgateway is the only path; defensive raise if disabled); replace _fake_credential_from_route (api_key="") with RouteProviderInfo (provider_type + base_url, no key). - _agent_shared.py / dsl_compiler.py: stop reading credential.api_key; dsl sources models from agentgateway. - api/credentials.py: LLM credential CRUD/test/models -> 410 (git/gateway/tool intact). - alembic a41f9c2d7e10 + models/credential.py: drop llm_credentials.api_key column. - config.py: AGENTGATEWAY_ENABLED defaults True; main.py: fail-fast startup health guard; conftest: disabled for tests. - cli migrate-credentials: deprecation stub (keystore emptied). Co-Authored-By: Claude Opus 4.8 --- ...1f9c2d7e10_drop_llm_credentials_api_key.py | 57 ++ platform/api/credentials.py | 176 +--- platform/cli/__main__.py | 359 +-------- platform/components/_agent_shared.py | 10 +- platform/config.py | 2 +- platform/conftest.py | 7 + platform/main.py | 15 + platform/models/credential.py | 9 +- platform/services/dsl_compiler.py | 50 +- platform/services/llm.py | 139 ++-- platform/tests/test_api.py | 45 +- platform/tests/test_api_extended.py | 152 ++-- .../tests/test_credentials_agentgateway.py | 66 +- platform/tests/test_credentials_glm.py | 110 +-- platform/tests/test_deep_agent.py | 33 +- platform/tests/test_dsl_compiler.py | 83 +- platform/tests/test_llm_agentgateway.py | 39 +- platform/tests/test_migrate_credentials.py | 762 ++---------------- platform/tests/test_services_llm.py | 240 +++--- platform/tests/test_workflow_create.py | 1 - platform/tests/test_zombie_execution_fixes.py | 4 + 21 files changed, 691 insertions(+), 1668 deletions(-) create mode 100644 platform/alembic/versions/a41f9c2d7e10_drop_llm_credentials_api_key.py diff --git a/platform/alembic/versions/a41f9c2d7e10_drop_llm_credentials_api_key.py b/platform/alembic/versions/a41f9c2d7e10_drop_llm_credentials_api_key.py new file mode 100644 index 00000000..7ba68724 --- /dev/null +++ b/platform/alembic/versions/a41f9c2d7e10_drop_llm_credentials_api_key.py @@ -0,0 +1,57 @@ +"""drop llm_credentials.api_key — raw LLM keys live only in agentgateway + +Revision ID: a41f9c2d7e10 +Revises: 758cd1ca2aad +Create Date: 2026-07-03 00:00:00.000000 + +Phase 1(b) hard cutover (Pipelit#188): agentgateway is the sole holder of +LLM provider API keys. All code paths that read or wrote the encrypted +``llm_credentials.api_key`` column have been removed (direct-provider LLM +construction, credential CRUD/test/models endpoints, credential-field env +injection, DSL-compiler model fetch, and the one-shot ``migrate-credentials`` +CLI, now deprecated), so the at-rest secret is dropped. + +Column audit — what stays and why: + +* ``provider_type`` — KEPT. Still read for backend-route resolution and + provider inference (services/llm.py ``_resolve_backend_name``, + ``resolve_credential_for_node`` → web-search provider detection). +* ``base_url`` — KEPT. Still resolvable via workspace credential-field env + injection (components/_agent_shared.py ``_resolve_credential_field``) and + used for legacy backend naming. +* ``organization_id`` — KEPT. Still resolvable via + ``_resolve_credential_field`` for workspace env vars (non-secret metadata). +* ``custom_headers`` — KEPT (schema stability). No product-code reader + remains after the LLM CRUD removal; retained as harmless non-secret + metadata rather than widening this hard-cutover migration. Candidate for + a future cleanup revision. +* ``api_key`` — DROPPED. This is the trust-boundary secret; no code reads + or writes it anymore. + +Hard cutover: no live user credentials exist, so there is no +data-preservation branch. ``downgrade()`` re-adds the column empty +(nullable — the original encrypted values are intentionally unrecoverable). +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'a41f9c2d7e10' +down_revision: Union[str, Sequence[str], None] = '758cd1ca2aad' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # batch_alter_table: portable across postgres and sqlite (test/dev DBs). + with op.batch_alter_table('llm_credentials') as batch_op: + batch_op.drop_column('api_key') + + +def downgrade() -> None: + # Re-create the column shape only — the encrypted key data is gone for + # good (hard cutover); nullable so existing rows remain valid. + with op.batch_alter_table('llm_credentials') as batch_op: + batch_op.add_column(sa.Column('api_key', sa.Text(), nullable=True)) diff --git a/platform/api/credentials.py b/platform/api/credentials.py index 2ffce851..78274d7b 100644 --- a/platform/api/credentials.py +++ b/platform/api/credentials.py @@ -4,8 +4,6 @@ import logging -import httpx - from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session @@ -18,7 +16,6 @@ BaseCredential, GatewayCredential, GitCredential, - LLMProviderCredential, ToolCredential, ) from models.user import UserProfile @@ -35,22 +32,19 @@ router = APIRouter() -ANTHROPIC_MODELS = [ - "claude-opus-4-6", - "claude-sonnet-4-6", - "claude-sonnet-4-20250514", - "claude-opus-4-0-20250514", - "claude-haiku-4-5-20251001", - "claude-3-5-sonnet-20241022", -] +# LLM credentials are no longer created, updated, or stored by pipelit. +# Raw provider API keys live exclusively in agentgateway (managed via the +# admin providers API / agentgateway config.d). Any attempt to manage an +# ``llm``-typed credential through this router is rejected with 410 Gone. +LLM_CREDENTIALS_GONE_DETAIL = ( + "LLM credentials are now managed by agentgateway. Creating, updating, " + "or testing 'llm' credentials in pipelit has been removed — configure " + "providers and API keys through the agentgateway admin providers API." +) + -MINIMAX_MODELS = [ - "MiniMax-M2.5", - "MiniMax-M2.5-highspeed", - "MiniMax-M2.1", - "MiniMax-M2.1-highspeed", - "MiniMax-M2", -] +def _reject_llm_credential() -> None: + raise HTTPException(status_code=410, detail=LLM_CREDENTIALS_GONE_DETAIL) def _mask(value: str) -> str: @@ -69,16 +63,10 @@ def _serialize_credential(cred: BaseCredential, db: Session) -> dict: "detail": {}, "agentgateway_backend": None, } - if cred.credential_type == "llm" and cred.llm_credential: - llm = cred.llm_credential - data["detail"] = { - "provider_type": llm.provider_type, - "api_key": _mask(llm.api_key), - "base_url": llm.base_url, - "organization_id": llm.organization_id, - "custom_headers": llm.custom_headers, - } - elif cred.credential_type == "gateway" and cred.gateway_credential: + # NOTE: no ``llm`` branch — LLM keys live in agentgateway, and pipelit + # never serializes LLM provider secrets. Leftover llm-typed rows (kept + # for legacy llm_credential_id routing) serialize with an empty detail. + if cred.credential_type == "gateway" and cred.gateway_credential: gw = cred.gateway_credential data["detail"] = { "gateway_credential_id": gw.gateway_credential_id, @@ -124,6 +112,9 @@ def create_credential( db: Session = Depends(get_db), profile: UserProfile = Depends(get_current_user), ): + if payload.credential_type == "llm": + _reject_llm_credential() + base = BaseCredential( user_profile_id=profile.id, name=payload.name, @@ -133,17 +124,7 @@ def create_credential( db.flush() detail = payload.detail or {} - if payload.credential_type == "llm": - sub = LLMProviderCredential( - base_credentials_id=base.id, - provider_type=detail.get("provider_type", "openai_compatible"), - api_key=detail.get("api_key", ""), - base_url=detail.get("base_url", ""), - organization_id=detail.get("organization_id", ""), - custom_headers=detail.get("custom_headers", {}), - ) - db.add(sub) - elif payload.credential_type == "gateway": + if payload.credential_type == "gateway": adapter_type = detail.get("adapter_type", "") token = detail.get("token", "") config = detail.get("config") @@ -230,17 +211,15 @@ def update_credential( if not cred: raise HTTPException(status_code=404, detail="Credential not found.") + if cred.credential_type == "llm": + _reject_llm_credential() + if payload.name is not None: cred.name = payload.name detail = payload.detail if detail: - if cred.credential_type == "llm" and cred.llm_credential: - llm = cred.llm_credential - for field in ("provider_type", "api_key", "base_url", "organization_id", "custom_headers"): - if field in detail: - setattr(llm, field, detail[field]) - elif cred.credential_type == "gateway" and cred.gateway_credential: + if cred.credential_type == "gateway" and cred.gateway_credential: gw = cred.gateway_credential gw_update_kwargs: dict = {} new_adapter_type = None @@ -403,56 +382,15 @@ def test_credential( return {"ok": False, "detail": "not found in gateway"} return {"ok": True, "detail": health_info} - if not cred.llm_credential: - raise HTTPException(status_code=404, detail="LLM credential not found.") - llm = cred.llm_credential + # LLM credentials: direct-provider testing removed — keys live in + # agentgateway, pipelit has nothing to test them with. + if cred.credential_type == "llm": + _reject_llm_credential() - # Direct provider test - is_custom_base = llm.base_url and "anthropic.com" not in llm.base_url - try: - if llm.provider_type == "anthropic" and not is_custom_base: - resp = httpx.post( - "https://api.anthropic.com/v1/messages", - headers={ - "x-api-key": llm.api_key, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - json={ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 1, - "messages": [{"role": "user", "content": "hi"}], - }, - timeout=15, - ) - if resp.status_code >= 400: - return {"ok": False, "error": resp.text[:500]} - elif llm.provider_type == "glm": - base = llm.base_url.rstrip("/") if llm.base_url else "https://api.z.ai/api/paas/v4" - resp = httpx.get( - f"{base}/models", - headers={"Authorization": f"Bearer {llm.api_key}"}, - timeout=15, - ) - if resp.status_code in (401, 403): - return {"ok": False, "error": "Authentication failed - invalid API key"} - if resp.status_code >= 400: - return {"ok": False, "error": resp.text[:500]} - else: - # Test auth by listing models — no valid model name needed - base_url = llm.base_url.rstrip("/") if llm.base_url else "https://api.openai.com/v1" - resp = httpx.get( - f"{base_url}/models", - headers={"Authorization": f"Bearer {llm.api_key}"}, - timeout=15, - ) - if resp.status_code in (401, 403): - return {"ok": False, "error": "Authentication failed - invalid API key"} - if resp.status_code >= 400: - return {"ok": False, "error": resp.text[:500]} - return {"ok": True} - except Exception as exc: - return {"ok": False, "error": str(exc)[:500]} + raise HTTPException( + status_code=400, + detail=f"Credential type '{cred.credential_type}' does not support testing.", + ) @router.get("/{credential_id}/models/", response_model=list[CredentialModelOut]) @@ -461,49 +399,9 @@ def list_credential_models( db: Session = Depends(get_db), profile: UserProfile = Depends(get_current_user), ): - query = db.query(BaseCredential).filter(BaseCredential.id == credential_id, BaseCredential.credential_type == "llm") - if profile.role != UserRole.ADMIN: - query = query.filter(BaseCredential.user_profile_id == profile.id) - cred = query.first() - if not cred or not cred.llm_credential: - raise HTTPException(status_code=404, detail="LLM credential not found.") - llm = cred.llm_credential + """Removed: credential-derived model listing is gone. - # Direct provider call - is_custom_base = llm.base_url and "anthropic.com" not in llm.base_url - if llm.provider_type == "anthropic" and not is_custom_base: - try: - from anthropic import Anthropic - client = Anthropic(api_key=llm.api_key) - page = client.models.list(limit=100) - models = sorted(page.data, key=lambda m: m.id) - return [{"id": m.id, "name": m.id} for m in models] - except Exception: - logger.debug("Anthropic models API failed, using fallback list", exc_info=True) - return [{"id": m, "name": m} for m in ANTHROPIC_MODELS] - - if llm.provider_type == "glm": - base_url = llm.base_url.rstrip("/") if llm.base_url else "https://api.z.ai/api/paas/v4" - else: - base_url = llm.base_url.rstrip("/") if llm.base_url else "https://api.openai.com/v1" - try: - resp = httpx.get( - f"{base_url}/models", - headers={"Authorization": f"Bearer {llm.api_key}"}, - timeout=15, - ) - resp.raise_for_status() - body = resp.json() - if isinstance(body, dict): - data = body.get("data", []) - elif isinstance(body, list): - data = body - else: - logger.warning("Unexpected /models response format: %s", type(body).__name__) - data = [] - models = sorted(data, key=lambda m: m.get("id", "") if isinstance(m, dict) else "") - return [{"id": m["id"], "name": m["id"]} for m in models if isinstance(m, dict) and "id" in m] - except Exception: - if "minimax" in base_url.lower(): - return [{"id": m, "name": m} for m in MINIMAX_MODELS] - return [] + Models are listed from agentgateway (see api/available_models.py) — + pipelit no longer holds provider API keys to query upstream /models. + """ + _reject_llm_credential() diff --git a/platform/cli/__main__.py b/platform/cli/__main__.py index 610c7d7e..94122807 100644 --- a/platform/cli/__main__.py +++ b/platform/cli/__main__.py @@ -2,7 +2,7 @@ Usage: cd platform && python -m cli setup --username admin --password secret - cd platform && python -m cli apply-fixture default-agent --provider openai --model gpt-4o --api-key sk-... + cd platform && python -m cli apply-fixture default-agent --provider openai --model gpt-4o """ from __future__ import annotations @@ -117,10 +117,12 @@ def cmd_apply_fixture(args: argparse.Namespace) -> None: db.add(base_cred) db.flush() + # No api_key: pipelit does not store LLM keys — the raw provider key + # lives in agentgateway. This row only carries provider metadata for + # routing/provider inference. llm_cred = LLMProviderCredential( base_credentials_id=base_cred.id, provider_type=args.provider, - api_key=args.api_key, base_url=args.base_url or "", ) db.add(llm_cred) @@ -281,329 +283,24 @@ def cmd_apply_fixture(args: argparse.Namespace) -> None: db.close() -def _resolve_provider_name(credential) -> str: - """Derive provider name from credential. Must NOT contain hyphens.""" - if credential.provider_type in ("openai", "anthropic", "glm"): - return credential.provider_type - # openai_compatible: sanitize the credential name - base_name = credential.base_credentials.name or f"custom{credential.base_credentials_id}" - # Remove hyphens (breaks route parsing), lowercase, replace spaces/underscores - sanitized = base_name.lower().replace(" ", "_").replace("-", "_") - # Remove non-alphanumeric except underscores - sanitized = "".join(c for c in sanitized if c.isalnum() or c == "_") - return sanitized or f"custom{credential.base_credentials_id}" - - -def _model_to_slug(model_name: str) -> str: - """Convert model name to a filesystem-safe slug.""" - slug = model_name.lower().replace(" ", "-").replace("/", "-").replace(":", "-") - # Keep only alphanumeric, hyphens, dots - slug = "".join(c for c in slug if c.isalnum() or c in "-.") - return slug or "default" - - -def _parse_base_url(base_url: str, provider_type: str = "") -> tuple[str, str]: - """Parse a base_url into (host:port, path) for agentgateway overrides. - - Always includes the port (443 for HTTPS, 80 for HTTP) even when not - explicitly present in the URL. Appends the appropriate endpoint suffix - based on provider_type. - """ - from urllib.parse import urlparse - - if not base_url or not base_url.strip(): - return "", "" - - parsed = urlparse(base_url.rstrip("/")) - host = parsed.hostname or "" - port = parsed.port - if host and not port: - port = 443 if parsed.scheme == "https" else 80 - host_override = f"{host}:{port}" if host else "" - path_override = parsed.path or "" - - # Append endpoint suffix - if provider_type in ("openai_compatible", "glm", "openai"): - if path_override and not path_override.endswith("/chat/completions"): - path_override = path_override.rstrip("/") + "/chat/completions" - elif provider_type == "anthropic": - if path_override and not path_override.endswith("/messages"): - path_override = path_override.rstrip("/") + "/messages" - - return host_override, path_override - - def cmd_migrate_credentials(args: argparse.Namespace) -> None: - """Migrate existing LLM credentials from DB to agentgateway provider/model structure.""" - from pathlib import Path + """DEPRECATED — one-shot DB→agentgateway key migration is end-of-life. - from database import SessionLocal - from models.credential import BaseCredential, LLMProviderCredential - from models.node import BaseComponentConfig - from services.agentgateway_config import ( - add_model, - add_provider, - list_providers, - reassemble_config, - remove_provider_key, - write_provider_key, + Phase 1(b) hard cutover: pipelit no longer stores LLM provider API keys + (the encrypted ``llm_credentials.api_key`` column was dropped), so there + is nothing left to migrate. Provider keys and models are managed + directly in agentgateway (admin providers API / config.d). The command + is kept only to fail loudly with guidance instead of an obscure error. + """ + message = ( + "migrate-credentials is deprecated and inoperable: pipelit no longer " + "stores LLM API keys (llm_credentials.api_key was dropped). Manage " + "provider keys and models directly in agentgateway via the admin " + "providers API or its config.d directory." ) - - if args.rollback: - _rollback_migration(args) - return - - db = SessionLocal() - try: - # Query all LLM credentials with their base credential (for name) - credentials = ( - db.query(LLMProviderCredential) - .join(BaseCredential, LLMProviderCredential.base_credentials_id == BaseCredential.id) - .all() - ) - - if not credentials: - print(json.dumps({"migrated": 0, "providers": 0, "models": 0, "message": "No LLM credentials found"})) - return - - from config import settings - - agw_dir = Path(settings.AGENTGATEWAY_DIR) if settings.AGENTGATEWAY_DIR else None - if not agw_dir and not args.dry_run: - print(json.dumps({"error": "AGENTGATEWAY_DIR is not set"}), file=sys.stderr) - sys.exit(1) - - # Group credentials by provider - # Each provider gets one key + one _provider.yaml; each credential+model gets a model file - providers_seen: dict[str, list] = {} # provider_name -> [credential, ...] - for cred in credentials: - provider_name = _resolve_provider_name(cred) - providers_seen.setdefault(provider_name, []).append(cred) - - providers_created = 0 - models_created = 0 - skipped = 0 - - # Map base_credentials_id -> (provider_name, model_slug) for --populate-routes - route_map: dict[int, str] = {} - - for provider_name, creds in providers_seen.items(): - # Use the first credential for provider-level config (key, host, path) - first_cred = creds[0] - - # Check for existing provider dir/key - if agw_dir and not args.dry_run: - provider_dir_exists = (agw_dir / "config.d" / "backends" / provider_name).exists() - key_exists = (agw_dir / "keys" / f"{provider_name}.key").exists() - else: - provider_dir_exists = False - key_exists = False - - provider_already_exists = provider_dir_exists or key_exists - - if provider_already_exists and not args.force: - skipped += len(creds) - for c in creds: - print( - f"SKIP: {c.base_credentials.name} -> provider={provider_name} " - f"(already exists, use --force to overwrite)", - file=sys.stderr, - ) - continue - - # Parse base_url for host/path overrides - host_override, path_override = _parse_base_url( - first_cred.base_url or "", first_cred.provider_type - ) - - if args.dry_run: - print( - f"DRY-RUN: provider={provider_name}, " - f"type={first_cred.provider_type}, " - f"host_override={host_override!r}, path_override={path_override!r}", - file=sys.stderr, - ) - providers_created += 1 - for cred in creds: - model_name = cred.base_credentials.name or "default" - model_slug = _model_to_slug(model_name) - print( - f"DRY-RUN: model={model_slug} (name={model_name})", - file=sys.stderr, - ) - models_created += 1 - route_map[cred.base_credentials_id] = f"{provider_name}-{model_slug}" - continue - - # Write encrypted key file (one per provider, using first credential's key) - write_provider_key(provider_name, first_cred.api_key) - - # Write _provider.yaml - add_provider( - provider=provider_name, - provider_type=first_cred.provider_type, - host_override=host_override, - path_override=path_override, - ) - providers_created += 1 - - # Write model files for each credential - for cred in creds: - model_name = cred.base_credentials.name or "default" - model_slug = _model_to_slug(model_name) - - # Check if model file exists - if agw_dir: - model_file_exists = ( - agw_dir / "config.d" / "backends" / provider_name / f"{model_slug}.yaml" - ).exists() - else: - model_file_exists = False - - if model_file_exists and not args.force: - skipped += 1 - print( - f"SKIP: model {model_slug} in {provider_name} (already exists)", - file=sys.stderr, - ) - continue - - add_model( - provider=provider_name, - model_slug=model_slug, - model_name=model_name, - reassemble=False, - ) - models_created += 1 - route_map[cred.base_credentials_id] = f"{provider_name}-{model_slug}" - - # Single reassemble at the end - if not args.dry_run and (providers_created > 0 or models_created > 0): - reassemble_config() - - # Optionally populate backend_route on component_configs - routes_updated = 0 - if args.populate_routes and route_map and not args.dry_run: - configs = ( - db.query(BaseComponentConfig) - .filter(BaseComponentConfig.llm_credential_id.isnot(None)) - .all() - ) - for cfg in configs: - route = route_map.get(cfg.llm_credential_id) - if route: - cfg.backend_route = route - routes_updated += 1 - if routes_updated: - db.commit() - - result = { - "providers": providers_created, - "models": models_created, - "skipped": skipped, - "routes_updated": routes_updated, - } - print(json.dumps(result)) - summary_parts = [ - f"{providers_created} providers created", - f"{models_created} models created", - ] - if skipped: - summary_parts.append(f"{skipped} skipped") - if routes_updated: - summary_parts.append(f"{routes_updated} routes updated") - print(", ".join(summary_parts), file=sys.stderr) - except Exception: - raise - finally: - db.close() - - -def _rollback_migration(args: argparse.Namespace) -> None: - """Reverse a credential migration: delete provider dirs + keys, reassemble, disable.""" - import shutil - from pathlib import Path - - from config import settings - from database import SessionLocal - from models.node import BaseComponentConfig - from services.agentgateway_config import list_providers, reassemble_config, remove_provider_key - - agw_dir = Path(settings.AGENTGATEWAY_DIR) if settings.AGENTGATEWAY_DIR else None - if not agw_dir: - print(json.dumps({"error": "AGENTGATEWAY_DIR is not set"}), file=sys.stderr) - sys.exit(1) - - providers = list_providers() - if not providers: - print(json.dumps({"rolled_back": 0, "message": "No providers found"})) - return - - removed = 0 - for name in providers: - if args.dry_run: - print(f"DRY-RUN: would remove provider={name} (directory + key)", file=sys.stderr) - removed += 1 - continue - - provider_dir = agw_dir / "config.d" / "backends" / name - if provider_dir.exists(): - shutil.rmtree(provider_dir) - remove_provider_key(name) - removed += 1 - - if not args.dry_run and removed > 0: - reassemble_config() - - # Set AGENTGATEWAY_ENABLED=false in .env - env_path = Path(__file__).resolve().parent.parent.parent / ".env" - _set_env_var(env_path, "AGENTGATEWAY_ENABLED", "false") - - # Null out backend_route on all component_configs if --populate-routes was used - routes_cleared = 0 - if args.populate_routes and not args.dry_run: - db = SessionLocal() - try: - configs = ( - db.query(BaseComponentConfig) - .filter(BaseComponentConfig.backend_route.isnot(None)) - .all() - ) - for cfg in configs: - cfg.backend_route = None - routes_cleared += 1 - if routes_cleared: - db.commit() - finally: - db.close() - - result = {"rolled_back": removed, "routes_cleared": routes_cleared} - print(json.dumps(result)) - print(f"{removed} providers removed", file=sys.stderr) - if routes_cleared: - print(f"{routes_cleared} backend_route values cleared", file=sys.stderr) - if not args.dry_run: - print("Set AGENTGATEWAY_ENABLED=false in .env", file=sys.stderr) - print("Please restart Pipelit to apply changes.", file=sys.stderr) - - -def _set_env_var(env_path: Path, key: str, value: str) -> None: - """Set or update an environment variable in a .env file.""" - from pathlib import Path - - if env_path.exists(): - lines = env_path.read_text().splitlines() - found = False - for i, line in enumerate(lines): - if line.startswith(f"{key}=") or line.startswith(f"{key} ="): - lines[i] = f"{key}={value}" - found = True - break - if not found: - lines.append(f"{key}={value}") - env_path.write_text("\n".join(lines) + "\n") - else: - env_path.write_text(f"{key}={value}\n") + print(json.dumps({"error": "deprecated", "message": message})) + print(f"ERROR: {message}", file=sys.stderr) + sys.exit(2) def cmd_import_fixture(args: argparse.Namespace) -> None: @@ -783,7 +480,9 @@ def main() -> None: sp_fixture.add_argument( "--api-key", default=os.environ.get("PIPELIT_LLM_API_KEY"), - help="LLM provider API key (or set PIPELIT_LLM_API_KEY env var)", + help="DEPRECATED, ignored — LLM keys are managed by agentgateway, " + "pipelit no longer stores them (kept so existing install scripts " + "don't break)", ) sp_fixture.add_argument("--base-url", default=None, help="LLM provider base URL") @@ -792,19 +491,19 @@ def main() -> None: sp_migrate = sub.add_parser( "migrate-credentials", - help="Migrate LLM credentials from DB to agentgateway filesystem", + help="DEPRECATED — inoperable; LLM keys are managed directly in agentgateway", ) - sp_migrate.add_argument("--rollback", action="store_true", help="Reverse the migration") - sp_migrate.add_argument("--force", action="store_true", help="Overwrite existing provider dirs and key files") - sp_migrate.add_argument("--dry-run", action="store_true", help="Print what would be done without writing files") - sp_migrate.add_argument("--populate-routes", action="store_true", help="Set backend_route on existing component_configs") + # Legacy flags kept so old invocations fail with the deprecation message + # instead of an argparse error. + sp_migrate.add_argument("--rollback", action="store_true", help=argparse.SUPPRESS) + sp_migrate.add_argument("--force", action="store_true", help=argparse.SUPPRESS) + sp_migrate.add_argument("--dry-run", action="store_true", help=argparse.SUPPRESS) + sp_migrate.add_argument("--populate-routes", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args() if args.command == "setup" and not args.password: parser.error("--password is required (or set PIPELIT_SETUP_PASSWORD env var)") - if args.command == "apply-fixture" and not args.api_key: - parser.error("--api-key is required (or set PIPELIT_LLM_API_KEY env var)") try: if args.command == "setup": diff --git a/platform/components/_agent_shared.py b/platform/components/_agent_shared.py index ec79cd83..79b87f31 100644 --- a/platform/components/_agent_shared.py +++ b/platform/components/_agent_shared.py @@ -166,9 +166,13 @@ def _wrap_llm_with_native_tools(llm, native_tools: list[dict]): def _resolve_credential_field(cred, field: str) -> str | None: - """Extract a secret value from a credential's child record.""" - if field == "api_key" and cred.llm_credential: - return cred.llm_credential.api_key or None + """Extract a secret value from a credential's child record. + + Note: LLM ``api_key`` is intentionally NOT resolvable — raw LLM keys + live in agentgateway (see services/agentgateway_config.py), never in + pipelit. Requests for it resolve to ``None`` and callers skip the + env var. + """ if field == "base_url" and cred.llm_credential: return cred.llm_credential.base_url or None if field == "organization_id" and cred.llm_credential: diff --git a/platform/config.py b/platform/config.py index 4ed0b092..b2130371 100644 --- a/platform/config.py +++ b/platform/config.py @@ -137,7 +137,7 @@ class Settings(BaseSettings): # -- agentgateway integration -- JWT_PRIVATE_KEY: str = "" # PEM-encoded EC private key, generated by `plit init` AGENTGATEWAY_URL: str = "" # e.g. http://localhost:4000 - AGENTGATEWAY_ENABLED: bool = False # feature flag for gradual rollout + AGENTGATEWAY_ENABLED: bool = True # feature flag for gradual rollout (default ON; hard cutover) AGENTGATEWAY_DIR: str = "" # path to agentgateway installation directory model_config = ConfigDict( diff --git a/platform/conftest.py b/platform/conftest.py index 79c9271e..2757dcb8 100644 --- a/platform/conftest.py +++ b/platform/conftest.py @@ -19,6 +19,13 @@ from cryptography.fernet import Fernet os.environ["FIELD_ENCRYPTION_KEY"] = Fernet.generate_key().decode() +# Tests don't have a live agentgateway; default it disabled so the hard +# startup dependency (main.py lifespan) doesn't fail app boot in the test +# suite. Individual tests that exercise the agentgateway-enabled path patch +# `config.settings.AGENTGATEWAY_ENABLED` explicitly at runtime. +if not os.environ.get("AGENTGATEWAY_ENABLED"): + os.environ["AGENTGATEWAY_ENABLED"] = "false" + import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker diff --git a/platform/main.py b/platform/main.py index cb745e70..bd771980 100644 --- a/platform/main.py +++ b/platform/main.py @@ -49,6 +49,21 @@ async def lifespan(app: FastAPI): "Set a secure SECRET_KEY in .env or environment before running in production (DEBUG=False)." ) + # Hard boot dependency: agentgateway must be reachable when enabled. + # This is a hard cutover (Phase 1b) — do NOT silently continue if the + # gateway is required but unreachable, as that would hide the dependency + # behind the still-live direct-provider fallback. + if settings.AGENTGATEWAY_ENABLED: + from services.agentgateway_client import check_agentgateway_health + + ok, message = await check_agentgateway_health(settings.AGENTGATEWAY_URL) + if not ok: + raise RuntimeError( + f"FATAL: AGENTGATEWAY_ENABLED is True but agentgateway is not reachable: " + f"{message} (AGENTGATEWAY_URL={settings.AGENTGATEWAY_URL!r})" + ) + logger.info("agentgateway health check passed: %s", message) + # Startup: create tables if they don't exist (dev convenience; use alembic in prod) Base.metadata.create_all(bind=engine) diff --git a/platform/models/credential.py b/platform/models/credential.py index 9a41dd46..0af2cbf2 100644 --- a/platform/models/credential.py +++ b/platform/models/credential.py @@ -68,6 +68,14 @@ class GitCredential(Base): class LLMProviderCredential(Base): + """LLM provider metadata — holds NO secret material. + + The encrypted ``api_key`` column was dropped (hard cutover): raw LLM + provider keys live exclusively in agentgateway's encrypted key store. + ``provider_type``/``base_url`` are kept for routing / provider + inference on legacy ``llm_credential_id`` references. + """ + __tablename__ = "llm_credentials" id: Mapped[int] = mapped_column(primary_key=True) @@ -75,7 +83,6 @@ class LLMProviderCredential(Base): ForeignKey("credentials.id", ondelete="CASCADE"), unique=True ) provider_type: Mapped[str] = mapped_column(String(30), default="openai_compatible") - api_key: Mapped[str] = mapped_column(EncryptedString(500)) base_url: Mapped[str] = mapped_column(String(500), default="") organization_id: Mapped[str] = mapped_column(String(255), default="") custom_headers: Mapped[dict] = mapped_column(JSON, default=dict) diff --git a/platform/services/dsl_compiler.py b/platform/services/dsl_compiler.py index b79e4ee5..e94a13a6 100644 --- a/platform/services/dsl_compiler.py +++ b/platform/services/dsl_compiler.py @@ -356,8 +356,10 @@ def _discover_model( temperature: float | None, db: Session, ) -> tuple[int, str, float | None]: - """Auto-discover the best model from available LLM credentials. + """Auto-discover the best model available through agentgateway. + Iterates LLM credentials for provider matching, but the model list + itself comes from the agentgateway config (no raw API key is read). Preference can be "cheapest", "fastest", or "most_capable". """ creds = ( @@ -388,30 +390,38 @@ def _discover_model( return (best_cred_id, best_model, temperature) +# Maps agentgateway provider dir names to credential provider_type values. +# Any provider not listed here is an openai-compatible custom backend. +_GATEWAY_PROVIDER_TYPE_MAP = {"openai": "openai", "anthropic": "anthropic", "glm": "glm"} + + def _fetch_model_ids(cred: LLMProviderCredential) -> list[str]: - """Fetch available model IDs from a credential. Best-effort, silent on failure.""" - if cred.provider_type == "anthropic": - return [ - "claude-sonnet-4-20250514", - "claude-opus-4-0-20250514", - "claude-haiku-3-5-20241022", - "claude-3-5-sonnet-20241022", - ] - # For OpenAI-compatible providers, try the /models endpoint + """List model IDs reachable through agentgateway for this credential's provider. + + Sources models from the agentgateway config tree + (``list_all_available_models()``) instead of calling the upstream + ``/models`` endpoint with a raw API key — pipelit no longer holds raw + LLM keys (they live in agentgateway). Best-effort, silent on failure. + """ try: - import httpx - base_url = (cred.base_url or "https://api.openai.com/v1").rstrip("/") - resp = httpx.get( - f"{base_url}/models", - headers={"Authorization": f"Bearer {cred.api_key}"}, - timeout=10, - ) - resp.raise_for_status() - data = resp.json().get("data", []) - return [m["id"] for m in data if "id" in m] + from services.agentgateway_config import list_all_available_models + + gateway_models = list_all_available_models() except Exception: return [] + model_ids: list[str] = [] + for m in gateway_models: + provider_type = _GATEWAY_PROVIDER_TYPE_MAP.get(m.get("provider"), "openai_compatible") + if provider_type != cred.provider_type: + continue + # model_name is the real upstream model id when the gateway config + # sets an override; pass-through models fall back to the route slug. + model_id = m.get("model_name") or m.get("model_slug") + if model_id: + model_ids.append(model_id) + return model_ids + def _score_model(model_id: str, preference: str) -> float: """Score a model ID based on preference using substring matching.""" diff --git a/platform/services/llm.py b/platform/services/llm.py index 2e1c769f..da12691f 100644 --- a/platform/services/llm.py +++ b/platform/services/llm.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass from langchain_core.language_models import BaseChatModel from sqlalchemy.orm import Session @@ -84,22 +85,28 @@ def _route_provider_to_type(provider: str) -> str: return _map.get(provider, "openai_compatible") -def _fake_credential_from_route(backend_route: str): - """Return a minimal credential-like object inferred from a backend route string. +@dataclass(frozen=True) +class RouteProviderInfo: + """Provider info inferred from a backend route string. - Used by ``resolve_credential_for_node()`` when only ``backend_route`` is - configured (no DB credential). Callers that inspect ``.provider_type`` - (e.g. web-search provider detection) will get a sensible value. + Carries the provider type and a (non-secret) ``base_url`` — deliberately + no ``api_key`` (raw LLM keys live in agentgateway, never in pipelit). + Returned by ``resolve_credential_for_node()`` when a node is configured + with a ``backend_route`` instead of a DB credential. Callers that inspect + ``.provider_type`` / ``.base_url`` (e.g. web-search provider detection via + ``is_anthropic_native``) get sensible values. ``base_url`` defaults to "" + (empty == provider's native endpoint), matching the legacy route-credential + contract so anthropic-native web search still resolves. """ - from collections import namedtuple - FakeCredential = namedtuple("FakeCredential", ["provider_type", "base_url", "api_key"]) + provider_type: str + base_url: str = "" + + +def _provider_info_from_route(backend_route: str) -> RouteProviderInfo: + """Infer a :class:`RouteProviderInfo` from a backend route string.""" prefix = backend_route.split("-", 1)[0] if "-" in backend_route else backend_route - return FakeCredential( - provider_type=_route_provider_to_type(prefix), - base_url="", - api_key="", - ) + return RouteProviderInfo(provider_type=_route_provider_to_type(prefix)) def _resolve_backend_name(credential=None, backend_route: str | None = None) -> str: @@ -143,77 +150,35 @@ def create_llm_from_db( ) -> BaseChatModel: from config import settings - # -- agentgateway proxy path -- - if settings.AGENTGATEWAY_ENABLED and settings.AGENTGATEWAY_URL: - resolved_route = _resolve_backend_name(credential, backend_route) - return _create_llm_via_agentgateway( - credential, - model_name, - backend_route=resolved_route, - user_profile_id=user_profile_id, - user_role=user_role, - temperature=temperature, - max_tokens=max_tokens, - frequency_penalty=frequency_penalty, - presence_penalty=presence_penalty, - top_p=top_p, - timeout=timeout, - max_retries=max_retries, - response_format=response_format, + # agentgateway is the ONLY way pipelit reaches an LLM. The direct + # provider path (raw api_key clients) was removed — raw LLM keys live + # in agentgateway, never in pipelit. + if not (settings.AGENTGATEWAY_ENABLED and settings.AGENTGATEWAY_URL): + # Defensive: startup enforces AGENTGATEWAY_ENABLED + gateway health, + # so reaching this means misconfiguration — fail loudly rather than + # silently building a direct provider client. + raise RuntimeError( + "LLM resolution requires agentgateway: the direct provider path " + "has been removed. Set AGENTGATEWAY_ENABLED=true and " + "AGENTGATEWAY_URL (e.g. http://localhost:4000)." ) - # -- direct provider path (unchanged) -- - provider_type = credential.provider_type - api_key = credential.api_key - - kwargs: dict = {"model": model_name} - if temperature is not None: - kwargs["temperature"] = temperature - if max_tokens is not None: - kwargs["max_tokens"] = max_tokens - if top_p is not None: - kwargs["top_p"] = top_p - if timeout is not None: - kwargs["timeout"] = timeout - if max_retries is not None: - kwargs["max_retries"] = max_retries - - if provider_type == "openai": - SanitizedChatOpenAI = _make_sanitized_chat_openai() - if frequency_penalty is not None: - kwargs["frequency_penalty"] = frequency_penalty - if presence_penalty is not None: - kwargs["presence_penalty"] = presence_penalty - if response_format is not None: - kwargs["model_kwargs"] = {"response_format": response_format} - return SanitizedChatOpenAI(api_key=api_key, **kwargs) - - if provider_type == "anthropic": - from langchain_anthropic import ChatAnthropic - if credential.base_url: - kwargs["base_url"] = credential.base_url - return ChatAnthropic(api_key=api_key, **kwargs) - - if provider_type == "glm": - SanitizedChatOpenAI = _make_sanitized_chat_openai() - base = credential.base_url or "https://api.z.ai/api/paas/v4/" - kwargs["base_url"] = base - kwargs["use_responses_api"] = False - return SanitizedChatOpenAI(api_key=api_key, **kwargs) - - if provider_type == "openai_compatible": - SanitizedChatOpenAI = _make_sanitized_chat_openai() - if frequency_penalty is not None: - kwargs["frequency_penalty"] = frequency_penalty - if presence_penalty is not None: - kwargs["presence_penalty"] = presence_penalty - if response_format is not None: - kwargs["model_kwargs"] = {"response_format": response_format} - if credential.base_url: - kwargs["base_url"] = credential.base_url - return SanitizedChatOpenAI(api_key=api_key, **kwargs) - - raise ValueError(f"Unsupported provider type: {provider_type}") + resolved_route = _resolve_backend_name(credential, backend_route) + return _create_llm_via_agentgateway( + credential, + model_name, + backend_route=resolved_route, + user_profile_id=user_profile_id, + user_role=user_role, + temperature=temperature, + max_tokens=max_tokens, + frequency_penalty=frequency_penalty, + presence_penalty=presence_penalty, + top_p=top_p, + timeout=timeout, + max_retries=max_retries, + response_format=response_format, + ) def _create_llm_via_agentgateway( @@ -463,10 +428,12 @@ def resolve_llm_for_node(node, db: Session | None = None) -> BaseChatModel: def resolve_credential_for_node(node, db: Session | None = None): - """Resolve the LLMProviderCredential for a node (agent or ai_model). + """Resolve provider info for a node (agent or ai_model). - Same traversal as resolve_llm_for_node but returns the credential - instead of the LLM instance. Used for provider detection (e.g. web search). + Same traversal as resolve_llm_for_node but returns the DB credential + (legacy) or a :class:`RouteProviderInfo` (backend_route nodes) instead + of the LLM instance. Used for provider detection (e.g. web search) — + callers should only rely on ``.provider_type``. """ from database import SessionLocal from models.credential import BaseCredential @@ -482,7 +449,7 @@ def resolve_credential_for_node(node, db: Session | None = None): if cc.component_type == "ai_model": # backend_route path: infer provider from route string if backend_route and not cc.llm_credential_id: - return _fake_credential_from_route(backend_route) + return _provider_info_from_route(backend_route) if not cc.llm_credential_id: raise ValueError(f"Node '{node.node_id}' (ai_model) has no credential.") @@ -497,7 +464,7 @@ def resolve_credential_for_node(node, db: Session | None = None): if tc and tc.component_type == "ai_model": tc_backend_route = getattr(tc, "backend_route", None) if tc_backend_route and not tc.llm_credential_id: - return _fake_credential_from_route(tc_backend_route) + return _provider_info_from_route(tc_backend_route) if tc.llm_credential_id: base_cred = db.query(BaseCredential).filter(BaseCredential.id == tc.llm_credential_id).first() if not base_cred or not base_cred.llm_credential: diff --git a/platform/tests/test_api.py b/platform/tests/test_api.py index a70e3b42..85db81c9 100644 --- a/platform/tests/test_api.py +++ b/platform/tests/test_api.py @@ -599,53 +599,28 @@ def llm_credential(self, db, user_profile): llm = LLMProviderCredential( base_credentials_id=base.id, provider_type="anthropic", - api_key="sk-ant-test", ) db.add(llm) db.commit() db.refresh(base) return base - def test_anthropic_models_live_api_success(self, auth_client, llm_credential): + def test_anthropic_models_endpoint_rejected(self, auth_client, llm_credential): + """Credential-derived model listing is removed — pipelit holds no + provider keys; models come from agentgateway (available_models API). + The Anthropic SDK must never be constructed.""" from unittest.mock import patch, MagicMock - mock_model1 = MagicMock() - mock_model1.id = "claude-opus-4-0-20250514" - mock_model2 = MagicMock() - mock_model2.id = "claude-sonnet-4-20250514" - - mock_page = MagicMock() - mock_page.data = [mock_model2, mock_model1] # unsorted - - mock_client = MagicMock() - mock_client.models.list.return_value = mock_page - - mock_anthropic_cls = MagicMock(return_value=mock_client) - - # Anthropic is imported locally inside the endpoint, so patch at the module level - with patch.dict("sys.modules", {"anthropic": MagicMock(Anthropic=mock_anthropic_cls)}): - resp = auth_client.get(f"/api/v1/credentials/{llm_credential.id}/models/") - - assert resp.status_code == 200 - models = resp.json() - # Should be sorted by id - assert models[0]["id"] == "claude-opus-4-0-20250514" - assert models[1]["id"] == "claude-sonnet-4-20250514" - - def test_anthropic_models_api_failure_falls_back(self, auth_client, llm_credential): - from unittest.mock import patch, MagicMock - - mock_anthropic_cls = MagicMock(side_effect=RuntimeError("network error")) + mock_anthropic_cls = MagicMock( + side_effect=AssertionError("Anthropic client must not be built") + ) with patch.dict("sys.modules", {"anthropic": MagicMock(Anthropic=mock_anthropic_cls)}): resp = auth_client.get(f"/api/v1/credentials/{llm_credential.id}/models/") - assert resp.status_code == 200 - models = resp.json() - # Should return fallback ANTHROPIC_MODELS list - assert len(models) >= 1 - model_ids = [m["id"] for m in models] - assert "claude-sonnet-4-20250514" in model_ids + assert resp.status_code == 410 + assert "agentgateway" in resp.json()["detail"] + mock_anthropic_cls.assert_not_called() # ── Checkpoint metadata parsing ────────────────────────────────────────────── diff --git a/platform/tests/test_api_extended.py b/platform/tests/test_api_extended.py index 8669fa09..1f955921 100644 --- a/platform/tests/test_api_extended.py +++ b/platform/tests/test_api_extended.py @@ -66,7 +66,8 @@ def test_list_credentials_empty(self, auth_client): assert data["items"] == [] assert data["total"] == 0 - def test_create_llm_credential(self, auth_client): + def test_create_llm_credential_rejected(self, auth_client): + """LLM credentials are managed by agentgateway — creation is 410 Gone.""" resp = auth_client.post("/api/v1/credentials/", json={ "name": "My OpenAI", "credential_type": "llm", @@ -76,10 +77,8 @@ def test_create_llm_credential(self, auth_client): "base_url": "", }, }) - assert resp.status_code == 201 - data = resp.json() - assert data["name"] == "My OpenAI" - assert data["credential_type"] == "llm" + assert resp.status_code == 410 + assert "agentgateway" in resp.json()["detail"] def test_create_gateway_credential(self, auth_client): mock_client = MagicMock() @@ -107,13 +106,13 @@ def test_create_tool_credential(self, auth_client): assert resp.status_code == 201 def test_list_credentials(self, auth_client, db, user_profile): + """Leftover llm rows still list, but expose no detail (no secrets).""" cred = BaseCredential(user_profile_id=user_profile.id, name="Test", credential_type="llm") db.add(cred) db.flush() llm = LLMProviderCredential( base_credentials_id=cred.id, provider_type="openai", - api_key="sk-1234567890abcdef", ) db.add(llm) db.commit() @@ -123,8 +122,9 @@ def test_list_credentials(self, auth_client, db, user_profile): data = resp.json() assert data["total"] >= 1 item = data["items"][0] - assert item["detail"]["provider_type"] == "openai" - assert "****" in item["detail"]["api_key"] + assert item["credential_type"] == "llm" + # LLM detail is no longer serialized — no provider secrets in pipelit + assert item["detail"] == {} def test_get_credential(self, auth_client, db, user_profile): cred = BaseCredential(user_profile_id=user_profile.id, name="Test", credential_type="llm") @@ -133,7 +133,6 @@ def test_get_credential(self, auth_client, db, user_profile): llm = LLMProviderCredential( base_credentials_id=cred.id, provider_type="openai", - api_key="sk-1234567890abcdef", ) db.add(llm) db.commit() @@ -147,13 +146,34 @@ def test_get_credential_not_found(self, auth_client): assert resp.status_code == 404 def test_update_credential(self, auth_client, db, user_profile): + """Renaming still works for non-LLM credentials (tool shown here).""" + from models.credential import ToolCredential + + cred = BaseCredential(user_profile_id=user_profile.id, name="Old Name", credential_type="tool") + db.add(cred) + db.flush() + tool = ToolCredential( + base_credentials_id=cred.id, + tool_type="searxng", + config={"url": "http://searx.local"}, + ) + db.add(tool) + db.commit() + + resp = auth_client.patch(f"/api/v1/credentials/{cred.id}/", json={ + "name": "New Name", + }) + assert resp.status_code == 200 + assert resp.json()["name"] == "New Name" + + def test_update_llm_credential_rejected(self, auth_client, db, user_profile): + """Any PATCH against an llm-typed credential is 410 Gone.""" cred = BaseCredential(user_profile_id=user_profile.id, name="Old Name", credential_type="llm") db.add(cred) db.flush() llm = LLMProviderCredential( base_credentials_id=cred.id, provider_type="openai", - api_key="sk-old", ) db.add(llm) db.commit() @@ -161,8 +181,7 @@ def test_update_credential(self, auth_client, db, user_profile): resp = auth_client.patch(f"/api/v1/credentials/{cred.id}/", json={ "name": "New Name", }) - assert resp.status_code == 200 - assert resp.json()["name"] == "New Name" + assert resp.status_code == 410 def test_delete_credential(self, auth_client, db, user_profile): cred = BaseCredential(user_profile_id=user_profile.id, name="To Delete", credential_type="llm") @@ -189,14 +208,13 @@ def test_mask_function(self): assert _mask("short") == "****" assert _mask("abcdefghijklmnop") == "abcd****mnop" - def _make_llm_cred(self, db, user_profile, provider_type="openai", base_url="", api_key="sk-test"): + def _make_llm_cred(self, db, user_profile, provider_type="openai", base_url=""): cred = BaseCredential(user_profile_id=user_profile.id, name="Test LLM", credential_type="llm") db.add(cred) db.flush() llm = LLMProviderCredential( base_credentials_id=cred.id, provider_type=provider_type, - api_key=api_key, base_url=base_url, ) db.add(llm) @@ -204,100 +222,27 @@ def _make_llm_cred(self, db, user_profile, provider_type="openai", base_url="", db.refresh(cred) return cred - def test_list_models_openai_dict_response(self, auth_client, db, user_profile): - """OpenAI-compatible /models returns dict with 'data' key — lines 345-346.""" + def test_list_models_rejected(self, auth_client, db, user_profile): + """Credential-derived model listing is removed -- models come from + agentgateway (api/available_models.py). The endpoint is 410 Gone and + must not make any upstream provider call.""" cred = self._make_llm_cred(db, user_profile, provider_type="openai") - mock_resp = MagicMock() - mock_resp.raise_for_status.return_value = None - mock_resp.json.return_value = { - "data": [{"id": "gpt-4"}, {"id": "gpt-3.5-turbo"}] - } - - with patch("api.credentials.httpx.get", return_value=mock_resp): + with patch("httpx.get", side_effect=AssertionError("no upstream call")): resp = auth_client.get(f"/api/v1/credentials/{cred.id}/models/") - assert resp.status_code == 200 - data = resp.json() - ids = [m["id"] for m in data] - assert "gpt-4" in ids - assert "gpt-3.5-turbo" in ids - - def test_list_models_openai_list_response(self, auth_client, db, user_profile): - """OpenAI-compatible /models returns a list directly — lines 347-348.""" - cred = self._make_llm_cred(db, user_profile, provider_type="openai", - base_url="https://custom.openai.com/v1") - - mock_resp = MagicMock() - mock_resp.raise_for_status.return_value = None - mock_resp.json.return_value = [{"id": "custom-model-1"}, {"id": "custom-model-2"}] + assert resp.status_code == 410 + assert "agentgateway" in resp.json()["detail"] - with patch("api.credentials.httpx.get", return_value=mock_resp): - resp = auth_client.get(f"/api/v1/credentials/{cred.id}/models/") - - assert resp.status_code == 200 - data = resp.json() - ids = [m["id"] for m in data] - assert "custom-model-1" in ids - - def test_list_models_unexpected_format_returns_empty(self, auth_client, db, user_profile): - """Unexpected /models response format (not dict, not list) — lines 349-351.""" + def test_test_llm_credential_rejected(self, auth_client, db, user_profile): + """Direct-provider credential testing is removed -- 410 Gone.""" cred = self._make_llm_cred(db, user_profile, provider_type="openai") - mock_resp = MagicMock() - mock_resp.raise_for_status.return_value = None - mock_resp.json.return_value = "unexpected string response" - - with patch("api.credentials.httpx.get", return_value=mock_resp): - resp = auth_client.get(f"/api/v1/credentials/{cred.id}/models/") - - assert resp.status_code == 200 - assert resp.json() == [] - - def test_list_models_openai_default_url_on_exception(self, auth_client, db, user_profile): - """Non-MiniMax exception returns [] — line 357.""" - cred = self._make_llm_cred(db, user_profile, provider_type="openai", base_url="") - - with patch("api.credentials.httpx.get", side_effect=Exception("connection error")): - resp = auth_client.get(f"/api/v1/credentials/{cred.id}/models/") - - assert resp.status_code == 200 - assert resp.json() == [] - - def test_list_models_minimax_fallback_on_exception(self, auth_client, db, user_profile): - """MiniMax base_url exception returns MINIMAX_MODELS — lines 355-356.""" - from api.credentials import MINIMAX_MODELS - cred = self._make_llm_cred( - db, user_profile, provider_type="openai", - base_url="https://api.minimax.io/v1", - ) - - with patch("api.credentials.httpx.get", side_effect=Exception("timeout")): - resp = auth_client.get(f"/api/v1/credentials/{cred.id}/models/") - - assert resp.status_code == 200 - data = resp.json() - returned_ids = [m["id"] for m in data] - assert returned_ids == MINIMAX_MODELS - - def test_list_models_no_base_url_uses_openai_default(self, auth_client, db, user_profile): - """No base_url defaults to https://api.openai.com/v1 — line 336.""" - cred = self._make_llm_cred(db, user_profile, provider_type="openai", base_url="") - - captured_url = [] - - def mock_get(url, **kwargs): - captured_url.append(url) - resp = MagicMock() - resp.raise_for_status.return_value = None - resp.json.return_value = {"data": [{"id": "gpt-4"}]} - return resp - - with patch("api.credentials.httpx.get", side_effect=mock_get): - resp = auth_client.get(f"/api/v1/credentials/{cred.id}/models/") + with patch("httpx.post", side_effect=AssertionError("no upstream call")): + resp = auth_client.post(f"/api/v1/credentials/{cred.id}/test/") - assert resp.status_code == 200 - assert captured_url[0] == "https://api.openai.com/v1/models" + assert resp.status_code == 410 + assert "agentgateway" in resp.json()["detail"] # ── Executions ─────────────────────────────────────────────────────────────── @@ -646,7 +591,9 @@ def test_serialize_telegram(self, auth_client, db, user_profile): data = resp.json() assert data["detail"]["gateway_credential_id"] == "tg_mybot" - def test_update_credential_detail(self, auth_client, db, user_profile): + def test_update_credential_detail_llm_rejected(self, auth_client, db, user_profile): + """Patching an api_key into an llm credential is impossible — 410 Gone + (pipelit no longer stores LLM keys; agentgateway holds them).""" cred = BaseCredential( user_profile_id=user_profile.id, name="LLM Cred", credential_type="llm", @@ -656,7 +603,6 @@ def test_update_credential_detail(self, auth_client, db, user_profile): llm = LLMProviderCredential( base_credentials_id=cred.id, provider_type="openai", - api_key="sk-old1234567890", ) db.add(llm) db.commit() @@ -664,7 +610,7 @@ def test_update_credential_detail(self, auth_client, db, user_profile): resp = auth_client.patch(f"/api/v1/credentials/{cred.id}/", json={ "detail": {"api_key": "sk-new1234567890"}, }) - assert resp.status_code == 200 + assert resp.status_code == 410 def test_create_git_credential(self, auth_client): resp = auth_client.post("/api/v1/credentials/", json={ diff --git a/platform/tests/test_credentials_agentgateway.py b/platform/tests/test_credentials_agentgateway.py index 3d0f20a2..08d72f72 100644 --- a/platform/tests/test_credentials_agentgateway.py +++ b/platform/tests/test_credentials_agentgateway.py @@ -1,7 +1,8 @@ -"""Tests verifying agentgateway dual-write logic has been removed from credentials.py. +"""Tests verifying LLM credential management is fully removed from credentials.py. -After T6a, LLM credential CRUD is DB-only. These tests confirm: -- No agentgateway writes happen on create/delete +Phase 1(b) hard cutover: +- Creating/updating/testing llm-typed credentials returns 410 Gone +- Leftover llm rows (metadata only, no api_key) remain listable/deletable - The agentgateway_backend field exists in the schema but is always None - Removed functions are no longer importable """ @@ -72,7 +73,7 @@ def admin_headers(admin_key): @pytest.fixture def llm_credential(db, admin_user): - """Create an LLM credential directly in DB for update/delete tests.""" + """A leftover llm-typed credential row (metadata only — no api_key).""" base = BaseCredential( user_profile_id=admin_user.id, name="Test OpenAI", @@ -83,7 +84,6 @@ def llm_credential(db, admin_user): llm = LLMProviderCredential( base_credentials_id=base.id, provider_type="openai", - api_key="sk-test-key-1234567890", base_url="", organization_id="", custom_headers={}, @@ -94,14 +94,13 @@ def llm_credential(db, admin_user): return base -# -- No agentgateway writes on create ---------------------------------------- +# -- LLM credential creation is rejected -------------------------------------- -class TestCreateLLMCredentialNoAgentgatewayWrites: - """Verify that creating an LLM credential does NOT write to agentgateway.""" +class TestCreateLLMCredentialRejected: + """Creating an llm-typed credential is gone — 410, and nothing is stored.""" - def test_create_llm_credential_no_agentgateway_writes(self, client, admin_headers): - """Creating an LLM credential should succeed with DB-only, no agentgateway imports.""" + def test_create_llm_credential_rejected(self, client, admin_headers, db): resp = client.post( "/api/v1/credentials/", json={ @@ -114,17 +113,20 @@ def test_create_llm_credential_no_agentgateway_writes(self, client, admin_header }, headers=admin_headers, ) - assert resp.status_code == 201 - data = resp.json() - assert data["credential_type"] == "llm" - assert data["detail"]["provider_type"] == "anthropic" - # agentgateway_backend is always None now - assert data["agentgateway_backend"] is None + assert resp.status_code == 410 + assert "agentgateway" in resp.json()["detail"] + # No row was created + assert ( + db.query(BaseCredential) + .filter(BaseCredential.name == "Test Anthropic") + .count() + == 0 + ) - def test_create_llm_credential_with_agentgateway_enabled_still_no_writes( + def test_create_llm_credential_rejected_even_with_agentgateway_enabled( self, client, admin_headers ): - """Even with AGENTGATEWAY_ENABLED=True, no agentgateway writes should happen.""" + """The rejection does not depend on the AGENTGATEWAY_ENABLED flag.""" with patch("api.credentials.settings") as mock_settings: from config import settings as real_settings for attr in dir(real_settings): @@ -144,9 +146,22 @@ def test_create_llm_credential_with_agentgateway_enabled_still_no_writes( }, headers=admin_headers, ) - assert resp.status_code == 201 - data = resp.json() - assert data["agentgateway_backend"] is None + assert resp.status_code == 410 + + def test_update_llm_credential_rejected(self, client, admin_headers, llm_credential): + resp = client.patch( + f"/api/v1/credentials/{llm_credential.id}/", + json={"name": "Renamed"}, + headers=admin_headers, + ) + assert resp.status_code == 410 + + def test_test_llm_credential_rejected(self, client, admin_headers, llm_credential): + resp = client.post( + f"/api/v1/credentials/{llm_credential.id}/test/", + headers=admin_headers, + ) + assert resp.status_code == 410 # -- No agentgateway writes on delete ---------------------------------------- @@ -192,14 +207,17 @@ class TestAgentgatewayBackendFieldIsNone: def test_agentgateway_backend_field_is_none_on_create( self, client, admin_headers ): + # llm creation is rejected now — use a git credential to exercise + # serialization of a freshly created credential. resp = client.post( "/api/v1/credentials/", json={ "name": "Field Test", - "credential_type": "llm", + "credential_type": "git", "detail": { - "provider_type": "openai", - "api_key": "sk-field-test-12345678", + "provider": "github", + "credential_type": "token", + "access_token": "ghp_field_test_12345678", }, }, headers=admin_headers, diff --git a/platform/tests/test_credentials_glm.py b/platform/tests/test_credentials_glm.py index e8297125..c1c99b56 100644 --- a/platform/tests/test_credentials_glm.py +++ b/platform/tests/test_credentials_glm.py @@ -1,8 +1,11 @@ -"""Tests for credentials API — GLM provider coverage.""" +"""Tests for credentials API — GLM (LLM-typed) credentials are rejected. -from __future__ import annotations +Phase 1(b) hard cutover: pipelit no longer stores or tests LLM provider +keys — agentgateway is the sole holder. Direct-provider test/models calls +for GLM (and every other LLM provider) return 410 Gone. +""" -from unittest.mock import MagicMock, patch +from __future__ import annotations import pytest from fastapi.testclient import TestClient @@ -38,6 +41,7 @@ def auth_client(client, api_key): @pytest.fixture def glm_credential(db, user_profile): + """A leftover GLM llm-typed credential row (metadata only, no api_key).""" from models.credential import BaseCredential, LLMProviderCredential base = BaseCredential( @@ -50,7 +54,6 @@ def glm_credential(db, user_profile): llm = LLMProviderCredential( base_credentials_id=base.id, provider_type="glm", - api_key="test-glm-key", ) db.add(llm) db.commit() @@ -58,76 +61,39 @@ def glm_credential(db, user_profile): return base -@pytest.fixture -def glm_credential_custom_url(db, user_profile): - from models.credential import BaseCredential, LLMProviderCredential +class TestGLMCredentialRejected: + """LLM credential management (create/test/models) is gone — 410.""" - base = BaseCredential( - user_profile_id=user_profile.id, - name="GLM Custom URL", - credential_type="llm", - ) - db.add(base) - db.flush() - llm = LLMProviderCredential( - base_credentials_id=base.id, - provider_type="glm", - api_key="test-glm-key", - base_url="https://custom.glm.api/v4", - ) - db.add(llm) - db.commit() - db.refresh(base) - return base - - -class TestGLMCredential: - """Test GLM provider in credentials API.""" - - @patch("api.credentials.httpx.get") - def test_glm_test_credential_success(self, mock_get, auth_client, glm_credential): - """Test GLM credential test with valid API key returns success.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"data": [{"id": "glm-4"}]} - mock_get.return_value = mock_response + def test_glm_create_rejected(self, auth_client): + resp = auth_client.post( + "/api/v1/credentials/", + json={ + "name": "GLM Key", + "credential_type": "llm", + "detail": {"provider_type": "glm", "api_key": "test-glm-key"}, + }, + ) + assert resp.status_code == 410 + assert "agentgateway" in resp.json()["detail"] + def test_glm_test_credential_rejected(self, auth_client, glm_credential): resp = auth_client.post(f"/api/v1/credentials/{glm_credential.id}/test/") + assert resp.status_code == 410 + assert "agentgateway" in resp.json()["detail"] - assert resp.status_code == 200 - assert resp.json()["ok"] is True - - @patch("api.credentials.httpx.get") - def test_glm_test_credential_with_custom_url(self, mock_get, auth_client, glm_credential_custom_url): - """Test GLM credential test with custom base URL.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_get.return_value = mock_response - - resp = auth_client.post(f"/api/v1/credentials/{glm_credential_custom_url.id}/test/") - - assert resp.status_code == 200 - # Verify custom URL was used - call_url = mock_get.call_args[0][0] - assert "custom.glm.api" in call_url - - @patch("api.credentials.httpx.get") - def test_glm_list_models(self, mock_get, auth_client, glm_credential): - """Test listing GLM models.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": [ - {"id": "glm-4", "object": "model"}, - {"id": "glm-4-plus", "object": "model"}, - ] - } - mock_get.return_value = mock_response - + def test_glm_list_models_rejected(self, auth_client, glm_credential): resp = auth_client.get(f"/api/v1/credentials/{glm_credential.id}/models/") - - assert resp.status_code == 200 - models = resp.json() - ids = [m["id"] for m in models] - assert "glm-4" in ids - assert "glm-4-plus" in ids + assert resp.status_code == 410 + assert "agentgateway" in resp.json()["detail"] + + def test_glm_update_rejected(self, auth_client, glm_credential): + resp = auth_client.patch( + f"/api/v1/credentials/{glm_credential.id}/", + json={"detail": {"api_key": "new-key"}}, + ) + assert resp.status_code == 410 + + def test_glm_leftover_row_still_deletable(self, auth_client, glm_credential): + """Cleanup of legacy llm rows keeps working.""" + resp = auth_client.delete(f"/api/v1/credentials/{glm_credential.id}/") + assert resp.status_code == 204 diff --git a/platform/tests/test_deep_agent.py b/platform/tests/test_deep_agent.py index e75f121a..232c5df7 100644 --- a/platform/tests/test_deep_agent.py +++ b/platform/tests/test_deep_agent.py @@ -54,20 +54,20 @@ def _make_deep_agent_node(system_prompt="", extra_config=None): class TestResolveCredentialField: - def test_llm_api_key(self, db, user_profile): + def test_llm_api_key_not_resolvable(self, db, user_profile): + """LLM api_key is intentionally NOT resolvable — raw LLM keys live in + agentgateway, never in pipelit (the DB column was dropped).""" cred = BaseCredential( user_profile_id=user_profile.id, name="llm", credential_type="llm" ) db.add(cred) db.flush() - llm = LLMProviderCredential( - base_credentials_id=cred.id, api_key="sk-test-123" - ) + llm = LLMProviderCredential(base_credentials_id=cred.id) db.add(llm) db.commit() db.refresh(cred) - assert _resolve_credential_field(cred, "api_key") == "sk-test-123" + assert _resolve_credential_field(cred, "api_key") is None def test_llm_base_url(self, db, user_profile): cred = BaseCredential( @@ -77,7 +77,6 @@ def test_llm_base_url(self, db, user_profile): db.flush() llm = LLMProviderCredential( base_credentials_id=cred.id, - api_key="sk-x", base_url="https://api.example.com", ) db.add(llm) @@ -94,7 +93,6 @@ def test_llm_organization_id(self, db, user_profile): db.flush() llm = LLMProviderCredential( base_credentials_id=cred.id, - api_key="sk-x", organization_id="org-abc", ) db.add(llm) @@ -180,9 +178,7 @@ def test_unknown_field_returns_none(self, db, user_profile): ) db.add(cred) db.flush() - llm = LLMProviderCredential( - base_credentials_id=cred.id, api_key="sk-x" - ) + llm = LLMProviderCredential(base_credentials_id=cred.id) db.add(llm) db.commit() db.refresh(cred) @@ -234,7 +230,9 @@ def test_with_workspace_id(self, db, user_profile, tmp_path): assert backend._custom_env == {"FOO": "bar"} def test_with_workspace_credential_env(self, db, user_profile, tmp_path): - """workspace with credential-sourced env vars resolves the credential field.""" + """Credential-sourced env vars resolve non-secret LLM fields, but the + LLM api_key is intentionally NOT injectable any more — raw keys live + in agentgateway, so an api_key-sourced env var is silently skipped.""" ws_path = str(tmp_path / "workspace") cred = BaseCredential( user_profile_id=user_profile.id, name="llm-cred", credential_type="llm" @@ -242,7 +240,7 @@ def test_with_workspace_credential_env(self, db, user_profile, tmp_path): db.add(cred) db.flush() llm = LLMProviderCredential( - base_credentials_id=cred.id, api_key="sk-secret-key" + base_credentials_id=cred.id, base_url="https://api.example.com" ) db.add(llm) db.flush() @@ -258,6 +256,12 @@ def test_with_workspace_credential_env(self, db, user_profile, tmp_path): "credential_id": cred.id, "credential_field": "api_key", }, + { + "key": "OPENAI_BASE_URL", + "source": "credential", + "credential_id": cred.id, + "credential_field": "base_url", + }, {"key": "RAW_VAR", "value": "raw_value", "source": "raw"}, ], ) @@ -272,7 +276,10 @@ def test_with_workspace_credential_env(self, db, user_profile, tmp_path): from components._agent_shared import _build_backend backend = _build_backend({"workspace_id": ws.id}) - assert backend._custom_env["OPENAI_API_KEY"] == "sk-secret-key" + # api_key is no longer resolvable/injectable + assert "OPENAI_API_KEY" not in backend._custom_env + # Non-secret LLM fields still resolve + assert backend._custom_env["OPENAI_BASE_URL"] == "https://api.example.com" assert backend._custom_env["RAW_VAR"] == "raw_value" def test_without_workspace_id(self, tmp_path): diff --git a/platform/tests/test_dsl_compiler.py b/platform/tests/test_dsl_compiler.py index 29837621..04e425f5 100644 --- a/platform/tests/test_dsl_compiler.py +++ b/platform/tests/test_dsl_compiler.py @@ -1153,19 +1153,49 @@ def test_transform_maps_to_code(self): # ── Discover model tests ───────────────────────────────────────────────────── +# Model discovery now sources models from the agentgateway config +# (list_all_available_models) instead of a raw-API-key /models fetch. +_GATEWAY_ANTHROPIC_MODELS = [ + { + "route": "anthropic-claude-sonnet-4", + "provider": "anthropic", + "model_slug": "claude-sonnet-4", + "model_name": "claude-sonnet-4-20250514", + }, + { + "route": "anthropic-claude-opus-4", + "provider": "anthropic", + "model_slug": "claude-opus-4", + "model_name": "claude-opus-4-0-20250514", + }, + { + "route": "anthropic-claude-haiku-3-5", + "provider": "anthropic", + "model_slug": "claude-haiku-3-5", + "model_name": "claude-haiku-3-5-20241022", + }, +] + + +def _patch_gateway_models(models=_GATEWAY_ANTHROPIC_MODELS): + return patch( + "services.agentgateway_config.list_all_available_models", + return_value=models, + ) + class TestDiscoverModel: def test_cheapest_preference(self): mock_cred = SimpleNamespace( base_credentials_id=1, provider_type="anthropic", - api_key="test", base_url=None, ) mock_db = MagicMock() mock_db.query.return_value.join.return_value.all.return_value = [mock_cred] - cred_id, model_name, temp = _discover_model("cheapest", 0.5, mock_db) + with _patch_gateway_models(): + cred_id, model_name, temp = _discover_model("cheapest", 0.5, mock_db) assert cred_id == 1 assert model_name is not None assert temp == 0.5 @@ -1176,13 +1206,13 @@ def test_most_capable_preference(self): mock_cred = SimpleNamespace( base_credentials_id=1, provider_type="anthropic", - api_key="test", base_url=None, ) mock_db = MagicMock() mock_db.query.return_value.join.return_value.all.return_value = [mock_cred] - cred_id, model_name, temp = _discover_model("most_capable", None, mock_db) + with _patch_gateway_models(): + cred_id, model_name, temp = _discover_model("most_capable", None, mock_db) assert cred_id == 1 # Most capable anthropic model should be opus assert "opus" in model_name.lower() @@ -1191,17 +1221,54 @@ def test_fastest_preference(self): mock_cred = SimpleNamespace( base_credentials_id=1, provider_type="anthropic", - api_key="test", base_url=None, ) mock_db = MagicMock() mock_db.query.return_value.join.return_value.all.return_value = [mock_cred] - cred_id, model_name, temp = _discover_model("fastest", None, mock_db) + with _patch_gateway_models(): + cred_id, model_name, temp = _discover_model("fastest", None, mock_db) assert cred_id == 1 # Fastest anthropic model should be haiku assert "haiku" in model_name.lower() + def test_provider_mismatch_yields_no_models(self): + """A credential whose provider has no gateway models scores nothing.""" + mock_cred = SimpleNamespace( + base_credentials_id=1, + provider_type="openai", + base_url=None, + ) + mock_db = MagicMock() + mock_db.query.return_value.join.return_value.all.return_value = [mock_cred] + + with _patch_gateway_models(): # gateway only has anthropic models + with pytest.raises(ValueError, match="No scoreable models"): + _discover_model("cheapest", None, mock_db) + + def test_pass_through_model_falls_back_to_slug(self): + """Gateway pass-through entries (empty model_name) score by slug.""" + mock_cred = SimpleNamespace( + base_credentials_id=1, + provider_type="anthropic", + base_url=None, + ) + mock_db = MagicMock() + mock_db.query.return_value.join.return_value.all.return_value = [mock_cred] + + pass_through = [ + { + "route": "anthropic-claude-haiku-3-5", + "provider": "anthropic", + "model_slug": "claude-haiku-3-5", + "model_name": "", + }, + ] + with _patch_gateway_models(pass_through): + cred_id, model_name, temp = _discover_model("cheapest", None, mock_db) + assert cred_id == 1 + assert model_name == "claude-haiku-3-5" + def test_no_credentials_raises(self): mock_db = MagicMock() mock_db.query.return_value.join.return_value.all.return_value = [] @@ -1227,13 +1294,13 @@ def test_discover_via_resolve_model(self): mock_cred = SimpleNamespace( base_credentials_id=1, provider_type="anthropic", - api_key="test", base_url=None, ) mock_db = MagicMock() mock_db.query.return_value.join.return_value.all.return_value = [mock_cred] - result = _resolve_model({"discover": True, "preference": "cheapest"}, None, mock_db) + with _patch_gateway_models(): + result = _resolve_model({"discover": True, "preference": "cheapest"}, None, mock_db) assert result[0] == 1 assert result[1] is not None diff --git a/platform/tests/test_llm_agentgateway.py b/platform/tests/test_llm_agentgateway.py index 1ff8a656..1824de98 100644 --- a/platform/tests/test_llm_agentgateway.py +++ b/platform/tests/test_llm_agentgateway.py @@ -13,10 +13,9 @@ def _make_credential(provider_type: str, base_credentials_id: int = 42): - """Build a mock LLMProviderCredential.""" + """Build a mock LLMProviderCredential (no api_key — the column is gone).""" cred = MagicMock() cred.provider_type = provider_type - cred.api_key = "sk-test-key" cred.base_url = "" cred.base_credentials_id = base_credentials_id cred.base_credentials = None # No eager-loaded base credential @@ -286,49 +285,43 @@ def test_backend_route_anthropic_prefix_infers_provider(self): # --------------------------------------------------------------------------- -# create_llm_from_db — agentgateway disabled (direct provider path) +# create_llm_from_db — agentgateway disabled (defensive raise, no direct path) # --------------------------------------------------------------------------- class TestCreateLlmFromDbDirect: - """When AGENTGATEWAY_ENABLED=False, the direct provider path is used.""" + """When AGENTGATEWAY_ENABLED=False there is NO fallback — the direct + provider path was removed; resolution raises loudly instead.""" @patch("services.llm._make_sanitized_chat_openai") - def test_openai_direct_when_disabled(self, mock_make_sanitized): + def test_raises_when_disabled(self, mock_make_sanitized): from services.llm import create_llm_from_db - mock_cls = MagicMock() - mock_make_sanitized.return_value = mock_cls - cred = _make_credential("openai") with patch("config.settings") as mock_settings: mock_settings.AGENTGATEWAY_ENABLED = False mock_settings.AGENTGATEWAY_URL = "" - create_llm_from_db(cred, "gpt-4o", temperature=0.7) + with pytest.raises(RuntimeError, match="requires agentgateway"): + create_llm_from_db(cred, "gpt-4o", temperature=0.7) - mock_cls.assert_called_once() - call_args = mock_cls.call_args - assert call_args.kwargs["api_key"] == "sk-test-key" - assert call_args.kwargs["model"] == "gpt-4o" + # The removed direct provider client must never be constructed. + mock_make_sanitized.assert_not_called() @patch("services.llm._make_sanitized_chat_openai") - def test_new_params_ignored_when_disabled(self, mock_make_sanitized): - """user_profile_id and user_role are accepted but unused in direct path.""" + def test_raises_even_with_user_context(self, mock_make_sanitized): + """user_profile_id/user_role do not unlock any direct path.""" from services.llm import create_llm_from_db - mock_cls = MagicMock() - mock_make_sanitized.return_value = mock_cls - cred = _make_credential("openai") with patch("config.settings") as mock_settings: mock_settings.AGENTGATEWAY_ENABLED = False mock_settings.AGENTGATEWAY_URL = "" - # Should not raise - create_llm_from_db( - cred, "gpt-4o", user_profile_id=5, user_role="admin" - ) + with pytest.raises(RuntimeError, match="requires agentgateway"): + create_llm_from_db( + cred, "gpt-4o", user_profile_id=5, user_role="admin" + ) - mock_cls.assert_called_once() + mock_make_sanitized.assert_not_called() # --------------------------------------------------------------------------- diff --git a/platform/tests/test_migrate_credentials.py b/platform/tests/test_migrate_credentials.py index 2e6ef7bc..9cbb7408 100644 --- a/platform/tests/test_migrate_credentials.py +++ b/platform/tests/test_migrate_credentials.py @@ -1,20 +1,20 @@ -"""Tests for the migrate-credentials CLI command (provider/model structure).""" +"""Tests for the deprecated migrate-credentials CLI command. + +Phase 1(b) hard cutover: pipelit no longer stores LLM API keys (the +encrypted ``llm_credentials.api_key`` column was dropped), so the one-shot +DB→agentgateway migration is end-of-life. The command is kept only as a +stub that fails loudly with guidance — every invocation (including the old +``--rollback`` / ``--dry-run`` / ``--force`` / ``--populate-routes`` flags) +exits non-zero without touching the DB or the agentgateway directory. +""" from __future__ import annotations import json -import shutil import sys -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -import yaml -from cryptography.fernet import Fernet - -from conftest import TestSession - -_TEST_KEY = Fernet.generate_key().decode() def _run_cli(args: list[str]) -> tuple[int, str, str]: @@ -49,711 +49,69 @@ def mock_stderr_write(s): @pytest.fixture def agw_dir(tmp_path): - """Create a temporary agentgateway directory structure.""" + """A pristine agentgateway directory tree — must never be touched.""" (tmp_path / "config.d" / "backends").mkdir(parents=True) (tmp_path / "keys").mkdir() - # Create a dummy assemble-config.sh that succeeds - script = tmp_path / "assemble-config.sh" - script.write_text("#!/bin/bash\nexit 0\n") - script.chmod(0o755) return tmp_path -@pytest.fixture -def cli_db(): - """Patch SessionLocal so CLI commands use the test database.""" - session = TestSession() - try: - with patch("database.SessionLocal", return_value=session): - yield session - finally: - session.close() - - -@pytest.fixture -def _patch_agw_settings(agw_dir): - """Patch settings for agentgateway config writer.""" - with patch("services.agentgateway_config.settings") as mock_settings: - mock_settings.AGENTGATEWAY_DIR = str(agw_dir) - mock_settings.FIELD_ENCRYPTION_KEY = _TEST_KEY - yield mock_settings - - -@pytest.fixture -def _patch_config_settings(agw_dir): - """Patch config.settings for the CLI command that reads AGENTGATEWAY_DIR.""" - with patch("config.settings") as mock_settings: - mock_settings.AGENTGATEWAY_DIR = str(agw_dir) - mock_settings.FIELD_ENCRYPTION_KEY = _TEST_KEY - yield mock_settings - - -def _create_llm_credential(db, user_profile, *, name, provider_type, api_key, base_url=""): - """Helper to create an LLM credential in the test DB.""" - from models.credential import BaseCredential, LLMProviderCredential - - base = BaseCredential( - user_profile_id=user_profile.id, - name=name, - credential_type="llm", - ) - db.add(base) - db.flush() - - llm = LLMProviderCredential( - base_credentials_id=base.id, - provider_type=provider_type, - api_key=api_key, - base_url=base_url, - ) - db.add(llm) - db.commit() - return llm - - -# --------------------------------------------------------------------------- -# TestResolveProviderName -# --------------------------------------------------------------------------- - - -class TestResolveProviderName: - def test_openai(self): - from cli.__main__ import _resolve_provider_name - - cred = MagicMock(provider_type="openai") - assert _resolve_provider_name(cred) == "openai" - - def test_anthropic(self): - from cli.__main__ import _resolve_provider_name - - cred = MagicMock(provider_type="anthropic") - assert _resolve_provider_name(cred) == "anthropic" - - def test_glm(self): - from cli.__main__ import _resolve_provider_name - - cred = MagicMock(provider_type="glm") - assert _resolve_provider_name(cred) == "glm" - - def test_openai_compatible_spaces_to_underscores(self): - from cli.__main__ import _resolve_provider_name - - cred = MagicMock(provider_type="openai_compatible") - cred.base_credentials.name = "Venice AI" - assert _resolve_provider_name(cred) == "venice_ai" - - def test_openai_compatible_hyphens_sanitized(self): - from cli.__main__ import _resolve_provider_name - - cred = MagicMock(provider_type="openai_compatible") - cred.base_credentials.name = "my-custom-llm" - assert _resolve_provider_name(cred) == "my_custom_llm" - - def test_openai_compatible_special_chars_removed(self): - from cli.__main__ import _resolve_provider_name - - cred = MagicMock(provider_type="openai_compatible") - cred.base_credentials.name = "LLM Provider (v2)!" - assert _resolve_provider_name(cred) == "llm_provider_v2" - - def test_openai_compatible_fallback_to_custom_id(self): - from cli.__main__ import _resolve_provider_name - - cred = MagicMock(provider_type="openai_compatible") - cred.base_credentials.name = "" - cred.base_credentials_id = 42 - assert _resolve_provider_name(cred) == "custom42" - - -# --------------------------------------------------------------------------- -# TestModelToSlug -# --------------------------------------------------------------------------- - - -class TestModelToSlug: - def test_standard_model(self): - from cli.__main__ import _model_to_slug - - assert _model_to_slug("gpt-4o") == "gpt-4o" - - def test_model_with_slashes(self): - from cli.__main__ import _model_to_slug - - assert _model_to_slug("meta/llama-3.1-70b") == "meta-llama-3.1-70b" - - def test_model_with_colons(self): - from cli.__main__ import _model_to_slug - - assert _model_to_slug("ollama:llama3") == "ollama-llama3" - - def test_model_with_spaces(self): - from cli.__main__ import _model_to_slug - - assert _model_to_slug("Claude Sonnet 4") == "claude-sonnet-4" - - def test_model_special_chars(self): - from cli.__main__ import _model_to_slug - - assert _model_to_slug("model@v2#beta") == "modelv2beta" - - def test_empty_model(self): - from cli.__main__ import _model_to_slug - - assert _model_to_slug("") == "default" - - def test_dots_preserved(self): - from cli.__main__ import _model_to_slug - - assert _model_to_slug("glm-4.7") == "glm-4.7" - - -# --------------------------------------------------------------------------- -# TestParseBaseUrl -# --------------------------------------------------------------------------- - - -class TestParseBaseUrl: - def test_https_default_port(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("https://api.openai.com/v1") - assert host == "api.openai.com:443" - assert path == "/v1" - - def test_http_default_port(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("http://localhost/api") - assert host == "localhost:80" - assert path == "/api" - - def test_explicit_port(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("http://localhost:11434/v1") - assert host == "localhost:11434" - assert path == "/v1" - - def test_trailing_slash_stripped(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("https://api.venice.ai/api/v1/") - assert host == "api.venice.ai:443" - assert path == "/api/v1" - - def test_empty_url(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("") - assert host == "" - assert path == "" - - def test_none_like_url(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url(" ") - assert host == "" - assert path == "" - - def test_https_no_path(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("https://api.anthropic.com") - assert host == "api.anthropic.com:443" - assert path == "" - - def test_openai_appends_chat_completions(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("https://api.openai.com/v1", provider_type="openai") - assert path == "/v1/chat/completions" - - def test_openai_compatible_appends_chat_completions(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("https://api.venice.ai/api/v1", provider_type="openai_compatible") - assert path == "/api/v1/chat/completions" - - def test_anthropic_appends_messages(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("https://api.anthropic.com/v1", provider_type="anthropic") - assert path == "/v1/messages" - - def test_glm_appends_chat_completions(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("https://api.example.com/v1", provider_type="glm") - assert path == "/v1/chat/completions" - - def test_no_duplicate_suffix(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url( - "https://api.openai.com/v1/chat/completions", provider_type="openai" - ) - assert path == "/v1/chat/completions" - - def test_no_suffix_without_provider_type(self): - from cli.__main__ import _parse_base_url - - host, path = _parse_base_url("https://api.openai.com/v1") - assert path == "/v1" - - -# --------------------------------------------------------------------------- -# TestMigrateCredentials -# --------------------------------------------------------------------------- - - -class TestMigrateCredentials: - def test_migrate_single_openai( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - _create_llm_credential( - cli_db, user_profile, - name="OpenAI (default)", - provider_type="openai", - api_key="sk-openai-test-key", - ) - - code, out, err = _run_cli(["migrate-credentials"]) - - assert code == 0 - data = json.loads(out) - assert data["providers"] == 1 - assert data["models"] == 1 - - # Key file written and encrypted - key_path = agw_dir / "keys" / "openai.key" - assert key_path.exists() - fernet = Fernet(_TEST_KEY.encode()) - decrypted = fernet.decrypt(key_path.read_bytes()).decode() - assert decrypted == "sk-openai-test-key" - - # Provider dir created with _provider.yaml - provider_yaml = agw_dir / "config.d" / "backends" / "openai" / "_provider.yaml" - assert provider_yaml.exists() - parsed = yaml.safe_load(provider_yaml.read_text()) - assert parsed["backendAuth"]["key"] == "${OPENAI_API_KEY}" - assert "openAI" in parsed["provider"] - - # Model file created - model_yaml = agw_dir / "config.d" / "backends" / "openai" / "openai-default.yaml" - assert model_yaml.exists() - model_data = yaml.safe_load(model_yaml.read_text()) - assert model_data["model"] == "OpenAI (default)" - - def test_migrate_single_anthropic( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - _create_llm_credential( - cli_db, user_profile, - name="Anthropic", - provider_type="anthropic", - api_key="sk-ant-test", - ) - - code, out, err = _run_cli(["migrate-credentials"]) - - assert code == 0 - data = json.loads(out) - assert data["providers"] == 1 - - provider_yaml = agw_dir / "config.d" / "backends" / "anthropic" / "_provider.yaml" - assert provider_yaml.exists() - parsed = yaml.safe_load(provider_yaml.read_text()) - assert "anthropic" in parsed["provider"] - - def test_migrate_openai_compatible_with_base_url( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - _create_llm_credential( - cli_db, user_profile, - name="Venice AI", - provider_type="openai_compatible", - api_key="sk-venice-key", - base_url="https://api.venice.ai/api/v1", - ) - - code, out, err = _run_cli(["migrate-credentials"]) - - assert code == 0 - data = json.loads(out) - assert data["providers"] == 1 - - # Provider name sanitized: no hyphens - provider_yaml = agw_dir / "config.d" / "backends" / "venice_ai" / "_provider.yaml" - assert provider_yaml.exists() - parsed = yaml.safe_load(provider_yaml.read_text()) - assert parsed["hostOverride"] == "api.venice.ai:443" - assert parsed["pathOverride"] == "/api/v1/chat/completions" - - def test_multiple_credentials_same_provider_share_key( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - """Multiple credentials for the same provider should share one key and one _provider.yaml.""" - _create_llm_credential( - cli_db, user_profile, - name="OpenAI GPT-4o", - provider_type="openai", - api_key="sk-openai-key", - ) - _create_llm_credential( - cli_db, user_profile, - name="OpenAI o1", - provider_type="openai", - api_key="sk-openai-key", - ) - - code, out, err = _run_cli(["migrate-credentials"]) - - assert code == 0 - data = json.loads(out) - assert data["providers"] == 1 - assert data["models"] == 2 - - # One key file - assert (agw_dir / "keys" / "openai.key").exists() - - # Two model files - openai_dir = agw_dir / "config.d" / "backends" / "openai" - model_files = sorted(f.name for f in openai_dir.glob("*.yaml") if f.name != "_provider.yaml") - assert len(model_files) == 2 - - def test_different_providers_get_separate_dirs( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - _create_llm_credential( - cli_db, user_profile, - name="OpenAI", - provider_type="openai", - api_key="sk-openai", - ) - _create_llm_credential( - cli_db, user_profile, - name="Anthropic", - provider_type="anthropic", - api_key="sk-ant", - ) - - code, out, err = _run_cli(["migrate-credentials"]) - - assert code == 0 - data = json.loads(out) - assert data["providers"] == 2 - - assert (agw_dir / "config.d" / "backends" / "openai" / "_provider.yaml").exists() - assert (agw_dir / "config.d" / "backends" / "anthropic" / "_provider.yaml").exists() - assert (agw_dir / "keys" / "openai.key").exists() - assert (agw_dir / "keys" / "anthropic.key").exists() +def _tree_snapshot(root): + return sorted(str(p.relative_to(root)) for p in root.rglob("*")) - def test_skip_already_migrated_idempotent( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - _create_llm_credential( - cli_db, user_profile, - name="OpenAI", - provider_type="openai", - api_key="sk-openai-test", - ) - - # Pre-create provider dir to simulate previous migration - provider_dir = agw_dir / "config.d" / "backends" / "openai" - provider_dir.mkdir(parents=True, exist_ok=True) - (provider_dir / "_provider.yaml").write_text("existing") +class TestMigrateCredentialsDeprecated: + def test_plain_invocation_fails_with_deprecation(self): code, out, err = _run_cli(["migrate-credentials"]) - assert code == 0 - data = json.loads(out) - assert data["providers"] == 0 - assert data["skipped"] == 1 - - # Files unchanged - assert (provider_dir / "_provider.yaml").read_text() == "existing" - - def test_force_overwrites_existing( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - _create_llm_credential( - cli_db, user_profile, - name="OpenAI", - provider_type="openai", - api_key="sk-new-key", - ) - - # Pre-create files - provider_dir = agw_dir / "config.d" / "backends" / "openai" - provider_dir.mkdir(parents=True, exist_ok=True) - (provider_dir / "_provider.yaml").write_text("old-config") - (agw_dir / "keys" / "openai.key").write_text("old-data") - - code, out, err = _run_cli(["migrate-credentials", "--force"]) - - assert code == 0 + assert code != 0 data = json.loads(out) - assert data["providers"] == 1 - assert data["skipped"] == 0 - - # Key file overwritten with encrypted content - fernet = Fernet(_TEST_KEY.encode()) - decrypted = fernet.decrypt((agw_dir / "keys" / "openai.key").read_bytes()).decode() - assert decrypted == "sk-new-key" - - def test_dry_run_no_files_written( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - _create_llm_credential( - cli_db, user_profile, - name="OpenAI", - provider_type="openai", - api_key="sk-test", - ) - - code, out, err = _run_cli(["migrate-credentials", "--dry-run"]) + assert data["error"] == "deprecated" + assert "agentgateway" in data["message"] + assert "deprecated" in err.lower() + + @pytest.mark.parametrize( + "flags", + [ + ["--rollback"], + ["--dry-run"], + ["--force"], + ["--populate-routes"], + ["--rollback", "--dry-run"], + ], + ) + def test_legacy_flags_still_parse_but_fail_deprecated(self, flags): + """Old invocations get the deprecation message, not an argparse error.""" + code, out, err = _run_cli(["migrate-credentials"] + flags) - assert code == 0 + assert code != 0 data = json.loads(out) - assert data["providers"] == 1 - assert data["models"] == 1 - - # No files created - assert not (agw_dir / "keys" / "openai.key").exists() - assert not (agw_dir / "config.d" / "backends" / "openai").exists() + assert data["error"] == "deprecated" - # Dry-run output visible in stderr - assert "DRY-RUN" in err + def test_no_filesystem_writes(self, agw_dir): + """The stub must not touch the agentgateway directory.""" + before = _tree_snapshot(agw_dir) - def test_single_reassemble_at_end( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - """Multiple providers should trigger reassemble_config exactly once at the end.""" - _create_llm_credential( - cli_db, user_profile, - name="OpenAI", - provider_type="openai", - api_key="sk-openai", - ) - _create_llm_credential( - cli_db, user_profile, - name="Anthropic", - provider_type="anthropic", - api_key="sk-ant", - ) - _create_llm_credential( - cli_db, user_profile, - name="Venice AI", - provider_type="openai_compatible", - api_key="sk-venice", - base_url="https://api.venice.ai/api/v1", - ) - - # Track reassemble_config calls - reassemble_calls = [] - - import services.agentgateway_config as agw_mod - original_reassemble = agw_mod.reassemble_config - - def counting_reassemble(): - reassemble_calls.append(1) - return original_reassemble() - - with patch.object(agw_mod, "reassemble_config", counting_reassemble): + with patch("config.settings") as mock_settings: + mock_settings.AGENTGATEWAY_DIR = str(agw_dir) code, out, err = _run_cli(["migrate-credentials"]) - assert code == 0 - data = json.loads(out) - assert data["providers"] == 3 - - # reassemble_config called exactly once - assert len(reassemble_calls) == 1 - - def test_populate_routes_sets_backend_route( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - """--populate-routes should set backend_route on component_configs.""" - from models.credential import BaseCredential - from models.node import BaseComponentConfig - - cred = _create_llm_credential( - cli_db, user_profile, - name="OpenAI GPT-4o", - provider_type="openai", - api_key="sk-openai", - ) - - # Create a component config pointing to this credential - base_cred = cli_db.query(BaseCredential).filter(BaseCredential.id == cred.base_credentials_id).one() - cfg = BaseComponentConfig( - component_type="ai_model", - llm_credential_id=base_cred.id, - model_name="gpt-4o", - ) - cli_db.add(cfg) - cli_db.commit() - - code, out, err = _run_cli(["migrate-credentials", "--populate-routes"]) - - assert code == 0 - data = json.loads(out) - assert data["routes_updated"] == 1 - - # Re-query from the same session (CLI uses its own SessionLocal instance) - cli_db.expire_all() - updated_cfg = cli_db.query(BaseComponentConfig).filter(BaseComponentConfig.id == cfg.id).one() - assert updated_cfg.backend_route == "openai-openai-gpt-4o" - - def test_no_credentials_found( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - code, out, err = _run_cli(["migrate-credentials"]) - - assert code == 0 - data = json.loads(out) - assert data["providers"] == 0 - assert data["message"] == "No LLM credentials found" - - -# --------------------------------------------------------------------------- -# TestRollback -# --------------------------------------------------------------------------- - - -class TestRollback: - def test_rollback_deletes_dirs_keys_and_reassembles( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings, tmp_path - ): - # Create provider directories and keys to roll back - openai_dir = agw_dir / "config.d" / "backends" / "openai" - openai_dir.mkdir(parents=True) - (openai_dir / "_provider.yaml").write_text("provider: openAI") - (openai_dir / "gpt-4o.yaml").write_text("model: gpt-4o") - (agw_dir / "keys" / "openai.key").write_text("encrypted-key") - - anthropic_dir = agw_dir / "config.d" / "backends" / "anthropic" - anthropic_dir.mkdir(parents=True) - (anthropic_dir / "_provider.yaml").write_text("provider: anthropic") - (agw_dir / "keys" / "anthropic.key").write_text("encrypted-key") - - with patch("cli.__main__._set_env_var") as mock_set_env: - code, out, err = _run_cli(["migrate-credentials", "--rollback"]) - - assert code == 0 - data = json.loads(out) - assert data["rolled_back"] == 2 - - # Directories deleted - assert not openai_dir.exists() - assert not anthropic_dir.exists() - assert not (agw_dir / "keys" / "openai.key").exists() - assert not (agw_dir / "keys" / "anthropic.key").exists() - - # _set_env_var was called to disable agentgateway - mock_set_env.assert_called_once() - call_args = mock_set_env.call_args - assert call_args[0][1] == "AGENTGATEWAY_ENABLED" - assert call_args[0][2] == "false" - - def test_rollback_no_providers( - self, cli_db, agw_dir, _patch_agw_settings, _patch_config_settings - ): - code, out, err = _run_cli(["migrate-credentials", "--rollback"]) - - assert code == 0 - data = json.loads(out) - assert data["rolled_back"] == 0 - - def test_rollback_dry_run( - self, cli_db, agw_dir, _patch_agw_settings, _patch_config_settings - ): - openai_dir = agw_dir / "config.d" / "backends" / "openai" - openai_dir.mkdir(parents=True) - (openai_dir / "_provider.yaml").write_text("provider: openAI") - (agw_dir / "keys" / "openai.key").write_text("encrypted") - - code, out, err = _run_cli(["migrate-credentials", "--rollback", "--dry-run"]) - - assert code == 0 - data = json.loads(out) - assert data["rolled_back"] == 1 - - # Files NOT deleted (dry run) - assert openai_dir.exists() - assert "DRY-RUN" in err - - def test_rollback_clears_backend_routes( - self, cli_db, user_profile, agw_dir, _patch_agw_settings, _patch_config_settings - ): - """--populate-routes on rollback should null out backend_route values.""" - from models.node import BaseComponentConfig - - # Create a component config with a backend_route - cfg = BaseComponentConfig( - component_type="ai_model", - backend_route="openai-gpt-4o", - ) - cli_db.add(cfg) - cli_db.commit() - - # Create a provider dir so rollback has something to remove - openai_dir = agw_dir / "config.d" / "backends" / "openai" - openai_dir.mkdir(parents=True) - (openai_dir / "_provider.yaml").write_text("provider: openAI") - - with patch("cli.__main__._set_env_var"): - code, out, err = _run_cli(["migrate-credentials", "--rollback", "--populate-routes"]) - - assert code == 0 - data = json.loads(out) - assert data["rolled_back"] == 1 - assert data["routes_cleared"] == 1 - - # Re-query from the same session (CLI uses its own SessionLocal instance) - cli_db.expire_all() - updated_cfg = cli_db.query(BaseComponentConfig).filter(BaseComponentConfig.id == cfg.id).one() - assert updated_cfg.backend_route is None - - -# --------------------------------------------------------------------------- -# TestSetEnvVar -# --------------------------------------------------------------------------- - - -class TestSetEnvVar: - def test_updates_existing_var(self, tmp_path): - from cli.__main__ import _set_env_var - - env_file = tmp_path / ".env" - env_file.write_text("FOO=bar\nAGENTGATEWAY_ENABLED=true\nBAZ=qux\n") - - _set_env_var(env_file, "AGENTGATEWAY_ENABLED", "false") - - content = env_file.read_text() - assert "AGENTGATEWAY_ENABLED=false" in content - assert "AGENTGATEWAY_ENABLED=true" not in content - assert "FOO=bar" in content - assert "BAZ=qux" in content - - def test_appends_new_var(self, tmp_path): - from cli.__main__ import _set_env_var - - env_file = tmp_path / ".env" - env_file.write_text("FOO=bar\n") - - _set_env_var(env_file, "AGENTGATEWAY_ENABLED", "false") - - content = env_file.read_text() - assert "AGENTGATEWAY_ENABLED=false" in content - assert "FOO=bar" in content - - def test_creates_file_if_missing(self, tmp_path): - from cli.__main__ import _set_env_var - - env_file = tmp_path / ".env" - assert not env_file.exists() - - _set_env_var(env_file, "AGENTGATEWAY_ENABLED", "false") - - assert env_file.exists() - assert env_file.read_text().strip() == "AGENTGATEWAY_ENABLED=false" + assert code != 0 + assert _tree_snapshot(agw_dir) == before + + def test_exit_code_is_2(self): + """Distinct exit code so scripts can tell deprecation from crash.""" + code, _, _ = _run_cli(["migrate-credentials"]) + assert code == 2 + + def test_migration_machinery_removed(self): + """The old migration internals are gone from the CLI module.""" + import cli.__main__ as cli_main + + for removed in ( + "_rollback_migration", + "_resolve_provider_name", + "_model_to_slug", + "_parse_base_url", + "_set_env_var", + ): + assert not hasattr(cli_main, removed), removed diff --git a/platform/tests/test_services_llm.py b/platform/tests/test_services_llm.py index b6dc9a19..ebb635da 100644 --- a/platform/tests/test_services_llm.py +++ b/platform/tests/test_services_llm.py @@ -11,122 +11,148 @@ # ── create_llm_from_db ──────────────────────────────────────────────────────── +# +# agentgateway is the ONLY LLM path. The direct provider client construction +# (ChatOpenAI/ChatAnthropic built from a raw cred.api_key) was removed — +# credentials carry no api_key any more. Disabled gateway → defensive raise. + +def _gw_disabled_settings(): + return patch("config.settings", **{ + "AGENTGATEWAY_ENABLED": False, + "AGENTGATEWAY_URL": "", + }) + + +def _gw_enabled_mocks(): + """Patches for the agentgateway path (health/mint/proxy mocked at source).""" + from unittest.mock import AsyncMock + return ( + patch("config.settings", **{ + "AGENTGATEWAY_ENABLED": True, + "AGENTGATEWAY_URL": "http://localhost:4000", + }), + patch( + "services.agentgateway_client.check_agentgateway_health", + new_callable=AsyncMock, + return_value=(True, "ok"), + ), + patch("services.jwt_issuer.mint_llm_token", return_value="jwt-test-token"), + patch( + "services.agentgateway_client.create_proxied_llm", + return_value=MagicMock(name="proxied_llm"), + ), + ) -class TestCreateLlmFromDb: - @patch("services.llm._make_sanitized_chat_openai") - def test_openai_provider(self, mock_factory): - mock_instance = MagicMock() - mock_factory.return_value = mock_instance - cred = SimpleNamespace(provider_type="openai", api_key="sk-test") - create_llm_from_db(cred, "gpt-4") - mock_instance.assert_called_once_with(api_key="sk-test", model="gpt-4") - - @patch("services.llm._make_sanitized_chat_openai") - def test_openai_with_all_params(self, mock_factory): - mock_instance = MagicMock() - mock_factory.return_value = mock_instance - cred = SimpleNamespace(provider_type="openai", api_key="sk-test") - create_llm_from_db( - cred, "gpt-4", - temperature=0.7, - max_tokens=100, - frequency_penalty=0.5, - presence_penalty=0.3, - top_p=0.9, - timeout=30, - max_retries=2, - response_format={"type": "json_object"}, - ) - call_kwargs = mock_instance.call_args[1] - assert call_kwargs["temperature"] == 0.7 - assert call_kwargs["max_tokens"] == 100 - assert call_kwargs["frequency_penalty"] == 0.5 - assert call_kwargs["presence_penalty"] == 0.3 - assert call_kwargs["top_p"] == 0.9 - assert call_kwargs["timeout"] == 30 - assert call_kwargs["max_retries"] == 2 - assert call_kwargs["model_kwargs"] == {"response_format": {"type": "json_object"}} - - @patch("services.llm.ChatAnthropic", create=True) - def test_anthropic_provider(self, mock_cls): - with patch.dict("sys.modules", {"langchain_anthropic": MagicMock(ChatAnthropic=mock_cls)}): - cred = SimpleNamespace(provider_type="anthropic", api_key="sk-ant-test", base_url="") - create_llm_from_db(cred, "claude-3-opus-20240229") - mock_cls.assert_called_once_with(api_key="sk-ant-test", model="claude-3-opus-20240229") - - @patch("services.llm.ChatAnthropic", create=True) - def test_anthropic_with_custom_base_url(self, mock_cls): - with patch.dict("sys.modules", {"langchain_anthropic": MagicMock(ChatAnthropic=mock_cls)}): - cred = SimpleNamespace(provider_type="anthropic", api_key="sk-mm", base_url="https://api.minimax.io/anthropic") - create_llm_from_db(cred, "MiniMax-M2.5") - mock_cls.assert_called_once_with(api_key="sk-mm", base_url="https://api.minimax.io/anthropic", model="MiniMax-M2.5") - @patch("services.llm._make_sanitized_chat_openai") - def test_glm_provider(self, mock_factory): - mock_instance = MagicMock() - mock_factory.return_value = mock_instance - cred = SimpleNamespace(provider_type="glm", api_key="glm-key", base_url="") - create_llm_from_db(cred, "glm-4-plus") - mock_instance.assert_called_once_with( - api_key="glm-key", - base_url="https://api.z.ai/api/paas/v4/", - model="glm-4-plus", - use_responses_api=False, - ) +class TestCreateLlmFromDb: + """create_llm_from_db routes exclusively through agentgateway.""" - @patch("services.llm._make_sanitized_chat_openai") - def test_glm_provider_custom_base_url(self, mock_factory): - mock_instance = MagicMock() - mock_factory.return_value = mock_instance - cred = SimpleNamespace(provider_type="glm", api_key="glm-key", base_url="https://custom.z.ai/v4/") - create_llm_from_db(cred, "glm-4") - mock_instance.assert_called_once_with( - api_key="glm-key", - base_url="https://custom.z.ai/v4/", - model="glm-4", - use_responses_api=False, - ) + def test_raises_when_agentgateway_disabled(self): + cred = SimpleNamespace(provider_type="openai", base_credentials_id=1, base_credentials=None) + with _gw_disabled_settings(): + with pytest.raises(RuntimeError, match="requires agentgateway"): + create_llm_from_db(cred, "gpt-4") - @patch("services.llm._make_sanitized_chat_openai") - def test_openai_compatible_provider(self, mock_factory): - mock_instance = MagicMock() - mock_factory.return_value = mock_instance - cred = SimpleNamespace( - provider_type="openai_compatible", - api_key="custom-key", - base_url="http://localhost:11434/v1", - ) - create_llm_from_db(cred, "llama2") - mock_instance.assert_called_once_with( - api_key="custom-key", - base_url="http://localhost:11434/v1", - model="llama2", - ) + def test_raises_when_agentgateway_url_missing(self): + """Enabled flag alone is not enough — a gateway URL is required.""" + cred = SimpleNamespace(provider_type="openai", base_credentials_id=1, base_credentials=None) + with patch("config.settings", **{"AGENTGATEWAY_ENABLED": True, "AGENTGATEWAY_URL": ""}): + with pytest.raises(RuntimeError, match="requires agentgateway"): + create_llm_from_db(cred, "gpt-4") @patch("services.llm._make_sanitized_chat_openai") - def test_openai_compatible_with_params(self, mock_factory): - mock_instance = MagicMock() - mock_factory.return_value = mock_instance + def test_no_direct_client_construction_when_disabled(self, mock_factory): + """The removed direct-provider path must never be reached.""" + cred = SimpleNamespace(provider_type="openai", base_credentials_id=1, base_credentials=None) + with _gw_disabled_settings(): + with pytest.raises(RuntimeError): + create_llm_from_db(cred, "gpt-4") + mock_factory.assert_not_called() + + def test_openai_credential_routes_via_agentgateway(self): + settings_p, health_p, mint_p, proxy_p = _gw_enabled_mocks() + cred = SimpleNamespace(provider_type="openai", base_credentials_id=42, base_credentials=None) + with settings_p, health_p, mint_p, proxy_p as mock_proxy: + create_llm_from_db(cred, "gpt-4", user_profile_id=7, user_role="normal") + kw = mock_proxy.call_args.kwargs + assert kw["provider_type"] == "openai" + assert kw["backend_name"] == "openai" + assert kw["model"] == "gpt-4" + assert kw["jwt_token"] == "jwt-test-token" + + def test_anthropic_credential_routes_via_agentgateway(self): + settings_p, health_p, mint_p, proxy_p = _gw_enabled_mocks() + cred = SimpleNamespace(provider_type="anthropic", base_credentials_id=42, base_credentials=None) + with settings_p, health_p, mint_p, proxy_p as mock_proxy: + create_llm_from_db(cred, "claude-sonnet-4-6") + kw = mock_proxy.call_args.kwargs + assert kw["provider_type"] == "anthropic" + assert kw["backend_name"] == "anthropic" + + def test_glm_credential_routes_via_agentgateway(self): + settings_p, health_p, mint_p, proxy_p = _gw_enabled_mocks() + cred = SimpleNamespace(provider_type="glm", base_credentials_id=42, base_credentials=None) + with settings_p, health_p, mint_p, proxy_p as mock_proxy: + create_llm_from_db(cred, "glm-4-plus") + kw = mock_proxy.call_args.kwargs + assert kw["provider_type"] == "glm" + assert kw["backend_name"] == "glm" + + def test_openai_compatible_credential_routes_via_agentgateway(self): + settings_p, health_p, mint_p, proxy_p = _gw_enabled_mocks() cred = SimpleNamespace( provider_type="openai_compatible", - api_key="key", - base_url="http://test/v1", - ) - create_llm_from_db( - cred, "model", - frequency_penalty=0.1, - presence_penalty=0.2, - response_format={"type": "json"}, + base_credentials_id=99, + base_credentials=None, ) - call_kwargs = mock_instance.call_args[1] - assert call_kwargs["frequency_penalty"] == 0.1 - assert call_kwargs["presence_penalty"] == 0.2 - assert call_kwargs["model_kwargs"] == {"response_format": {"type": "json"}} - - def test_unsupported_provider(self): - cred = SimpleNamespace(provider_type="unknown_provider", api_key="key") - with pytest.raises(ValueError, match="Unsupported provider"): - create_llm_from_db(cred, "model") + with settings_p, health_p, mint_p, proxy_p as mock_proxy: + create_llm_from_db(cred, "llama2") + kw = mock_proxy.call_args.kwargs + assert kw["provider_type"] == "openai_compatible" + assert kw["backend_name"] == "custom-99" + + def test_all_params_forwarded_to_proxied_llm(self): + settings_p, health_p, mint_p, proxy_p = _gw_enabled_mocks() + cred = SimpleNamespace(provider_type="openai", base_credentials_id=42, base_credentials=None) + with settings_p, health_p, mint_p, proxy_p as mock_proxy: + create_llm_from_db( + cred, "gpt-4", + temperature=0.7, + max_tokens=100, + frequency_penalty=0.5, + presence_penalty=0.3, + top_p=0.9, + timeout=30, + max_retries=2, + response_format={"type": "json_object"}, + ) + kw = mock_proxy.call_args.kwargs + assert kw["temperature"] == 0.7 + assert kw["max_tokens"] == 100 + assert kw["frequency_penalty"] == 0.5 + assert kw["presence_penalty"] == 0.3 + assert kw["top_p"] == 0.9 + assert kw["timeout"] == 30 + assert kw["max_retries"] == 2 + assert kw["response_format"] == {"type": "json_object"} + + def test_credential_without_api_key_attribute_is_fine(self): + """Credentials no longer carry api_key — resolution must not touch it.""" + settings_p, health_p, mint_p, proxy_p = _gw_enabled_mocks() + + class KeylessCred: + provider_type = "openai" + base_credentials_id = 42 + base_credentials = None + + def __getattr__(self, name): + if name == "api_key": + raise AssertionError("api_key must never be read") + raise AttributeError(name) + + with settings_p, health_p, mint_p, proxy_p as mock_proxy: + create_llm_from_db(KeylessCred(), "gpt-4") + assert mock_proxy.call_args.kwargs["backend_name"] == "openai" # ── resolve_llm_for_node ────────────────────────────────────────────────────── diff --git a/platform/tests/test_workflow_create.py b/platform/tests/test_workflow_create.py index 0031374f..8fab3908 100644 --- a/platform/tests/test_workflow_create.py +++ b/platform/tests/test_workflow_create.py @@ -34,7 +34,6 @@ def llm_credential(db, user_profile): llm = LLMProviderCredential( base_credentials_id=base.id, provider_type="openai_compatible", - api_key="sk-test-key", base_url="https://api.openai.com/v1", ) db.add(llm) diff --git a/platform/tests/test_zombie_execution_fixes.py b/platform/tests/test_zombie_execution_fixes.py index 4dac7e75..cda808d5 100644 --- a/platform/tests/test_zombie_execution_fixes.py +++ b/platform/tests/test_zombie_execution_fixes.py @@ -434,6 +434,7 @@ def test_missing_base_credential_ai_model(self): mock_config.component_type = "ai_model" mock_config.model_name = "gpt-4" mock_config.llm_credential_id = 999 + mock_config.backend_route = None # force the credential path mock_node = MagicMock() mock_node.node_id = "model_1" @@ -455,6 +456,7 @@ def test_missing_llm_credential_relationship(self): mock_config.component_type = "ai_model" mock_config.model_name = "gpt-4" mock_config.llm_credential_id = 1 + mock_config.backend_route = None # force the credential path mock_node = MagicMock() mock_node.node_id = "model_1" @@ -474,6 +476,7 @@ def test_missing_base_credential_via_model_config(self): mock_ai_config.component_type = "ai_model" mock_ai_config.model_name = "gpt-4" mock_ai_config.llm_credential_id = 999 + mock_ai_config.backend_route = None # force the credential path mock_db.get.return_value = mock_ai_config # Credential query returns None @@ -483,6 +486,7 @@ def test_missing_base_credential_via_model_config(self): mock_config.component_type = "agent" mock_config.llm_model_config_id = 5 mock_config.llm_credential_id = None + mock_config.backend_route = None mock_node = MagicMock() mock_node.node_id = "agent_1" From 16628823d353efd980eb1a94ff27fddf0848fc2a Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Fri, 3 Jul 2026 14:34:48 +0930 Subject: [PATCH 04/10] feat(frontend): remove LLM credential UI; drive model picker from agentgateway - CredentialsPage: drop the LLM credential form/type entirely (git/gateway/tool remain). - NodeDetailsPanel: model picker + native-search detection now source from agentgateway available-models, not credential rows. - credentials.ts: drop the removed LLM test/models hooks. Co-Authored-By: Claude Opus 4.8 --- platform/frontend/src/api/credentials.ts | 14 +-- .../features/credentials/CredentialsPage.tsx | 105 ++++-------------- .../workflows/components/NodeDetailsPanel.tsx | 104 ++++++----------- 3 files changed, 51 insertions(+), 172 deletions(-) diff --git a/platform/frontend/src/api/credentials.ts b/platform/frontend/src/api/credentials.ts index 01a86a7c..8214ebde 100644 --- a/platform/frontend/src/api/credentials.ts +++ b/platform/frontend/src/api/credentials.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { apiFetch } from "./client" -import type { Credential, CredentialCreate, CredentialUpdate, CredentialTestResult, CredentialModel, PaginatedResponse } from "@/types/models" +import type { Credential, CredentialCreate, CredentialUpdate, PaginatedResponse } from "@/types/models" export function useCredentials(params?: { limit?: number; offset?: number }) { const qs = new URLSearchParams() @@ -25,18 +25,6 @@ export function useDeleteCredential() { return useMutation({ mutationFn: (id: number) => apiFetch(`/credentials/${id}/`, { method: "DELETE" }), onSuccess: () => qc.invalidateQueries({ queryKey: ["credentials"] }) }) } -export function useTestCredential() { - return useMutation({ mutationFn: (id: number) => apiFetch(`/credentials/${id}/test/`, { method: "POST" }) }) -} - -export function useCredentialModels(credentialId: number | undefined) { - return useQuery({ - queryKey: ["credential-models", credentialId], - queryFn: () => apiFetch(`/credentials/${credentialId}/models/`), - enabled: !!credentialId, - }) -} - export function useBatchDeleteCredentials() { const qc = useQueryClient() return useMutation({ diff --git a/platform/frontend/src/features/credentials/CredentialsPage.tsx b/platform/frontend/src/features/credentials/CredentialsPage.tsx index baee5336..2b8c0da1 100644 --- a/platform/frontend/src/features/credentials/CredentialsPage.tsx +++ b/platform/frontend/src/features/credentials/CredentialsPage.tsx @@ -1,7 +1,6 @@ import { useState } from "react" import { Link } from "react-router-dom" -import { useCredentials, useCreateCredential, useUpdateCredential, useDeleteCredential, useTestCredential, useBatchDeleteCredentials, useActivateCredential, useDeactivateCredential } from "@/api/credentials" -import { useAvailableModels } from "@/api/available_models" +import { useCredentials, useCreateCredential, useUpdateCredential, useDeleteCredential, useBatchDeleteCredentials, useActivateCredential, useDeactivateCredential } from "@/api/credentials" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" @@ -12,43 +11,28 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Badge } from "@/components/ui/badge" import { PaginationControls } from "@/components/ui/pagination-controls" -import { Plus, Trash2, CheckCircle, XCircle, Loader2, Star, Power, PowerOff, Info } from "lucide-react" +import { Plus, Trash2, Star, Power, PowerOff, Info } from "lucide-react" import { format } from "date-fns" import type { CredentialType } from "@/types/models" const PAGE_SIZE = 50 -const ALL_CREDENTIAL_TYPES: CredentialType[] = ["llm", "gateway", "git", "tool"] -const PROVIDER_TYPES = [ - { value: "openai", label: "OpenAI" }, - { value: "anthropic", label: "Anthropic" }, - { value: "glm", label: "GLM (Z.AI)" }, - { value: "openai_compatible", label: "OpenAI Compatible" }, -] +const ALL_CREDENTIAL_TYPES: CredentialType[] = ["gateway", "git", "tool"] export default function CredentialsPage() { const [page, setPage] = useState(1) const { data, isLoading } = useCredentials({ limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }) - const { data: availableModels } = useAvailableModels() - const agentgatewayEnabled = (availableModels?.length ?? 0) > 0 - const credentialTypes = agentgatewayEnabled - ? ALL_CREDENTIAL_TYPES.filter((t) => t !== "llm") - : ALL_CREDENTIAL_TYPES + const credentialTypes = ALL_CREDENTIAL_TYPES const credentials = data?.items const total = data?.total ?? 0 const createCredential = useCreateCredential() const updateCredential = useUpdateCredential() const deleteCredential = useDeleteCredential() - const testCredential = useTestCredential() const batchDelete = useBatchDeleteCredentials() const activateCredential = useActivateCredential() const deactivateCredential = useDeactivateCredential() const [open, setOpen] = useState(false) const [name, setName] = useState("") - const [credType, setCredType] = useState("llm") - const [providerType, setProviderType] = useState("openai_compatible") - const [apiKey, setApiKey] = useState("") - const [baseUrl, setBaseUrl] = useState("") - const [organizationId, setOrganizationId] = useState("") + const [credType, setCredType] = useState("gateway") const [toolType, setToolType] = useState("searxng") const [toolUrl, setToolUrl] = useState("") const [toolPreferred, setToolPreferred] = useState(false) @@ -56,7 +40,6 @@ export default function CredentialsPage() { const [gatewayToken, setGatewayToken] = useState("") const [gatewayConfig, setGatewayConfig] = useState("") const [deleteId, setDeleteId] = useState(null) - const [testResults, setTestResults] = useState>({}) const [selectedIds, setSelectedIds] = useState>(new Set()) const [confirmBatchDelete, setConfirmBatchDelete] = useState(false) @@ -66,8 +49,7 @@ export default function CredentialsPage() { return // URL is required } let detail: Record = {} - if (credType === "llm") detail = { provider_type: providerType, api_key: apiKey, base_url: baseUrl, organization_id: organizationId } - else if (credType === "gateway") { + if (credType === "gateway") { detail = { adapter_type: gatewayAdapterType, token: gatewayToken } if (gatewayConfig.trim()) { try { @@ -81,12 +63,8 @@ export default function CredentialsPage() { else if (credType === "tool") detail = { tool_type: toolType, config: { url: toolUrl }, is_preferred: toolPreferred } await createCredential.mutateAsync({ name, credential_type: credType, detail }) setOpen(false) - setCredType(agentgatewayEnabled ? "gateway" : "llm") - setProviderType("openai_compatible") + setCredType("gateway") setName("") - setApiKey("") - setBaseUrl("") - setOrganizationId("") setGatewayAdapterType("telegram") setGatewayToken("") setGatewayConfig("") @@ -95,16 +73,6 @@ export default function CredentialsPage() { setToolPreferred(false) } - async function handleTest(id: number) { - setTestResults((prev) => ({ ...prev, [id]: "loading" })) - try { - const result = await testCredential.mutateAsync(id) - setTestResults((prev) => ({ ...prev, [id]: result })) - } catch { - setTestResults((prev) => ({ ...prev, [id]: { ok: false, error: "Request failed" } })) - } - } - function toggleSelect(id: number) { setSelectedIds((prev) => { const next = new Set(prev) @@ -142,22 +110,20 @@ export default function CredentialsPage() { Delete Selected ({selectedIds.size}) )} - +
- {agentgatewayEnabled && ( -
- - - LLM providers are managed on the{" "} - - Providers page - - . - -
- )} +
+ + + LLM providers are managed on the{" "} + + Providers page + + . + +
@@ -176,17 +142,14 @@ export default function CredentialsPage() { {credentials?.map((cred) => { - const tr = testResults[cred.id] - const isLlmDimmed = agentgatewayEnabled && cred.credential_type === "llm" return ( - + toggleSelect(cred.id)} /> {cred.name} {cred.credential_type} - {cred.credential_type === "llm" && (cred.detail.provider_type as string ?? "")} {cred.credential_type === "gateway" && (cred.detail.adapter_type as string ?? "")} {cred.credential_type === "tool" && ( @@ -199,11 +162,6 @@ export default function CredentialsPage() { {format(new Date(cred.created_at), "MMM d, yyyy")} - {cred.credential_type === "llm" && ( - - )} {cred.credential_type === "tool" && (
- {credType === "llm" && ( - <> -
- - -
-
- - setApiKey(e.target.value)} /> -
-
- - setBaseUrl(e.target.value)} placeholder="https://api.openai.com/v1" /> -
-
- - setOrganizationId(e.target.value)} /> -
- - )} {credType === "gateway" && ( <>
diff --git a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx index 7b8dd0e6..633cc4aa 100644 --- a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx +++ b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useMemo } from "react" import { useQueryClient } from "@tanstack/react-query" import { useUpdateNode, useDeleteNode, useScheduleStart, useSchedulePause, useScheduleStop } from "@/api/nodes" import { useWorkflows } from "@/api/workflows" -import { useCredentials, useCredentialModels } from "@/api/credentials" +import { useCredentials } from "@/api/credentials" import { useAvailableModels } from "@/api/available_models" import { useWorkspaces } from "@/api/workspaces" @@ -71,7 +71,6 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { const { data: workspacesData } = useWorkspaces() const { data: gatewayModels = [] } = useAvailableModels() const allCredentials = credentials?.items ?? [] - const llmCredentials = allCredentials.filter((c) => c.credential_type === "llm") const [labelValue, setLabelValue] = useState(node.label || node.node_id) @@ -81,7 +80,6 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { const [systemPrompt, setSystemPrompt] = useState(node.config.system_prompt) const [extraConfig, setExtraConfig] = useState(JSON.stringify(node.config.extra_config, null, 2)) - const [llmCredentialId, setLlmCredentialId] = useState(node.config.llm_credential_id?.toString() ?? "") const [modelName, setModelName] = useState(node.config.model_name ?? "") const [backendRoute, setBackendRoute] = useState(node.config.backend_route ?? "") const [temperature, setTemperature] = useState(node.config.temperature?.toString() ?? "") @@ -246,9 +244,6 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { const manualExecute = useManualExecute(slug, node.node_id) - const credId = llmCredentialId ? Number(llmCredentialId) : undefined - const { data: credentialModels } = useCredentialModels(credId) - const isLLMNode = node.component_type === "ai_model" const isAgentNode = node.component_type === "agent" || node.component_type === "deep_agent" const isDeepAgent = node.component_type === "deep_agent" @@ -256,13 +251,10 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { const isTriggerNode = TRIGGER_TYPES.includes(node.component_type) const isAnthropicNative = useMemo(() => { - if (!isLLMNode || !llmCredentialId) return false - const cred = allCredentials.find(c => c.id === Number(llmCredentialId)) - if (!cred) return false - const provider = cred.detail?.provider_type as string - const baseUrl = cred.detail?.base_url as string - return provider === "anthropic" && (!baseUrl || baseUrl.includes("anthropic.com")) - }, [isLLMNode, llmCredentialId, allCredentials]) + if (!isLLMNode || !backendRoute) return false + const model = gatewayModels.find((m) => m.route === backendRoute) + return model?.provider === "anthropic" + }, [isLLMNode, backendRoute, gatewayModels]) const searchBackend = useMemo(() => { if (!isAgentNode || !workflow) return null @@ -271,13 +263,12 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { (e) => e.target_node_id === node.node_id && e.edge_label === "llm" ) const modelNode = modelEdge ? workflow.nodes.find((n) => n.node_id === modelEdge.source_node_id) : undefined - const credId = modelNode?.config.llm_credential_id - const cred = credId ? allCredentials.find((c) => c.id === credId) : undefined - const provider = (cred?.detail as Record)?.provider_type as string - const baseUrl = (cred?.detail as Record)?.base_url as string + const route = modelNode?.config.backend_route + const model = route ? gatewayModels.find((m) => m.route === route) : undefined + const provider = model?.provider const useNative = !!(modelNode?.config.extra_config as Record)?.use_native_search // Priority 1: Native opt-in overrides SearXNG - if (useNative && provider === "anthropic" && (!baseUrl || baseUrl.includes("anthropic.com"))) + if (useNative && provider === "anthropic") return "anthropic" // Priority 2: SearXNG is the default const hasSearxng = allCredentials.some( @@ -285,7 +276,7 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { ) if (hasSearxng) return "searxng" return "unavailable" - }, [isAgentNode, workflow, node.node_id, allCredentials]) + }, [isAgentNode, workflow, node.node_id, allCredentials, gatewayModels]) function handleSave() { let parsedExtra: Record = {} @@ -371,7 +362,7 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { config: { system_prompt: systemPrompt, extra_config: parsedExtra, - llm_credential_id: llmCredentialId ? Number(llmCredentialId) : null, + llm_credential_id: null, model_name: modelName, backend_route: backendRoute || null, temperature: temperature ? Number(temperature) : null, @@ -619,59 +610,26 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { {isLLMNode && ( <> - {gatewayModels.length > 0 ? ( -
- - -
- ) : ( - <> -
- - -
-
- - {credentialModels && credentialModels.length > 0 ? ( - - ) : ( - setModelName(e.target.value)} placeholder="e.g. gpt-4o" className="text-xs" /> - )} -
- - )} +
+ + +
From 567b06053038e47c5770170c5c9093bc17bdce15 Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Fri, 3 Jul 2026 14:34:48 +0930 Subject: [PATCH 05/10] docs: add Phase 1b execution plan (remove LLM keys / agentgateway trust boundary) Co-Authored-By: Claude Opus 4.8 --- .sisyphus/plans/phase-1b-remove-llm-keys.md | 184 ++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 .sisyphus/plans/phase-1b-remove-llm-keys.md diff --git a/.sisyphus/plans/phase-1b-remove-llm-keys.md b/.sisyphus/plans/phase-1b-remove-llm-keys.md new file mode 100644 index 00000000..25828dd1 --- /dev/null +++ b/.sisyphus/plans/phase-1b-remove-llm-keys.md @@ -0,0 +1,184 @@ +# Plan: Phase 1(b) — Remove real LLM keys from Pipelit (make the gateway trust boundary real) + +## Context +Phase 1(a) routed LLM calls through agentgateway but left it feature-flagged OFF and left raw +provider API keys living in Pipelit's DB with a still-live direct-provider code path. Phase 1(b) +(Pipelit#188) closes that gap: agentgateway becomes the sole holder of LLM credentials, Pipelit's +workflow code holds only a short-lived ES256 JWT, the flag defaults ON, and the direct-key path plus +DB key storage are removed. This is a **hard cutover** — no live user credentials exist, so +rollback/idempotency/zero-downtime are nice-to-have, not P0. + +**This migration is largely ALREADY BUILT on `feat/agentgateway-integration` but unfinished.** The job +is audit → fix → harden → land, not author-from-scratch. + +## Ground-truth file map (verified against current branches) + +**pipelit** (`~/Programs/pipelit.ai/pipelit`, branch `feat/agentgateway-integration`): +- `platform/services/llm.py` + - `create_llm_from_db` — agentgateway path `147-163`; **direct-provider fallback `165-216`** (reads `credential.api_key`). + - `_fake_credential_from_route` **`87-102`** (empty `api_key=""` trap) + `_route_provider_to_type` `78-84`. + - `resolve_llm_for_node` `319-462` — **TWO paths**: `ai_model` component direct (`342-396`) AND AI node via `llm_model_config_id` FK (`398-455`); each has a `backend_route` branch and a legacy credential branch. + - `resolve_credential_for_node` `465-510` — web-search provider detection; calls `_fake_credential_from_route` at `485`/`500`. +- `platform/services/agentgateway_config.py` — `write_provider_key` `83-98` (Fernet), `add_provider` `112-147`, **`add_model` `180-203` (writes `model: ` — this is where the model-id bug lands)**, `list_all_available_models` `240-276`. +- `platform/services/agentgateway_client.py` — `create_proxied_llm` `37-101` (base_url = `{gw}/{backend_name}`, JWT bearer; anthropic via `default_headers`), `check_agentgateway_health` `104-131`. +- `platform/services/jwt_issuer.py` — `mint_llm_token` `20-75`, ES256, 60s lifetime, `kid=pipelit-001`, needs `settings.JWT_PRIVATE_KEY`. +- `platform/cli/__main__.py` — `_resolve_provider_name` `284-294`, `_model_to_slug` `297-302`, `_parse_base_url` `305-333`, **`cmd_migrate_credentials` `336-519`** (model name derived from **`cred.base_credentials.name`** at `428` and `452` — the bug), `_rollback_migration` `522-587`, `_set_env_var` `590-606`. +- `platform/models/credential.py` — `LLMProviderCredential` `70-83`; secret is **`api_key: EncryptedString(500)` at line 78** in table **`llm_credentials`** (NOT the shared `credentials` table — the brief's "shared table" wording is imprecise; `credentials` holds only the discriminator/name). +- `platform/api/credentials.py` — multi-type router; LLM-typed logic in `_serialize_credential` `72-80`, `create_credential` `136-145`, `update_credential` `238-242`, and `test_credential` `382-455` (makes **direct provider HTTP calls with `llm.api_key`**). +- `platform/api/available_models.py` — **already reads from agentgateway filesystem** (`list_all_available_models`), returns `[]` when flag off. Pipelit#189 mostly done here. +- `platform/api/providers.py` — admin CRUD over agentgateway config.d (write_provider_key/fetch-models); **already agentgateway-native**, not credential-derived. +- `platform/config.py` — **`AGENTGATEWAY_ENABLED: bool = False` at line 140**; `AGENTGATEWAY_URL` `139`, `AGENTGATEWAY_DIR` `141`, `FIELD_ENCRYPTION_KEY` `110`. +- Extra `.api_key` readers to handle: `components/_agent_shared.py:168-172` (`_resolve_credential_field` reads `cred.llm_credential.api_key`), web-search path in `components/agent.py:85-86` and `components/deep_agent.py:124-125` (consume `resolve_credential_for_node().provider_type`). +- Alembic: single head = **`758cd1ca2aad`** (`add_backend_route_to_component_configs`). New migration's `down_revision` = `758cd1ca2aad`. +- Tests: `platform/tests/test_migrate_credentials.py` (**line 345 asserts `model_data["model"] == "OpenAI (default)"` — encodes the bug**), `test_credentials_glm.py`, `test_credentials_agentgateway.py`, `test_services_llm.py`, `test_llm_agentgateway.py`, `test_agent_web_search.py`. +- Frontend: `platform/frontend/src/features/credentials/CredentialsPage.tsx` (line ~34 already hides LLM form when agentgateway enabled), model picker with credential-fallback in `platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx`, api hooks `frontend/src/api/credentials.ts` + `frontend/src/api/available_models.ts`. + +**plit** (`~/Programs/pipelit.ai/plit`, branch `feat/agentgateway-docker`): +- `docker/entrypoint.sh` (~`26-90`) starts agentgateway; current gating is **soft** (probe ~10s then continue even on failure) and keyed off `AGENTGATEWAY_DIR` presence. +- `docker/Dockerfile` — downloads agentgateway v1.0.1. **NOTE / brief correction: there is NO `ARG INCLUDE_AGENTGATEWAY` gate — the binary is downloaded unconditionally.** So "flip INCLUDE_AGENTGATEWAY default→true" is moot; the real work is making the gateway a **hard** boot dependency. +- Port `:4000` hardcoded in ~2 spots (`docker/entrypoint.sh` and a Rust init/config file) — confirm exact list during Task 2. + +**Out of scope repos (do NOT touch):** `plit-gw` (master, message gateway — name-collision hazard with agentgateway), `tela`. + +## Librarian finding — BAKED IN (resolves the former verify-then-fix open item) +**CONFIRMED BUG.** agentgateway v1.0.1's per-provider `model:` field is a **hard outbound override**: +if set, it unconditionally replaces the caller's requested model with that literal string before +forwarding upstream. The migration writes the credential **display name** (`cred.base_credentials.name`, +e.g. `"OpenAI (default)"`) into `model:`, so every proxied request ships `"model":"OpenAI (default)"` → +guaranteed `404 model_not_found` at the upstream provider. Fails loudly/consistently. +**Fix:** do NOT write the display name. Leave `model:` **UNSET** so the caller's real model id +(`cc.model_name`) passes through unchanged; only populate `model:` with a **real upstream model id** when +intentionally pinning a route. This is now a **definite fix task (Task 1)**, not a verification task. + +## Task Dependency Graph +``` +Task 1 (model-id fix + migration hardening) ─┐ +Task 2 (plit hard boot dep) │ + ▼ +Task 3 (update migration tests) ── Task 4 (dry-run verify migration) + │ +Task 1, Task 2, Task 4 ─────────────────────▼ +Task 5 (flip AGENTGATEWAY_ENABLED default → True + pipelit hard boot dep) + │ + ▼ +Task 6 (remove direct-provider fallback + _fake_credential trap; both resolve paths; extra api_key readers) + │ + ┌────────────────┼────────────────┐ + ▼ ▼ ▼ +Task 7 (remove LLM CRUD Task 8 (alembic Task 9 (frontend: drop LLM cred +in api/credentials.py) drop api_key col) form + credential-fallback model picker) + │ │ │ + └────────────────┼─────────────────┘ + ▼ +Task 10 (Final Verification Wave) +``` + +## Execution Waves + +### Wave 1 (parallel — foundational, cross-repo) + +- **Task 1 — Fix model-id handling + harden migration.** Agent: **hephaestus** + - Objective: Stop writing display names as model ids; make multi-key collisions fail loud. + - Files: `platform/services/agentgateway_config.py` (`add_model` `180-203`), `platform/cli/__main__.py` (`cmd_migrate_credentials` `336-519`, esp. `428`/`452`). + - Sub-task 1a (config shape): confirm which agentgateway config shape the generator/assembled `config.yaml` actually targets. Librarian (v1.0.1 source, `crates/agentgateway/src/llm/mod.rs`) confirms the hard-override field is **`provider..model`** (`Provider { model: Option }`); `override_model()` writes it verbatim into the outgoing request body with no validation. Inspect `AGENTGATEWAY_DIR/assemble-config.sh` output and a real assembled `config.yaml` to confirm the assembled key path, then fix whichever field is generated. + - Sub-task 1b (model-id): change model-file writing so the provider `model:` override is **omitted by default** (pass-through of caller's `cc.model_name` — which `read_body_and_default_model` requires when the override is unset). Only emit a `model:` value when an explicit real upstream model id is supplied. Model **filename/slug** may still derive from the display name (routing key), but the routing-significant `provider..model` field must not carry the display name. **If a friendly client-facing label is ever wanted, express it via `policies.ai.modelAliases` (client-model → real-model rewrite), NEVER via `provider..model`** — the two are distinct mechanisms and mixing them has untested precedence at v1.0.1 (override parses before alias resolution). + - Sub-task 1c (multi-key FAIL LOUD): in `cmd_migrate_credentials`, when >1 `LLMProviderCredential` groups to the same provider name with differing `api_key`, **abort with a non-zero exit and explicit error** naming the colliding credentials — do NOT silently keep only `first_cred` (current `390-448` behavior). + - Dependencies: none. + - Acceptance: migrating a credential named `"OpenAI (default)"` produces a provider/model config whose routing-significant model field is empty/pass-through (no `"OpenAI (default)"` literal anywhere in the assembled config's model field); two openai creds with different keys → migration exits non-zero with a collision error; `assemble-config.sh` still succeeds; the targeted config field verified against sub-task 1a. + +- **Task 2 — Make agentgateway a hard boot dependency in plit containers.** Agent: **sisyphus-junior** + - Objective: Gateway must be present and healthy before plit serves; container fails fast if it isn't. + - Files: `docker/entrypoint.sh` (~`26-90`), `docker/Dockerfile`, plit docker-compose (locate it), the ~2 hardcoded `:4000` spots. + - Steps: replace the soft "probe ~10s then continue" with a **blocking readiness gate** (poll gateway health; exit non-zero on timeout). Confirm the v1.0.1 binary is unconditionally present (no `INCLUDE_AGENTGATEWAY` ARG exists — do not invent one; if you add an opt-out, default it to ON). Ensure `AGENTGATEWAY_URL`/`AGENTGATEWAY_DIR` are exported so pipelit sees them. Leave `:4000` centralized or documented. + - Dependencies: none. **Sequencing constraint #1: this must land before Task 5 is safe in containers.** + - Acceptance: `docker build` succeeds; container with a reachable gateway boots; container with the gateway forced-unreachable **fails its healthcheck / exits non-zero** rather than serving keyless. + +### Wave 2 (depends on Task 1) + +- **Task 3 — Update migration tests to the fixed model-id contract.** Agent: **sisyphus-junior** + - Objective: Tests must assert pass-through (no display-name model), not the old bug. + - Files: `platform/tests/test_migrate_credentials.py` (esp. `345`, plus anthropic/glm/openai_compatible cases and any assertion of `model:` == a display name), add a collision-fails-loud test. + - Dependencies: Task 1. + - Acceptance: updated suite asserts routing-significant model field is empty/pass-through; a new test asserts multi-key collision → non-zero exit; `pytest platform/tests/test_migrate_credentials.py` green. + +- **Task 4 — Run migration in `--dry-run` and verify (no over-engineered rollback).** Agent: **sisyphus-junior** + - Objective: Prove the fixed migration produces a correct provider/model tree for all four provider types. + - Files: run `python -m cli migrate-credentials --dry-run` (and `--populate-routes` dry preview) against a seeded test DB with openai/anthropic/glm/openai_compatible creds. + - Dependencies: Task 1, Task 3. + - Acceptance: dry-run output lists correct provider dirs, key files, model files (no display-name in the model field), correct `backend_route` mapping preview; no crash on the openai_compatible/custom-name path; keep the existing `--rollback` working (do not delete it) but do not expand it. + +### Wave 3 (depends on Task 1, Task 2, Task 4) + +- **Task 5 — Flip `AGENTGATEWAY_ENABLED` default → True and add pipelit hard boot dependency.** Agent: **sisyphus-junior** + - Objective: Gateway ON by default; Pipelit refuses to start (or fails LLM resolution loudly) if the gateway is required but unreachable. + - Files: `platform/config.py:140`; a startup hook (locate app startup, e.g. FastAPI lifespan / worker bootstrap) to assert `check_agentgateway_health` when `AGENTGATEWAY_ENABLED`. + - Dependencies: Task 2 (containers must guarantee gateway presence), Task 4 (migration verified). + - **Do NOT collapse Tasks 6/7/8 into this task.** This wave only flips the flag + adds the hard dep. + - Acceptance: fresh boot with gateway up works; gateway down → explicit fatal error at startup (not a silent fall-through to the direct path, which still exists until Task 6). + +### Wave 4 (depends on Task 5) + +- **Task 6 — Remove the direct-provider fallback and the empty-key trap; handle both resolve paths and all extra `api_key` readers.** Agent: **hephaestus** + - Objective: There is no code path left that reads a raw LLM `api_key`. + - Files & specifics: + - `platform/services/llm.py`: delete the direct-provider branch `165-216` in `create_llm_from_db` (agentgateway becomes the only path; raise clearly if disabled). Remove `_fake_credential_from_route` `87-102` **but preserve provider-type inference** — web-search detection (`agent.py:85`, `deep_agent.py:124`) relies on `resolve_credential_for_node().provider_type`; replace the fake-empty-key object with a provider-type value derived from `backend_route` (reuse `_route_provider_to_type`) carrying **no `api_key` field at all**. Apply identical treatment to **both** `resolve_llm_for_node` paths (`342-396` and `398-455`) and both branches of `resolve_credential_for_node` (`485`, `500`). + - `platform/components/_agent_shared.py:168-172`: `_resolve_credential_field` returns `cred.llm_credential.api_key` — remove/neuter the `api_key` branch (that secret no longer exists); confirm callers (`225-226`) degrade safely. + - **`platform/services/dsl_compiler.py:406` (`_fetch_model_ids`, reached via `_discover_model` → `_resolve_model`/`_build_step_config` from `compile_dsl`/`validate_dsl`) — FOURTH raw-key reader (momus-found): does `httpx.get(f"{base_url}/models", headers={"Authorization": f"Bearer {cred.api_key}"})`. LIVE code (imported from `api/workflows.py` and `components/workflow_create.py` for DSL model auto-selection). Replace the credential-key model fetch with agentgateway-native `list_all_available_models()` (already exists, see `api/available_models.py`). Must be handled here in Task 6 — if Task 8 drops `api_key` first, DSL compile raises at runtime.** + - Dependencies: Task 5. + - Acceptance: `grep -rn "\.api_key" platform --include=*.py` shows no remaining reads of an LLM credential key in product code — **including `dsl_compiler.py`** (tests updated separately); web-search provider detection still returns correct provider for a `backend_route`-only node; DSL compile/validate model auto-selection works via agentgateway; `test_services_llm.py`, `test_llm_agentgateway.py`, `test_agent_web_search.py` updated and green. + +### Wave 5 (parallel — depends on Task 6) + +- **Task 7 — Remove LLM-typed credential CRUD from the API.** Agent: **hephaestus** + - Objective: The API can no longer create/update/store/test raw LLM keys; git/gateway/tool creds untouched. + - Files: `platform/api/credentials.py` — strip the `llm` branches in `_serialize_credential` (`72-80`), `create_credential` (`136-145`), `update_credential` (`238-242`), and remove the direct-provider `test_credential` LLM logic (`382-455`) or make it reject `llm` type. Keep `git`/`gateway`/`tool` fully intact. + - Dependencies: Task 6. + - Acceptance: POST/PATCH of a `credential_type=llm` returns a clear rejection; git/gateway/tool CRUD unaffected; `test_credentials_glm.py` / `test_credentials_agentgateway.py` updated to reflect removal. + +- **Task 8 — Alembic: drop the LLM key storage column.** Agent: **sisyphus-junior** + - Objective: Remove the encrypted `api_key` at rest. + - Files: new revision under `platform/alembic/versions/` with `down_revision = "758cd1ca2aad"`; edit `platform/models/credential.py` to drop the `api_key` mapped_column (line 78). Decide (document in the revision docstring): drop **only `api_key`** vs the whole `llm_credentials` sub-table. **Recommended: drop the `api_key` column** (and `organization_id`/`custom_headers` only if confirmed unused post-Task 6); keep `provider_type`/`base_url` if any remaining read needs them (audit first). Hard cutover — no data-preservation branch required. + - Dependencies: Task 6 (no code reads `api_key`), coordinate with Task 7 (model/schema consistency). + - Acceptance: `alembic upgrade head` clean on a fresh DB; `alembic heads` shows a single head; model imports without the dropped attribute; app boots. + +- **Task 9 — Frontend: remove LLM credential form + credential-fallback model picker; agentgateway as model source of truth (Pipelit#189).** Agent: **sisyphus-junior** + - Objective: No orphaned UI calling removed endpoints; model picker driven solely by `available_models` (agentgateway). + - Files: `platform/frontend/src/features/credentials/CredentialsPage.tsx` (remove the LLM create/edit form path, not just the hide at ~34), `platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx` (remove the credential-derived model-picker fallback; keep the agentgateway `useAvailableModels` path), `frontend/src/api/credentials.ts` (drop llm-cred + test/models hooks), `frontend/src/api/available_models.ts`. + - Dependencies: Task 6/7 (backend endpoints removed). Backend `available_models.py`/`providers.py` are already agentgateway-native — verify no residual credential-derived model listing remains server-side. + - Acceptance: credentials UI shows only git/gateway/tool; node model picker populates from agentgateway; no frontend call hits a removed LLM endpoint; typecheck/lint pass. + +### Final Verification Wave + +- **Task 10 — Cross-provider + docker-compose integration.** Agent: **hephaestus** (with **oracle** review pass) + - Cross-provider unit/integration: openai, anthropic, glm, openai_compatible resolve through agentgateway with the real `cc.model_name` passed through (not overridden). Run full `pytest platform/tests`. + - **docker-compose integration (constraint #3):** bring up **pipelit + plit together**; assert gateway healthcheck passes, pipelit boots with `AGENTGATEWAY_ENABLED=true`, a workflow node resolves an LLM, mints a JWT, and reaches the gateway (health/JWT/reachability). Do NOT substitute pipelit unit tests for this. + - Secret-removal proof: `grep -rn "\.api_key" platform --include=*.py` clean in product code; DB has no LLM `api_key` column; no raw key in pipelit `.env`/config. + - Static sweeps: `alembic heads` single; `ruff`/type checks; frontend typecheck. + - oracle: post-implementation read-only review of the trust-boundary claim (JWT-only egress, no key leakage). + +## Deferred (document as known limitations — do NOT schedule) +- JWT issuance stays in pipelit (self-signed, "circular trust") — moves to plit-gw in **Phase 4**. +- `AGENTGATEWAY_ENCRYPTION_KEY` remains == pipelit `FIELD_ENCRYPTION_KEY` (single Fernet key) — split deferred, note the caveat in `agentgateway_config.py:_get_fernet`. +- MCP federation / CEL RBAC / adapter refactor — **Phase 2/3**; OAuth + credential-schema UX — **Phase 5**. +- Migration of `git`/`gateway`/`tool` credentials — out of scope (only `llm` discriminator migrates). +- No `plit-gw` or `tela` changes. + +## Risk Flags +- **Web-search provider detection regression:** `resolve_credential_for_node` feeds `agent.py`/`deep_agent.py`; removing `_fake_credential_from_route` must preserve `provider_type` inference or native web-search silently breaks. (Task 6.) +- **Config-shape mismatch:** if Task 1a mis-identifies the generated field (`model` vs `name`/`params.model`), the model-id fix targets the wrong key and the bug persists. Verify against a real assembled `config.yaml`. +- **Hidden `api_key` reader** in `_agent_shared.py:170` — easy to miss; dropping the column before neutering it crashes credential-field injection. (Order Task 6 before Task 8.) +- **`resolve_credential_for_node` legacy branches** still query `llm_credential`; if Task 8 drops columns those relationships still load — confirm they don't dereference `api_key`. +- **Hard boot dependency deadlock:** if pipelit's startup health-check (Task 5) runs before plit's gateway is ready in compose, boot fails — ensure ordering/retry in compose (Task 2/10). +- **Test fixtures assume the bug:** beyond line 345, other assertions may encode display-name-as-model; sweep the whole migration/agentgateway test set (Task 3). +- Multi-head alembic risk is low (single head `758cd1ca2aad` confirmed) but re-verify `alembic heads` at Task 8 time in case of drift. + +## QA Scenarios +- [ ] Migrating a credential named `"OpenAI (default)"` yields a route whose model field is empty/pass-through; a live openai call using `cc.model_name="gpt-4o"` returns 200 (no 404 model_not_found). +- [ ] Two openai credentials with different keys → `migrate-credentials` exits non-zero naming the collision; no key silently dropped. +- [ ] `AGENTGATEWAY_ENABLED` defaults True on a fresh checkout; pipelit refuses to boot when the gateway is unreachable. +- [ ] After Task 6, `grep -rn "\.api_key" platform --include=*.py` shows no LLM-key reads in product code; native web search still detects the right provider for a `backend_route`-only node. +- [ ] After Task 8, the DB has no LLM `api_key` column; `alembic upgrade head` and `alembic heads` (single) are clean. +- [ ] Credentials UI exposes only git/gateway/tool; the workflow model picker lists agentgateway models; no frontend request hits a removed LLM endpoint. +- [ ] docker-compose (pipelit + plit): gateway healthy → workflow node mints a JWT and reaches the gateway; gateway down → containers fail fast. +- [ ] All four provider types (openai/anthropic/glm/openai_compatible) pass through agentgateway end-to-end. +``` From 9e73d00f2417d2ba3b1f8feb997b9ffaf619112c Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Sat, 4 Jul 2026 09:00:12 +0930 Subject: [PATCH 06/10] feat(cli): apply-fixture --backend-route sets the model node's agentgateway route Accept an optional --backend-route and store it on the default-agent ai_model node config, so pipelit's create_proxied_llm calls the agentgateway route plit init created instead of a mismatched fallback name (which 404'd). Additive and backward-compatible. Co-Authored-By: Claude Opus 4.8 --- platform/cli/__main__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/platform/cli/__main__.py b/platform/cli/__main__.py index 94122807..1b9be0b5 100644 --- a/platform/cli/__main__.py +++ b/platform/cli/__main__.py @@ -156,6 +156,8 @@ def cmd_apply_fixture(args: argparse.Namespace) -> None: component_type="ai_model", llm_credential_id=base_cred.id, model_name=args.model, + # Route pipelit's agentgateway calls to the route plit init created. + backend_route=getattr(args, "backend_route", None) or None, ) db.add(model_cfg) db.flush() @@ -485,6 +487,14 @@ def main() -> None: "don't break)", ) sp_fixture.add_argument("--base-url", default=None, help="LLM provider base URL") + sp_fixture.add_argument( + "--backend-route", + default=None, + help="agentgateway route name for the model node (must match the route " + "plit init creates, i.e. '-'). Stored on the " + "ai_model node's backend_route so pipelit's proxied LLM calls hit the " + "right gateway route.", + ) sp_import = sub.add_parser("import-fixture", help="Import a workflow from a fixture JSON file") sp_import.add_argument("file", help="Path to fixture JSON file") From a69d66ac1c3ddb5383cf932dc018a8aa0b226f49 Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Sat, 4 Jul 2026 14:40:43 +0930 Subject: [PATCH 07/10] test(cli): cover apply-fixture --backend-route Asserts the flag sets the ai_model node's backend_route (so pipelit's agentgateway call matches plit init's route), and defaults to None when omitted. Co-Authored-By: Claude Opus 4.8 --- platform/tests/test_cli.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/platform/tests/test_cli.py b/platform/tests/test_cli.py index d420b27e..0a2f5b56 100644 --- a/platform/tests/test_cli.py +++ b/platform/tests/test_cli.py @@ -282,3 +282,41 @@ def test_apply_fixture_deep_agent_config(self, cli_db, user_profile): ) cfg = cli_db.query(BaseComponentConfig).get(agent_node.component_config_id) assert cfg.extra_config.get("conversation_memory") is True + + def test_apply_fixture_backend_route(self, cli_db, user_profile): + _run_cli([ + "apply-fixture", "default-agent", + "--provider", "openai-compatible", "--model", "Qwen-AgentWorld-35B-A3B-bf16", + "--base-url", "http://192.168.0.73:8080/v1", + "--backend-route", "p_192_168_0_73-Qwen-AgentWorld-35B-A3B-bf16", + ]) + + from models.node import BaseComponentConfig, WorkflowNode + from models.workflow import Workflow + wf = cli_db.query(Workflow).filter(Workflow.slug == "default-agent").first() + model_node = ( + cli_db.query(WorkflowNode) + .filter(WorkflowNode.workflow_id == wf.id, WorkflowNode.node_id == "ai_model_1") + .first() + ) + cfg = cli_db.query(BaseComponentConfig).get(model_node.component_config_id) + # pipelit calls agentgateway at /{backend_route}/... — this must match the + # route plit init created, or the proxied LLM call 404s ("route not found"). + assert cfg.backend_route == "p_192_168_0_73-Qwen-AgentWorld-35B-A3B-bf16" + + def test_apply_fixture_backend_route_defaults_none(self, cli_db, user_profile): + _run_cli([ + "apply-fixture", "default-agent", + "--provider", "openai", "--model", "gpt-4o", + ]) + + from models.node import BaseComponentConfig, WorkflowNode + from models.workflow import Workflow + wf = cli_db.query(Workflow).filter(Workflow.slug == "default-agent").first() + model_node = ( + cli_db.query(WorkflowNode) + .filter(WorkflowNode.workflow_id == wf.id, WorkflowNode.node_id == "ai_model_1") + .first() + ) + cfg = cli_db.query(BaseComponentConfig).get(model_node.component_config_id) + assert cfg.backend_route is None From 71520db63c0dfa63f97479b657d7139085e1e3c2 Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Sat, 4 Jul 2026 23:53:11 +0930 Subject: [PATCH 08/10] fix(security): remove default-admin JWT escalation on LLM path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_llm_for_node never set a JWT role, so every workflow LLM call hit the `role = user_role or "admin"` fallback and minted an ADMIN token — bypassing the gateway's CEL rules. Root cause was a role-vocab mismatch (DB stores admin/normal; gateway rules accept admin/user), so admin was the only role that passed for workflow traffic. - add _jwt_role_for_gateway(): admin -> admin; everything else (normal, None, empty, unknown) -> least-privilege user - resolve_llm_for_node now looks up the workflow owner's role and plumbs it through; lookup failure degrades to user, never admin - invariant: no code path where a missing role yields an admin token - tests: least-privilege default, explicit-admin, unknown-role, owner-role threading, str-enum handling Co-Authored-By: Claude Opus 4.8 --- platform/services/jwt_issuer.py | 4 +- platform/services/llm.py | 36 ++++++++- platform/tests/test_llm_agentgateway.py | 102 ++++++++++++++++++++++-- 3 files changed, 134 insertions(+), 8 deletions(-) diff --git a/platform/services/jwt_issuer.py b/platform/services/jwt_issuer.py index 06c189d9..2d7ca236 100644 --- a/platform/services/jwt_issuer.py +++ b/platform/services/jwt_issuer.py @@ -30,7 +30,9 @@ def mint_llm_token( user_profile_id: ``UserProfile.id`` — becomes the ``sub`` claim (as string per JWT convention). role: - User role, e.g. ``"admin"`` or ``"normal"``. + JWT role claim checked by agentgateway's CEL rules — ``"admin"`` or + ``"user"`` (pipelit's DB role ``"normal"`` is mapped to ``"user"`` + by the caller; see ``services.llm._jwt_role_for_gateway``). credential_id: ``BaseCredential.id`` of the credential used for this request. allowed_credentials: diff --git a/platform/services/llm.py b/platform/services/llm.py index da12691f..13dd6aee 100644 --- a/platform/services/llm.py +++ b/platform/services/llm.py @@ -76,6 +76,23 @@ async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): return SanitizedChatOpenAI +def _jwt_role_for_gateway(user_role: str | None) -> str: + """Map a pipelit user role to the JWT ``role`` claim for agentgateway. + + agentgateway's CEL authorization rules (``config.d/rules/``, written by + ``plit init``) recognise exactly two roles: ``"admin"`` and ``"user"``. + Pipelit's DB roles are ``"admin"`` / ``"normal"`` (``models.user.UserRole``), + so ``"normal"`` maps to the gateway's ``"user"`` role. + + Security invariant: a missing or unrecognised role must NEVER escalate + to admin — it always maps to the least-privilege ``"user"`` role. Admin + tokens are minted only when the resolved role is explicitly ``"admin"``. + """ + if user_role == "admin": + return "admin" + return "user" + + def _route_provider_to_type(provider: str) -> str: """Map a route prefix to a provider_type string. @@ -246,9 +263,11 @@ def _create_llm_via_agentgateway( else: provider_type = "openai_compatible" + # SECURITY: never default to admin. An unresolvable role mints a + # least-privilege "user" token; admin requires an explicit role. jwt_token = mint_llm_token( user_profile_id=user_profile_id or 0, - role=user_role or "admin", + role=_jwt_role_for_gateway(user_role), credential_id=credential_id, ) @@ -290,7 +309,9 @@ def resolve_llm_for_node(node, db: Session | None = None) -> BaseChatModel: db = SessionLocal() # Derive user context from the workflow owner for JWT minting. - # Falls back to safe defaults when the relationship is not loaded. + # When the owner (or their role) cannot be resolved, user_role stays + # None which downstream maps to the least-privilege "user" JWT role — + # NEVER admin (see _jwt_role_for_gateway). user_profile_id: int | None = None user_role: str | None = None try: @@ -300,6 +321,17 @@ def resolve_llm_for_node(node, db: Session | None = None) -> BaseChatModel: except Exception: pass # Relationship not loaded — defaults will be used. + if user_profile_id is not None: + try: + from models.user import UserProfile + + owner = db.get(UserProfile, user_profile_id) + role_value = getattr(owner, "role", None) + if isinstance(role_value, str): + user_role = role_value + except Exception: + pass # Role unresolvable — least-privilege "user" token is minted. + try: cc = node.component_config backend_route = getattr(cc, "backend_route", None) diff --git a/platform/tests/test_llm_agentgateway.py b/platform/tests/test_llm_agentgateway.py index 1824de98..bdf0ef4b 100644 --- a/platform/tests/test_llm_agentgateway.py +++ b/platform/tests/test_llm_agentgateway.py @@ -167,9 +167,10 @@ def test_backend_route_none_falls_back_to_credential(self): def test_openai_routes_through_proxy(self): result, mock_mint, mock_proxy, _ = self._call("openai", temperature=0.5) + # DB role "normal" maps to the gateway's least-privilege "user" role. mock_mint.assert_called_once_with( user_profile_id=7, - role="normal", + role="user", credential_id=42, ) mock_proxy.assert_called_once() @@ -202,18 +203,31 @@ def test_openai_compatible_routes_through_proxy(self): assert kw["provider_type"] == "openai_compatible" assert kw["backend_name"] == "custom-42" - def test_user_context_defaults_when_not_provided(self): + def test_user_context_defaults_to_least_privilege(self): + """A missing role must NEVER mint an admin token (least privilege).""" _, mock_mint, _, _ = self._call( "openai", user_profile_id=None, user_role=None ) - # Should default to user_profile_id=0, role="admin" + # Defaults to user_profile_id=0 and least-privilege role="user". mock_mint.assert_called_once_with( user_profile_id=0, - role="admin", + role="user", credential_id=42, ) + def test_admin_role_only_when_explicit(self): + """Admin tokens require the role to be explicitly 'admin'.""" + _, mock_mint, _, _ = self._call("openai", user_role="admin") + + assert mock_mint.call_args.kwargs["role"] == "admin" + + def test_unknown_role_maps_to_least_privilege_user(self): + """Unrecognised role strings map to 'user', never 'admin'.""" + _, mock_mint, _, _ = self._call("openai", user_role="something-weird") + + assert mock_mint.call_args.kwargs["role"] == "user" + def test_all_kwargs_forwarded(self): _, _, mock_proxy, _ = self._call( "openai", @@ -257,7 +271,7 @@ def test_backend_route_with_credential_none_mints_jwt_with_zero_id(self): mock_mint.assert_called_once_with( user_profile_id=5, - role="normal", + role="user", credential_id=0, ) @@ -412,8 +426,48 @@ def test_threads_owner_id_from_workflow(self, mock_create): mock_create.assert_called_once() call_kwargs = mock_create.call_args assert call_kwargs.kwargs["user_profile_id"] == 42 + # Owner lookup on a MagicMock db returns a mock whose .role is not a + # str — role stays None (mapped downstream to least-privilege "user"). assert call_kwargs.kwargs["user_role"] is None + @patch("services.llm.create_llm_from_db") + def test_threads_owner_role_when_resolvable(self, mock_create): + """The workflow owner's DB role is threaded through as user_role.""" + from services.llm import resolve_llm_for_node + + mock_create.return_value = MagicMock(name="llm_instance") + + node = MagicMock() + node.node_id = "agent_abc" + node.workflow.owner_id = 42 + node.component_config.component_type = "ai_model" + node.component_config.model_name = "gpt-4o" + node.component_config.llm_credential_id = 10 + node.component_config.backend_route = None + node.component_config.temperature = None + node.component_config.max_tokens = None + node.component_config.frequency_penalty = None + node.component_config.presence_penalty = None + node.component_config.top_p = None + node.component_config.timeout = None + node.component_config.max_retries = None + node.component_config.response_format = None + + mock_db = MagicMock() + mock_owner = MagicMock() + mock_owner.role = "normal" + mock_db.get.return_value = mock_owner + mock_base_cred = MagicMock() + mock_base_cred.llm_credential = _make_credential("openai") + mock_db.query.return_value.filter.return_value.first.return_value = mock_base_cred + + resolve_llm_for_node(node, db=mock_db) + + mock_create.assert_called_once() + call_kwargs = mock_create.call_args + assert call_kwargs.kwargs["user_profile_id"] == 42 + assert call_kwargs.kwargs["user_role"] == "normal" + @patch("services.llm.create_llm_from_db") def test_defaults_when_workflow_not_loaded(self, mock_create): from services.llm import resolve_llm_for_node @@ -494,6 +548,44 @@ def test_threads_context_via_llm_model_config_path(self, mock_create): assert call_kwargs.kwargs["user_role"] is None +# --------------------------------------------------------------------------- +# _jwt_role_for_gateway +# --------------------------------------------------------------------------- + + +class TestJwtRoleForGateway: + """The role mapping must never escalate a missing role to admin.""" + + def test_admin_maps_to_admin(self): + from services.llm import _jwt_role_for_gateway + + assert _jwt_role_for_gateway("admin") == "admin" + + def test_normal_maps_to_user(self): + from services.llm import _jwt_role_for_gateway + + assert _jwt_role_for_gateway("normal") == "user" + + def test_none_maps_to_user_never_admin(self): + from services.llm import _jwt_role_for_gateway + + assert _jwt_role_for_gateway(None) == "user" + + def test_empty_and_unknown_map_to_user(self): + from services.llm import _jwt_role_for_gateway + + assert _jwt_role_for_gateway("") == "user" + assert _jwt_role_for_gateway("superuser") == "user" + + def test_enum_admin_maps_to_admin(self): + """UserRole is a str enum — enum instances must map correctly too.""" + from models.user import UserRole + from services.llm import _jwt_role_for_gateway + + assert _jwt_role_for_gateway(UserRole.ADMIN) == "admin" + assert _jwt_role_for_gateway(UserRole.NORMAL) == "user" + + # --------------------------------------------------------------------------- # _route_provider_to_type # --------------------------------------------------------------------------- From fb02b4b635a8e1f2c03bc8c40b56fec09c7c01b5 Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Sat, 4 Jul 2026 23:53:11 +0930 Subject: [PATCH 09/10] fix(agentgateway): treat 5xx as unhealthy in boot health check check_agentgateway_health returned True for ANY HTTP status, so a 500 from agentgateway passed the hard-boot dependency guard in main.py. - healthy = 2xx or 401/403 only (401/403 preserved as 'gateway up, rejecting unauthenticated probe'); 5xx and unexpected statuses -> unhealthy. 3xx now unhealthy too (httpx doesn't follow redirects). - tests: 500, 503, 404 unhealthy cases Co-Authored-By: Claude Opus 4.8 --- platform/services/agentgateway_client.py | 23 ++++++--- platform/tests/test_agentgateway_client.py | 54 ++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/platform/services/agentgateway_client.py b/platform/services/agentgateway_client.py index b0509862..e0ad6011 100644 --- a/platform/services/agentgateway_client.py +++ b/platform/services/agentgateway_client.py @@ -102,11 +102,14 @@ def create_proxied_llm( async def check_agentgateway_health(agentgateway_url: str) -> tuple[bool, str]: - """Check whether agentgateway is reachable. - - Returns ``(ok, message)`` where *ok* is ``True`` when the service - responds (even with 401/403 — that means JWT auth is enforced, which - is the expected healthy state). + """Check whether agentgateway is reachable and healthy. + + Returns ``(ok, message)``. *ok* is ``True`` only when the service + responds with a success status (2xx) or the expected auth-probe + statuses 401/403 — the probe is unauthenticated, so a 401/403 means + agentgateway is up and enforcing JWT auth, which is the expected + healthy state. Any other status (5xx, unexpected 4xx) is treated as + UNHEALTHY, as are connection errors and timeouts. """ try: async with httpx.AsyncClient(timeout=5.0) as client: @@ -114,7 +117,15 @@ async def check_agentgateway_health(agentgateway_url: str) -> tuple[bool, str]: # 401/403 means agentgateway is up and enforcing JWT auth — healthy. if resp.status_code in (401, 403): return True, "agentgateway is running (JWT auth enforced)" - return True, f"agentgateway responded with {resp.status_code}" + if 200 <= resp.status_code < 300: + return True, f"agentgateway responded with {resp.status_code}" + # Anything else (5xx, unexpected 4xx) is NOT healthy — a 500 must + # not pass the hard-boot health guard. + return ( + False, + f"agentgateway at {agentgateway_url} returned unexpected " + f"status {resp.status_code} — treating as unhealthy.", + ) except httpx.ConnectError: return ( False, diff --git a/platform/tests/test_agentgateway_client.py b/platform/tests/test_agentgateway_client.py index 89ed5554..d70da55c 100644 --- a/platform/tests/test_agentgateway_client.py +++ b/platform/tests/test_agentgateway_client.py @@ -320,6 +320,60 @@ async def test_healthy_200_response(self) -> None: assert ok is True assert "200" in msg + @pytest.mark.asyncio + async def test_unhealthy_500_response(self) -> None: + """5xx means agentgateway is broken — must NOT pass the health guard.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 500 + + with patch("services.agentgateway_client.httpx.AsyncClient") as MockClient: + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + MockClient.return_value = mock_client + + ok, msg = await check_agentgateway_health(AGENTGATEWAY_URL) + + assert ok is False + assert "500" in msg + + @pytest.mark.asyncio + async def test_unhealthy_503_response(self) -> None: + """503 (service unavailable) is unhealthy.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 503 + + with patch("services.agentgateway_client.httpx.AsyncClient") as MockClient: + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + MockClient.return_value = mock_client + + ok, msg = await check_agentgateway_health(AGENTGATEWAY_URL) + + assert ok is False + assert "503" in msg + + @pytest.mark.asyncio + async def test_unhealthy_unexpected_4xx_response(self) -> None: + """Unexpected 4xx (not the 401/403 auth probe) is unhealthy.""" + mock_resp = MagicMock(spec=httpx.Response) + mock_resp.status_code = 404 + + with patch("services.agentgateway_client.httpx.AsyncClient") as MockClient: + mock_client = AsyncMock() + mock_client.get.return_value = mock_resp + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + MockClient.return_value = mock_client + + ok, msg = await check_agentgateway_health(AGENTGATEWAY_URL) + + assert ok is False + assert "404" in msg + @pytest.mark.asyncio async def test_connection_error(self) -> None: """ConnectError means agentgateway is unreachable.""" From 6492eeb20ab175d2d715ce654c7979b4fabb7e7f Mon Sep 17 00:00:00 2001 From: Yao Yuan Date: Sun, 5 Jul 2026 08:21:21 +0930 Subject: [PATCH 10/10] test: fix test_routers_registered for lazy router inclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI 0.139 / Starlette 1.3 include routers lazily — app.routes holds unmaterialised _IncludedRouter stubs (no .path) until the route table is finalised on first request, so the old assertion saw zero /api/ routes and failed. Pre-existing latent failure, surfaced by this branch's first CI run; unrelated to the JWT/health/rate-limit changes. Assert against app.openapi()['paths'], which forces materialisation and reflects the real route table (72/74 paths under /api/). App itself was never broken — e2e-smoke exercises these routes live. Co-Authored-By: Claude Opus 4.8 --- platform/tests/test_app_import.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/platform/tests/test_app_import.py b/platform/tests/test_app_import.py index 9ebe8342..017c0554 100644 --- a/platform/tests/test_app_import.py +++ b/platform/tests/test_app_import.py @@ -28,9 +28,14 @@ async def _run(): @patch("main.engine") def test_routers_registered(self, mock_engine): from main import app - # Check some expected routes exist - route_paths = [r.path for r in app.routes if hasattr(r, "path")] - assert any("/api/" in p for p in route_paths) + # FastAPI >=0.139 / Starlette >=1.3 include routers lazily: the + # entries in app.routes are unmaterialised _IncludedRouter stubs + # (no .path) until the route table is finalised on first request. + # Inspecting app.routes directly therefore misses every /api/ route. + # app.openapi() forces materialisation and reflects the real route + # table — which is what this test actually means to assert. + api_paths = list(app.openapi().get("paths", {}).keys()) + assert any("/api/" in p for p in api_paths) class TestLifespanSkillsDir: