From 3e67b658a7090a611e560efe69eef99ac4fdd9c8 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Tue, 23 Jun 2026 11:05:04 +0800 Subject: [PATCH 1/3] chore: prune crawler/rag standalone-service references and fix fallout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RAG/crawler and search no longer run as standalone HTTP services — that logic is in-process in the Convex backend, which talks to the knowledge DB directly. Remove the now-dangling references across config, docs, and code, and fix the two latent bugs the leftover wiring caused: - status-probe probed dead rag:8001 / crawler:8002 endpoints and so reported a permanent 'degraded' status; probe only Convex liveness now. - the single-node CLI stack had no 'knowledge-db' host, so the in-process RAG/crawler code couldn't resolve its corpus on 'tale start'/'tale deploy'; add a 'knowledge-db' network alias on the db service. Also drops the removed 'rag'/'crawler' commitlint scopes, the rag entry from the controller restart allowlist, and the obsolete RAG_URL / CRAWLER_URL / SEARCH_SERVICE_URL env defaults. --- .commitlintrc.json | 2 - .env.test | 13 +- .github/CONTRIBUTING.md | 2 +- README.de.md | 6 +- README.fr.md | 6 +- README.md | 6 +- compose.web.yml | 2 +- compose.yml | 3 +- .../operate/release-notes/format.md | 2 +- .../operate/release-notes/format.md | 2 +- .../operate/release-notes/format.md | 2 +- packages/shared/src/config/base.ts | 2 +- services/controller/README.md | 2 +- services/controller/src/server.ts | 12 +- services/platform/README.md | 5 +- .../sandbox/helpers/spawner_client.ts | 6 +- services/platform/env.sh | 7 +- .../platform/lib/shared/schemas/providers.ts | 4 +- services/platform/server.test.ts | 4 +- services/platform/status-probe.test.ts | 217 ++++-------------- services/platform/status-probe.ts | 39 ++-- services/proxy/README.md | 2 +- .../services/create-db-service.test.ts | 15 ++ .../lib/compose/services/create-db-service.ts | 13 +- tools/opengrep/config.yml | 2 +- 25 files changed, 135 insertions(+), 241 deletions(-) diff --git a/.commitlintrc.json b/.commitlintrc.json index c676e257eb..39b8eebfdb 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -8,7 +8,6 @@ "cli", "controller", "convex", - "crawler", "db", "deps", "design", @@ -17,7 +16,6 @@ "plop", "pii", "proxy", - "rag", "sandbox", "storybook", "ui", diff --git a/.env.test b/.env.test index 5b91590027..3b59156766 100644 --- a/.env.test +++ b/.env.test @@ -23,16 +23,15 @@ OPENAI_FAST_MODEL=test-model OPENAI_CODING_MODEL=test-model OPENAI_EMBEDDING_MODEL=test-model OPENAI_VISION_MODEL=test-model -EMBEDDING_DIMENSIONS=1536 -CRAWLER_EMBEDDING_DIMENSIONS=1536 # Database - DB_ vars are used by the db entrypoint wrapper to set POSTGRES_*. # DB_NAME is intentionally NOT set here: the db Dockerfile defaults it to `tale` -# for the postgres server, while rag/crawler entrypoints fall through to their -# own default (`tale_knowledge`) — the database where init-scripts/03 installs -# the `vector` and `pg_search` extensions. Setting DB_NAME globally via env_file -# would leak `tale` into rag/crawler and race with ParadeDB's bootstrap loading -# `vector` into `tale`, which fails on slower runners (e.g. ubuntu-latest). +# for the platform postgres server, while the knowledge-db role falls through to +# its own default (`tale_knowledge`) — the database where init-scripts/03 +# installs the `vector` and `pg_search` extensions. Setting DB_NAME globally via +# env_file would leak `tale` into the knowledge DB and race with ParadeDB's +# bootstrap loading `vector` into `tale`, which fails on slower runners +# (e.g. ubuntu-latest). DB_PASSWORD=test_password_e2e DB_USER=tale diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a14772507b..f82a8645f5 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -17,7 +17,7 @@ bun run dev # boot Convex + Vite (wait for the READY banner) You do **not** need Docker for source development; `bun run dev` runs Convex directly. The `web` and `docs` sites need neither Docker nor Convex — run just one with `bun run --filter @tale/web dev` (or `@tale/docs`). The full guide, -including port conflicts, hybrid Convex mode, and the Python services, is +including port conflicts and hybrid Convex mode, is [Contributor setup](../docs/en/develop/contributor-setup.md). ## Before you open a PR diff --git a/README.de.md b/README.de.md index ef5f929866..15412a7c1f 100644 --- a/README.de.md +++ b/README.de.md @@ -140,7 +140,7 @@ Für lokale Entwicklung (ohne Docker): ### Voraussetzungen - **Bun**: 1.3.x oder höher ([Installationsanleitung](https://bun.sh/docs/installation)) -- **Python**: 3.12.x (für die Python-Dienste rag und crawler) +- **Python**: 3.12.x (für die mitgelieferten Python-Skill-Skripte, z. B. den PPTX-Skill) - **uv**: Python-Paketmanager ([Installationsanleitung](https://github.com/astral-sh/uv)) ### Entwicklungs-Befehle @@ -225,7 +225,7 @@ Doku-Seite und Plattform-UI laufen in drei Basis-Sprachen (`en`, `de`, `fr`) plu
Für Developer -- **[API-Referenz](docs/de/develop/api-reference.md)** — REST-API für RAG, Crawler und Platform +- **[API-Referenz](docs/de/develop/api-reference.md)** — REST-API für Agenten, Chat, Wissen und Workflows - **[Webhooks](docs/de/develop/webhooks.md)** — Workflow- und Agent-Webhooks mit Signaturprüfung - **[Develop-Übersicht](docs/de/develop/overview.md)** — die Entwickler-Oberfläche von Anfang bis Ende @@ -241,7 +241,7 @@ Doku-Seite und Plattform-UI laufen in drei Basis-Sprachen (`en`, `de`, `fr`) plu ## Mitwirken -Neu im Repo? [Contributor-Setup](docs/de/develop/contributor-setup.md) ist die zentrale Quelle der Wahrheit, um den Quellcode lokal zum Laufen zu bringen — Voraussetzungen, `bun install`, der `bun run setup:check`-Pre-flight, `bun run dev` und die Python-Dienste. Lies [`AGENTS.md`](AGENTS.md) vor deinem ersten PR — das ist der einzige Vertrag für Code-Stil, Security, Tests, i18n und Dokumentation über alle Workspaces hinweg. Der [`docs`](.agents/docs/AGENTS.md)-Skill deckt die Doku-Seite ab; der [`translation`](.agents/translation/AGENTS.md)-Skill die sprachübergreifenden Übersetzungsregeln. Lass `bun run check` (Format, Lint, Typecheck, Tests) durchlaufen, bevor du einen PR öffnest; das [Pull-Request-Template](.github/pull_request_template.md) listet den Rest der Pre-Merge-Checkliste. +Neu im Repo? [Contributor-Setup](docs/de/develop/contributor-setup.md) ist die zentrale Quelle der Wahrheit, um den Quellcode lokal zum Laufen zu bringen — Voraussetzungen, `bun install`, der `bun run setup:check`-Pre-flight und `bun run dev`. Lies [`AGENTS.md`](AGENTS.md) vor deinem ersten PR — das ist der einzige Vertrag für Code-Stil, Security, Tests, i18n und Dokumentation über alle Workspaces hinweg. Der [`docs`](.agents/docs/AGENTS.md)-Skill deckt die Doku-Seite ab; der [`translation`](.agents/translation/AGENTS.md)-Skill die sprachübergreifenden Übersetzungsregeln. Lass `bun run check` (Format, Lint, Typecheck, Tests) durchlaufen, bevor du einen PR öffnest; das [Pull-Request-Template](.github/pull_request_template.md) listet den Rest der Pre-Merge-Checkliste. --- diff --git a/README.fr.md b/README.fr.md index 163c852b01..705f74e748 100644 --- a/README.fr.md +++ b/README.fr.md @@ -140,7 +140,7 @@ Pour le développement local (hors Docker) : ### Prérequis - **Bun** : 1.3.x ou supérieur ([instructions d'installation](https://bun.sh/docs/installation)) -- **Python** : 3.12.x (requis pour les services Python : rag, crawler) +- **Python** : 3.12.x (pour les scripts Python des skills fournis, p. ex. le skill PPTX) - **uv** : gestionnaire de paquets Python ([instructions d'installation](https://github.com/astral-sh/uv)) ### Commandes de développement @@ -225,7 +225,7 @@ Le site de doc et l'UI de la plateforme tournent en trois langues de base (`en`,
Pour les développeurs -- **[Référence API](docs/fr/develop/api-reference.md)** — API REST pour RAG, Crawler et Platform +- **[Référence API](docs/fr/develop/api-reference.md)** — API REST pour les agents, le chat, les connaissances et les workflows - **[Webhooks](docs/fr/develop/webhooks.md)** — webhooks de workflows et d'agents avec vérification de signature - **[Aperçu développeur](docs/fr/develop/overview.md)** — la surface développeur de bout en bout @@ -241,7 +241,7 @@ Le site de doc et l'UI de la plateforme tournent en trois langues de base (`en`, ## Contribuer -Nouveau dans le dépôt ? [Configuration contributeur](docs/fr/develop/contributor-setup.md) est la source unique de vérité pour faire tourner le code source en local — prérequis, `bun install`, le pré-vol `bun run setup:check`, `bun run dev` et les services Python. Lis [`AGENTS.md`](AGENTS.md) avant ton premier PR — c'est le contrat unique pour le style de code, la sécurité, les tests, l'i18n et la documentation à travers tous les workspaces. Le skill [`docs`](.agents/docs/AGENTS.md) couvre le site de doc ; le skill [`translation`](.agents/translation/AGENTS.md) les règles de traduction inter-langues. Lance `bun run check` (format, lint, typecheck, tests) avant d'ouvrir un PR ; le [pull request template](.github/pull_request_template.md) liste le reste de la checklist pre-merge. +Nouveau dans le dépôt ? [Configuration contributeur](docs/fr/develop/contributor-setup.md) est la source unique de vérité pour faire tourner le code source en local — prérequis, `bun install`, le pré-vol `bun run setup:check` et `bun run dev`. Lis [`AGENTS.md`](AGENTS.md) avant ton premier PR — c'est le contrat unique pour le style de code, la sécurité, les tests, l'i18n et la documentation à travers tous les workspaces. Le skill [`docs`](.agents/docs/AGENTS.md) couvre le site de doc ; le skill [`translation`](.agents/translation/AGENTS.md) les règles de traduction inter-langues. Lance `bun run check` (format, lint, typecheck, tests) avant d'ouvrir un PR ; le [pull request template](.github/pull_request_template.md) liste le reste de la checklist pre-merge. --- diff --git a/README.md b/README.md index feab2bdeee..817dabc48d 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ For local development (non-Docker): ### Prerequisites - **Bun**: 1.3.x or higher ([installation instructions](https://bun.sh/docs/installation)) -- **Python**: 3.12.x (required for Python services: rag, crawler) +- **Python**: 3.12.x (for the bundled Python skill scripts, e.g. the PPTX skill) - **uv**: Python package manager ([installation instructions](https://github.com/astral-sh/uv)) ### Development commands @@ -225,7 +225,7 @@ The docs site and platform UI both ship three base locales (`en`, `de`, `fr`) pl
For developers -- **[API reference](docs/en/develop/api-reference.md)** — REST API for RAG, Crawler, and Platform +- **[API reference](docs/en/develop/api-reference.md)** — REST API for agents, chat, knowledge, and workflows - **[Webhooks](docs/en/develop/webhooks.md)** — workflow and agent webhooks with signature verification - **[Develop overview](docs/en/develop/overview.md)** — the developer surface end to end @@ -241,7 +241,7 @@ The docs site and platform UI both ship three base locales (`en`, `de`, `fr`) pl ## Contributing -New to the repo? [Contributor setup](docs/en/develop/contributor-setup.md) is the single source of truth for getting the source running locally — prerequisites, `bun install`, the `bun run setup:check` pre-flight, `bun run dev`, and the Python services. Read [`AGENTS.md`](AGENTS.md) before your first PR — it is the single contract for code style, security, testing, i18n, and documentation across every workspace. The [`docs`](.agents/docs/AGENTS.md) skill covers the documentation site; the [`translation`](.agents/translation/AGENTS.md) skill covers cross-locale translation rules. Run `bun run check` (format, lint, typecheck, tests) before opening a PR; the [pull request template](.github/pull_request_template.md) lists the rest of the pre-merge checklist. +New to the repo? [Contributor setup](docs/en/develop/contributor-setup.md) is the single source of truth for getting the source running locally — prerequisites, `bun install`, the `bun run setup:check` pre-flight, and `bun run dev`. Read [`AGENTS.md`](AGENTS.md) before your first PR — it is the single contract for code style, security, testing, i18n, and documentation across every workspace. The [`docs`](.agents/docs/AGENTS.md) skill covers the documentation site; the [`translation`](.agents/translation/AGENTS.md) skill covers cross-locale translation rules. Run `bun run check` (format, lint, typecheck, tests) before opening a PR; the [pull request template](.github/pull_request_template.md) lists the rest of the pre-merge checklist. --- diff --git a/compose.web.yml b/compose.web.yml index 3f85332956..b065042ce7 100644 --- a/compose.web.yml +++ b/compose.web.yml @@ -2,7 +2,7 @@ # Tale Web (Marketing site) — Standalone Docker Compose # ============================================================================= # The marketing site at www.tale.dev runs independently of the platform stack -# (services/platform, convex, db, rag, crawler, proxy) and has its own +# (services/platform, convex, db, knowledge-db, proxy, sandbox) and has its own # environment file at services/web/.env.example. # # Usage: diff --git a/compose.yml b/compose.yml index 98b8611a41..2d2360d66a 100644 --- a/compose.yml +++ b/compose.yml @@ -571,7 +571,8 @@ services: # so the spawner and the daemon must agree on the path). - /var/lib/tale-sandbox:/var/lib/tale-sandbox # Read-only deployment config so loadConfig can read the sandboxRuntime - # tier from deployment.json (same convex-data volume as rag/platform). + # tier from deployment.json (same convex-data volume the convex/platform + # services share). - ${PLATFORM_SHARED_CONFIG:-convex-data}:/app/platform-config:ro restart: unless-stopped # Resource caps mirror the CLI compose generator diff --git a/docs/de/self-hosted/operate/release-notes/format.md b/docs/de/self-hosted/operate/release-notes/format.md index b0b36605e9..7a73a7c6ec 100644 --- a/docs/de/self-hosted/operate/release-notes/format.md +++ b/docs/de/self-hosted/operate/release-notes/format.md @@ -25,7 +25,7 @@ Jede Release-Seite ist dieselbe geordnete Abschnitts-Liste. Leere Abschnitte wer - **Breaking Changes** — jede Änderung, die verlangt, dass der Operator vor oder nach dem Upgrade etwas tut. Jede Zeile nennt das Symptom, das du treffen würdest, wenn du sie überspringst, und die Aktion, die das vermeidet. - **Deprecations** — Features, die in diesem Release noch laufen, aber zur Entfernung markiert sind. Jede Zeile nennt die Removal-Version, damit du den Cutover planen kannst. - **Security** — Einträge im CVE-Format für Fixes, die eine Schwachstelle schließen. Der vollständige Feed lebt unter [Security-Advisories](/de/self-hosted/operate/security/advisories); die Release-Notes tragen die Ein-Zeilen-Zusammenfassung plus den Link auf das Advisory. -- **Features und Fixes** — die lange Liste. Gruppiert nach Bereich (Platform, RAG, Crawler, CLI, Docs); jede Zeile liest sich als ein Satz. +- **Features und Fixes** — die lange Liste. Gruppiert nach Bereich (Platform, CLI, Docs); jede Zeile liest sich als ein Satz. - **Migrations-Notes** _(Major-Versionen und manche Minors)_ — der verlinkte Walk durch Schema-Migrationen, Config-Datei-Änderungen oder operatorseitige Umbenennungen. Bei Majors immer lesen. ## Wie du ein Release scannst diff --git a/docs/en/self-hosted/operate/release-notes/format.md b/docs/en/self-hosted/operate/release-notes/format.md index 05543e8c17..6b80918380 100644 --- a/docs/en/self-hosted/operate/release-notes/format.md +++ b/docs/en/self-hosted/operate/release-notes/format.md @@ -25,7 +25,7 @@ Each release page is the same ordered list of sections. Empty sections are omitt - **Breaking changes** — every change that requires the operator to do something before or after the upgrade. Each row names the symptom you would hit if you skipped, and the action that avoids it. - **Deprecations** — features still working in this release but flagged for removal. Each row names the removal version so you can plan the cutover. - **Security** — CVE-format entries for fixes that close a vulnerability. The full feed lives under [Security advisories](/self-hosted/operate/security/advisories); the release notes carry the one-line summary plus the advisory link. -- **Features and fixes** — the long list. Grouped by area (Platform, RAG, Crawler, CLI, Docs); each row reads as one sentence. +- **Features and fixes** — the long list. Grouped by area (Platform, CLI, Docs); each row reads as one sentence. - **Migration notes** _(major versions and some minors)_ — the linked walk through schema migrations, config-file changes, or operator-facing renames. Always read for majors. ## How to scan a release diff --git a/docs/fr/self-hosted/operate/release-notes/format.md b/docs/fr/self-hosted/operate/release-notes/format.md index 760b61c0fc..ff8c03b8ee 100644 --- a/docs/fr/self-hosted/operate/release-notes/format.md +++ b/docs/fr/self-hosted/operate/release-notes/format.md @@ -25,7 +25,7 @@ Chaque page de release est la même liste ordonnée de sections. Les sections vi - **Changements breaking** — chaque changement qui demande à l'opérateur de faire quelque chose avant ou après la montée de version. Chaque ligne nomme le symptôme que tu rencontrerais si tu sautais, et l'action qui l'évite. - **Obsolescences** — fonctionnalités qui marchent encore dans cette release mais marquées pour suppression. Chaque ligne nomme la version de suppression pour que tu planifies la bascule. - **Sécurité** — entrées au format CVE pour les fixes qui ferment une vulnérabilité. Le flux complet vit sous [Avis de sécurité](/fr/self-hosted/operate/security/advisories) ; les notes de version portent le résumé d'une ligne plus le lien vers l'avis. -- **Fonctionnalités et corrections** — la longue liste. Groupée par domaine (Platform, RAG, Crawler, CLI, Docs) ; chaque ligne se lit comme une phrase. +- **Fonctionnalités et corrections** — la longue liste. Groupée par domaine (Platform, CLI, Docs) ; chaque ligne se lit comme une phrase. - **Notes de migration** _(versions majeures et certaines mineures)_ — le parcours lié à travers les migrations de schéma, les changements de fichier de config ou les renommages côté opérateur. À lire systématiquement pour les majeures. ## Comment scanner une release diff --git a/packages/shared/src/config/base.ts b/packages/shared/src/config/base.ts index 056d7d944f..713b0f3939 100644 --- a/packages/shared/src/config/base.ts +++ b/packages/shared/src/config/base.ts @@ -41,7 +41,7 @@ const stringWithDefault = (fallback: string) => /** * The shared base settings schema. Services compose it with `.extend(...)`. * Field names use snake_case env-style keys so the same `process.env` mapping - * works as in the Python services. + * works consistently across services. */ export const baseServiceSettingsSchema = z.object({ host: stringWithDefault('0.0.0.0'), diff --git a/services/controller/README.md b/services/controller/README.md index ad38ae9dc4..21e05e5abb 100644 --- a/services/controller/README.md +++ b/services/controller/README.md @@ -8,7 +8,7 @@ It mounts `/var/run/docker.sock` (host root — the same accepted threat boundar as the sandbox spawner) but is far more constrained: - **HMAC-signed requests only** (timestamp + nonce replay guard). -- **Hard service allowlist** — `{rag, convex, sandbox}`. +- **Hard service allowlist** — `{convex, sandbox}`. - **list + restart only** — never `run`/`exec`. - Reachable only on the internal network. diff --git a/services/controller/src/server.ts b/services/controller/src/server.ts index 6d597dc9b5..9fc0874a0f 100644 --- a/services/controller/src/server.ts +++ b/services/controller/src/server.ts @@ -5,7 +5,7 @@ // deployment-config change (external knowledge Postgres / Convex S3 storage) // takes effect. It mounts the docker socket (host root — same accepted threat // boundary as the sandbox spawner) but is far more constrained: HMAC-signed -// requests only, a hard service allowlist of {rag, convex, sandbox}, and only +// requests only, a hard service allowlist of {convex, sandbox}, and only // list+restart (no run/exec). Reachable only on the internal network. import { SIGNATURE_HEADER, TIMESTAMP_HEADER, verify } from './auth.ts'; @@ -26,8 +26,10 @@ const PROJECT = /** Hard allowlist — the only services this control plane may ever restart. * `sandbox` is here so a sandboxRuntime tier change in deployment.json takes - * effect on apply-and-restart (the spawner reads it at boot). */ -const ALLOWED = new Set(['rag', 'convex', 'sandbox']); + * effect on apply-and-restart (the spawner reads it at boot). RAG/crawler used + * to be here too, but that logic is now in-process in `convex` — a knowledge + * config change restarts `convex`, not a standalone service. */ +const ALLOWED = new Set(['convex', 'sandbox']); // Delay before bouncing the caller's own container (`convex`) so the signed // HTTP response is flushed and the Convex action can return its result first. @@ -139,7 +141,7 @@ Bun.serve({ return Response.json( { ok: false, - error: `services must be a non-empty subset of {rag, convex, sandbox}${ + error: `services must be a non-empty subset of {convex, sandbox}${ invalid.length ? `; rejected: ${invalid.join(', ')}` : '' }`, }, @@ -197,5 +199,5 @@ Bun.serve({ }); console.log( - `[controller] listening on :${PORT} — allowlist {rag, convex, sandbox}, project=${PROJECT ?? '(any)'}`, + `[controller] listening on :${PORT} — allowlist {convex, sandbox}, project=${PROJECT ?? '(any)'}`, ); diff --git a/services/platform/README.md b/services/platform/README.md index 63cb96a01d..371d0a5836 100644 --- a/services/platform/README.md +++ b/services/platform/README.md @@ -22,7 +22,8 @@ Notable variables (canonical list in `compose.yml`, which is local-dev only — - `HOST`, `PORT`, `LOG_LEVEL` - `CONVEX_URL`, `CONVEX_DEPLOY_KEY` — point at the `convex` service -- `DB_URL`, `RAG_URL`, `CRAWLER_URL` — internal DNS to sibling services +- `SANDBOX_URL` — internal DNS to the sandbox spawner +- `KNOWLEDGE_DATABASE_URL` — knowledge corpus (ParadeDB) used by the in-process RAG/crawler path - `INSTANCE_NAME`, `INSTANCE_SECRET` — used when generating Convex admin keys ## Development @@ -35,7 +36,7 @@ CONVEX_EXTERNAL=true bun run dev # connects Vite to the convex container (do bun run check # format + lint + typecheck + tests ``` -For prerequisites, the pre-flight check, port-conflict handling, and the Python services, see the [contributor setup guide](../../docs/en/develop/contributor-setup.md). +For prerequisites, the pre-flight check, and port-conflict handling, see the [contributor setup guide](../../docs/en/develop/contributor-setup.md). ## Layout diff --git a/services/platform/convex/node_only/sandbox/helpers/spawner_client.ts b/services/platform/convex/node_only/sandbox/helpers/spawner_client.ts index d4dd455686..019f61bffc 100644 --- a/services/platform/convex/node_only/sandbox/helpers/spawner_client.ts +++ b/services/platform/convex/node_only/sandbox/helpers/spawner_client.ts @@ -235,9 +235,9 @@ function signRequest( } function spawnerBaseUrl(): string { - // Mirrors RAG_URL / CRAWLER_URL convention: default to host loopback - // so `bun dev`'s local convex-local-backend (running on the host) can - // reach the spawner via the published port. Docker compose sets + // Default to host loopback so `bun dev`'s local convex-local-backend + // (running on the host) can reach the spawner via the published port. + // Docker compose sets // SANDBOX_URL=http://sandbox:8003 on the tale-convex container so the // dockerized convex resolves through Docker DNS instead. In blue-green // mode `sandbox` is the bare alias that the deploy flip points at the diff --git a/services/platform/env.sh b/services/platform/env.sh index 94972118f9..c27c81f30f 100644 --- a/services/platform/env.sh +++ b/services/platform/env.sh @@ -44,10 +44,11 @@ env_normalize_common() { # Cross-service URLs (inside Docker) # These defaults use Docker service names for inter-service communication. # They can be overridden via environment variables in .env when needed. - export RAG_URL="${RAG_URL:-http://rag:8001}" - export CRAWLER_URL="${CRAWLER_URL:-http://crawler:8002}" + # NOTE: RAG, crawler and search no longer run as standalone HTTP services — + # that logic is in-process in the Convex backend, which talks to the + # knowledge DB directly (KNOWLEDGE_DATABASE_URL / RAG_DATABASE_URL). So + # there is no RAG_URL / CRAWLER_URL / SEARCH_SERVICE_URL service to point at. export SANDBOX_URL="${SANDBOX_URL:-http://sandbox:8003}" - export SEARCH_SERVICE_URL="${SEARCH_SERVICE_URL:-http://search:8080}" # Convex instance configuration # INSTANCE_NAME is hardcoded to tale_platform for safety and consistency diff --git a/services/platform/lib/shared/schemas/providers.ts b/services/platform/lib/shared/schemas/providers.ts index 23591d33d5..117e4bfa28 100644 --- a/services/platform/lib/shared/schemas/providers.ts +++ b/services/platform/lib/shared/schemas/providers.ts @@ -397,8 +397,8 @@ export const SECRETS_ENV_REGEX = /^TALE_PROVIDER_KEY_[A-Za-z0-9_]+$/; * Lives in the PUBLIC provider config (a var name is not a secret). The * resolution path prefers this over the file `apiKey`. The name must start with * `SECRETS_ENV_PREFIX` (see above). The 40-char cap matches the platform→Convex - * env-name sync limit in `docker-entrypoint.sh` — a longer name would resolve - * in the Python services but silently never reach the Node action chat path. + * env-name sync limit in `docker-entrypoint.sh` — a longer name would silently + * never reach the Node action chat path. */ const secretsEnvSchema = z .string() diff --git a/services/platform/server.test.ts b/services/platform/server.test.ts index c9c25debe0..4771aa51f0 100644 --- a/services/platform/server.test.ts +++ b/services/platform/server.test.ts @@ -347,13 +347,13 @@ describe('GET /status.json', () => { expect(body).toMatchObject({ status: expect.stringMatching(/^(operational|degraded|outage)$/), checkedAt: expect.any(String), + // RAG + crawler are in-process in Convex now, so the only probed + // backend is the Convex application. components: expect.arrayContaining([ expect.objectContaining({ id: 'convex', status: expect.stringMatching(/^(operational|outage)$/), }), - expect.objectContaining({ id: 'rag' }), - expect.objectContaining({ id: 'crawler' }), ]), }); }); diff --git a/services/platform/status-probe.test.ts b/services/platform/status-probe.test.ts index 8ceb9aa8eb..ae5f1b54f7 100644 --- a/services/platform/status-probe.test.ts +++ b/services/platform/status-probe.test.ts @@ -20,20 +20,16 @@ function downResponse() { return new Response('boom', { status: 503 }); } +// RAG + crawler now run in-process inside Convex, so the only backend the +// platform server can reach is Convex ("Application"). The probe set is a +// single component; the wider OverallStatus vocabulary is kept for a future +// per-subsystem probe. function allUpComponents(): ComponentResult[] { - return [ - { id: 'convex', up: true }, - { id: 'rag', up: true }, - { id: 'crawler', up: true }, - ]; + return [{ id: 'convex', up: true }]; } function allOperationalFeedComponents(): StatusFeedComponent[] { - return [ - { id: 'convex', status: 'operational' }, - { id: 'rag', status: 'operational' }, - { id: 'crawler', status: 'operational' }, - ]; + return [{ id: 'convex', status: 'operational' }]; } afterEach(() => { @@ -42,37 +38,21 @@ afterEach(() => { }); describe('probeServices', () => { - test('returns operational with all components up when every probe returns 2xx', async () => { + test('returns operational with the application up when the probe returns 2xx', async () => { const doFetch = vi.fn(() => Promise.resolve(okResponse())); const result = await probeServices(doFetch as unknown as typeof fetch); expect(result.overall).toBe('operational'); - expect(result.components.map((c) => c.id)).toEqual([ - 'convex', - 'rag', - 'crawler', - ]); + expect(result.components.map((c) => c.id)).toEqual(['convex']); expect(result.components.every((c) => c.up)).toBe(true); - expect(doFetch).toHaveBeenCalledTimes(3); + // One backend probe (Convex /version) — rag/crawler are in-process. + expect(doFetch).toHaveBeenCalledTimes(1); }); - test('returns degraded with the failing component marked down', async () => { - // Match RAG by port (8001) rather than substring — RAG_URL defaults to - // http://localhost:8001 in dev, which has no 'rag' substring. - const doFetch = vi.fn((url: string) => - Promise.resolve(url.includes(':8001') ? downResponse() : okResponse()), - ); - const result = await probeServices(doFetch as unknown as typeof fetch); - expect(result.overall).toBe('degraded'); - expect(result.components.find((c) => c.id === 'rag')?.up).toBe(false); - expect(result.components.find((c) => c.id === 'convex')?.up).toBe(true); - expect(result.components.find((c) => c.id === 'crawler')?.up).toBe(true); - }); - - test('returns outage with every component marked down when every probe fails', async () => { + test('returns outage with the application down when the probe returns non-2xx', async () => { const doFetch = vi.fn(() => Promise.resolve(downResponse())); const result = await probeServices(doFetch as unknown as typeof fetch); expect(result.overall).toBe('outage'); - expect(result.components.every((c) => !c.up)).toBe(true); + expect(result.components.find((c) => c.id === 'convex')?.up).toBe(false); }); test('treats fetch rejection (timeout, ECONNREFUSED) as down', async () => { @@ -100,11 +80,11 @@ describe('probeServices', () => { const clock = () => now; await probeServices(doFetch as unknown as typeof fetch, clock); - expect(doFetch).toHaveBeenCalledTimes(3); + expect(doFetch).toHaveBeenCalledTimes(1); now = 2000; // 1s later — still inside the 5s TTL await probeServices(doFetch as unknown as typeof fetch, clock); - expect(doFetch).toHaveBeenCalledTimes(3); + expect(doFetch).toHaveBeenCalledTimes(1); }); test('re-probes after TTL expires', async () => { @@ -113,11 +93,11 @@ describe('probeServices', () => { const clock = () => now; await probeServices(doFetch as unknown as typeof fetch, clock); - expect(doFetch).toHaveBeenCalledTimes(3); + expect(doFetch).toHaveBeenCalledTimes(1); now = 7000; // 6s later — past the 5s TTL await probeServices(doFetch as unknown as typeof fetch, clock); - expect(doFetch).toHaveBeenCalledTimes(6); + expect(doFetch).toHaveBeenCalledTimes(2); }); test('caches success and failure independently — recovery after TTL', async () => { @@ -157,8 +137,8 @@ describe('probeServices', () => { const c = probeServices(doFetch as unknown as typeof fetch); // All three callers should be waiting on the same probe round — - // exactly 3 fetches (one per backend), not 9. - expect(doFetch).toHaveBeenCalledTimes(3); + // exactly one fetch (the single backend), not three. + expect(doFetch).toHaveBeenCalledTimes(1); for (const r of resolvers) r(okResponse()); const [ra, rb, rc] = await Promise.all([a, b, c]); @@ -170,7 +150,7 @@ describe('probeServices', () => { describe('buildStatusFeed', () => { const checkedAt = '2026-05-11T13:45:07.123Z'; - test('all up → operational, each component operational', () => { + test('up → operational, component operational', () => { const raw: StatusResult = { overall: 'operational', components: allUpComponents(), @@ -183,37 +163,17 @@ describe('buildStatusFeed', () => { }); }); - test('one down → degraded overall, that component outage', () => { - const raw: StatusResult = { - overall: 'degraded', - components: [ - { id: 'convex', up: true }, - { id: 'rag', up: false }, - { id: 'crawler', up: true }, - ], - checkedAt, - }; - const feed = buildStatusFeed(raw); - expect(feed.status).toBe('degraded'); - expect(feed.components.find((c) => c.id === 'rag')?.status).toBe('outage'); - expect(feed.components.find((c) => c.id === 'convex')?.status).toBe( - 'operational', - ); - }); - - test('all down → outage overall, every component outage', () => { + test('down → outage overall, component outage', () => { const raw: StatusResult = { overall: 'outage', - components: [ - { id: 'convex', up: false }, - { id: 'rag', up: false }, - { id: 'crawler', up: false }, - ], + components: [{ id: 'convex', up: false }], checkedAt, }; const feed = buildStatusFeed(raw); expect(feed.status).toBe('outage'); - expect(feed.components.every((c) => c.status === 'outage')).toBe(true); + expect(feed.components.find((c) => c.id === 'convex')?.status).toBe( + 'outage', + ); }); }); @@ -234,31 +194,11 @@ describe('renderStatusJson', () => { expect(raw).toContain('"status":"operational"'); }); - test('serialises a degraded feed with mixed component statuses', () => { - const feed: StatusFeed = { - status: 'degraded', - checkedAt, - components: [ - { id: 'convex', status: 'operational' }, - { id: 'rag', status: 'outage' }, - { id: 'crawler', status: 'operational' }, - ], - }; - const raw = renderStatusJson(feed); - expect(JSON.parse(raw)).toEqual(feed); - expect(raw).toContain('"status":"degraded"'); - expect(raw).toContain('"status":"outage"'); - }); - - test('serialises a full outage feed', () => { + test('serialises an outage feed', () => { const feed: StatusFeed = { status: 'outage', checkedAt, - components: [ - { id: 'convex', status: 'outage' }, - { id: 'rag', status: 'outage' }, - { id: 'crawler', status: 'outage' }, - ], + components: [{ id: 'convex', status: 'outage' }], }; const raw = renderStatusJson(feed); expect(JSON.parse(raw)).toEqual(feed); @@ -274,6 +214,12 @@ describe('renderStatusPage', () => { checkedAt: '2026-05-11T13:45:07.123Z', }; + const outageFeed: StatusFeed = { + status: 'outage', + components: [{ id: 'convex', status: 'outage' }], + checkedAt: baseFeed.checkedAt, + }; + test('renders English by default', () => { const html = renderStatusPage(baseFeed, ''); expect(html).toContain(''); @@ -301,36 +247,8 @@ describe('renderStatusPage', () => { expect(html).not.toContain('Alle Systeme'); }); - test('renders degraded copy + amber banner', () => { - const html = renderStatusPage( - { - status: 'degraded', - components: [ - { id: 'convex', status: 'operational' }, - { id: 'rag', status: 'outage' }, - { id: 'crawler', status: 'operational' }, - ], - checkedAt: baseFeed.checkedAt, - }, - '', - ); - expect(html).toContain('Partial degradation'); - expect(html).toContain('#fef3c7'); - }); - test('renders outage copy + red banner', () => { - const html = renderStatusPage( - { - status: 'outage', - components: [ - { id: 'convex', status: 'outage' }, - { id: 'rag', status: 'outage' }, - { id: 'crawler', status: 'outage' }, - ], - checkedAt: baseFeed.checkedAt, - }, - '', - ); + const html = renderStatusPage(outageFeed, ''); expect(html).toContain('Service outage'); expect(html).toContain('#fee2e2'); }); @@ -351,80 +269,37 @@ describe('renderStatusPage', () => { expect(html).toContain(''); }); - test('renders neutral English component labels — no stack names leaked', () => { + test('renders the neutral English component label — no stack names leaked', () => { const html = renderStatusPage(baseFeed, ''); expect(html).toContain('Application'); - expect(html).toContain('Knowledge base'); - expect(html).toContain('Web & document services'); expect(html).not.toContain('Convex'); expect(html).not.toContain('RAG'); expect(html).not.toContain('Crawler'); }); - test('renders German component labels for de locale', () => { + test('renders the German component label for de locale', () => { const html = renderStatusPage(baseFeed, 'de'); expect(html).toContain('Anwendung'); - expect(html).toContain('Wissensdatenbank'); - expect(html).toContain('Web- & Dokumentendienste'); }); - test('renders French component labels for fr locale', () => { - const html = renderStatusPage(baseFeed, 'fr'); - expect(html).toContain('Base de connaissances'); - expect(html).toContain('Services web et documents'); - }); + test('shows the status word for the component (not color alone)', () => { + const upHtml = renderStatusPage(baseFeed, ''); + expect(upHtml).toContain('>Operational<'); - test('shows status word per component (not color alone)', () => { - const html = renderStatusPage( - { - status: 'degraded', - components: [ - { id: 'convex', status: 'operational' }, - { id: 'rag', status: 'outage' }, - { id: 'crawler', status: 'operational' }, - ], - checkedAt: baseFeed.checkedAt, - }, - '', - ); - // Two operational, one unavailable. - const operationalMatches = html.match(/>OperationalUnavailable<'); + const downHtml = renderStatusPage(outageFeed, ''); + expect(downHtml).toContain('>Unavailable<'); }); test('uses German status words for de locale', () => { - const html = renderStatusPage( - { - status: 'degraded', - components: [ - { id: 'convex', status: 'operational' }, - { id: 'rag', status: 'outage' }, - { id: 'crawler', status: 'operational' }, - ], - checkedAt: baseFeed.checkedAt, - }, - 'de-DE', + expect(renderStatusPage(baseFeed, 'de-DE')).toContain('>Verfügbar<'); + expect(renderStatusPage(outageFeed, 'de-DE')).toContain( + '>Nicht verfügbar<', ); - expect(html).toContain('>Verfügbar<'); - expect(html).toContain('>Nicht verfügbar<'); }); test('uses French status words for fr locale', () => { - const html = renderStatusPage( - { - status: 'degraded', - components: [ - { id: 'convex', status: 'operational' }, - { id: 'rag', status: 'outage' }, - { id: 'crawler', status: 'operational' }, - ], - checkedAt: baseFeed.checkedAt, - }, - 'fr-FR', - ); - expect(html).toContain('>Opérationnel<'); - expect(html).toContain('>Indisponible<'); + expect(renderStatusPage(baseFeed, 'fr-FR')).toContain('>Opérationnel<'); + expect(renderStatusPage(outageFeed, 'fr-FR')).toContain('>Indisponible<'); }); test('marks status dots aria-hidden so screen readers rely on the text label', () => { @@ -432,7 +307,7 @@ describe('renderStatusPage', () => { // Every dot element carries aria-hidden so the visible status text is // the canonical signal for assistive tech. const dots = html.match(/]*>/g) ?? []; - expect(dots.length).toBe(3); + expect(dots.length).toBe(1); for (const dot of dots) expect(dot).toContain('aria-hidden="true"'); }); }); diff --git a/services/platform/status-probe.ts b/services/platform/status-probe.ts index 5845471ff3..bbb10a1f0c 100644 --- a/services/platform/status-probe.ts +++ b/services/platform/status-probe.ts @@ -14,18 +14,18 @@ const CACHE_TTL_MS = 5000; const PROBE_TIMEOUT_MS = 2000; -// Default to loopback so `bun run dev` works without env overrides when the -// developer runs RAG / Crawler on the standard ports. Docker compose sets -// the env vars to the in-network DNS names (rag / crawler / convex), which -// take precedence. Matches the convention in vite.config.ts, dev.ts, -// convex/lib/helpers/rag_config.ts, convex/agent_tools/web/helpers/ -// get_crawler_service_url.ts. +// Default to loopback so `bun run dev` works without env overrides; docker +// compose sets CONVEX_URL to the in-network DNS name (convex), which takes +// precedence. Knowledge-base (RAG) and web/document (crawler) work now runs +// IN-PROCESS inside the Convex backend — there are no separate rag/crawler +// HTTP services to probe, so Convex liveness is the single backend signal. const CONVEX_URL = process.env.CONVEX_URL || 'http://127.0.0.1:3210'; -const RAG_URL = process.env.RAG_URL || 'http://localhost:8001'; -const CRAWLER_URL = process.env.CRAWLER_URL || 'http://localhost:8002'; export type OverallStatus = 'operational' | 'degraded' | 'outage'; -export type ComponentId = 'convex' | 'rag' | 'crawler'; +// One backend today (the Convex application, which subsumes RAG + crawler). +// The wider vocabulary is kept so a future per-subsystem probe can re-add ids +// without breaking consumers. +export type ComponentId = 'convex'; // Binary today because each probe is just `fetch.ok`. The wider // `OverallStatus` vocabulary leaves room for a future `'degraded'` @@ -64,8 +64,6 @@ interface Probe { // container's own healthcheck). Body is plain text — do NOT call .json(). const PROBES: readonly Probe[] = [ { id: 'convex', url: `${CONVEX_URL}/version` }, - { id: 'rag', url: `${RAG_URL}/health` }, - { id: 'crawler', url: `${CRAWLER_URL}/health` }, ]; let cache: { at: number; result: StatusResult } | null = null; @@ -170,13 +168,12 @@ export function renderStatusJson(feed: StatusFeed): string { // Public page rendering // // Server-rendered HTML for `/status` — no JavaScript, no React shell, no -// auto-refresh. The user reloads if they want a fresh state. Component -// labels are deliberately nouns ("Application" / "Knowledge base" / "Web -// & document services") rather than action verbs, so each label covers -// every failure mode of that subsystem (e.g. "Knowledge base" covers both -// indexing new docs and querying existing ones — neither aspect needs its -// own line). This also keeps the public surface free of stack names -// (Convex / RAG / Crawler). +// auto-refresh. The user reloads if they want a fresh state. The component +// label is a deliberate noun ("Application") rather than an action verb, so +// it covers every failure mode of the backend — knowledge-base and +// web/document work now run inside the application (Convex), so a single +// row reflects them all. This also keeps the public surface free of stack +// names (Convex / RAG / Crawler). // Locale picked from Accept-Language prefix: de → German, fr → French, // else English. Matches the locale bundles already shipped at // services/platform/messages/{en,de,fr}.json. @@ -194,8 +191,6 @@ const STRINGS = { statusDown: 'Unavailable', components: { convex: 'Application', - rag: 'Knowledge base', - crawler: 'Web & document services', }, }, de: { @@ -209,8 +204,6 @@ const STRINGS = { statusDown: 'Nicht verfügbar', components: { convex: 'Anwendung', - rag: 'Wissensdatenbank', - crawler: 'Web- & Dokumentendienste', }, }, fr: { @@ -224,8 +217,6 @@ const STRINGS = { statusDown: 'Indisponible', components: { convex: 'Application', - rag: 'Base de connaissances', - crawler: 'Services web et documents', }, }, } as const; diff --git a/services/proxy/README.md b/services/proxy/README.md index 5ac20d9fd9..174827648f 100644 --- a/services/proxy/README.md +++ b/services/proxy/README.md @@ -4,7 +4,7 @@ ## Overview -Routes traffic to the platform, Convex, and Python services using the `platform` DNS alias for blue-green failover. TLS mode and base path are templated into the `Caddyfile` at startup by `docker-entrypoint.sh`. +Routes traffic to the platform and Convex using the `platform` DNS alias for blue-green failover. TLS mode and base path are templated into the `Caddyfile` at startup by `docker-entrypoint.sh`. ## Interface diff --git a/tools/cli/src/lib/compose/services/create-db-service.test.ts b/tools/cli/src/lib/compose/services/create-db-service.test.ts index 7eb57d10e9..1e8e1a57d2 100644 --- a/tools/cli/src/lib/compose/services/create-db-service.test.ts +++ b/tools/cli/src/lib/compose/services/create-db-service.test.ts @@ -37,3 +37,18 @@ describe('createDbService healthcheck', () => { expect(createDbService(config).healthcheck?.start_period).toBe('120s'); }); }); + +describe('createDbService knowledge-db alias', () => { + // The single-node CLI stack has no separate `knowledge-db` service: the + // in-process RAG/crawler code (getKnowledgeDatabaseUrl) defaults to host + // `knowledge-db`, so this service must answer to that DNS name or knowledge + // search/indexing can't reach its corpus in a `tale start`/`tale deploy` + // deployment. + test('exposes a knowledge-db network alias so the runtime default resolves', () => { + const networks = createDbService(config).networks; + if (Array.isArray(networks) || networks === undefined) { + throw new Error('db networks should be the object form with aliases'); + } + expect(networks.internal?.aliases).toContain('knowledge-db'); + }); +}); diff --git a/tools/cli/src/lib/compose/services/create-db-service.ts b/tools/cli/src/lib/compose/services/create-db-service.ts index 81f9381559..6ecaebec3b 100644 --- a/tools/cli/src/lib/compose/services/create-db-service.ts +++ b/tools/cli/src/lib/compose/services/create-db-service.ts @@ -34,6 +34,17 @@ export function createDbService(config: ServiceConfig): ComposeService { start_period: '120s', }, logging: DEFAULT_LOGGING, - networks: ['internal'], + // The single-node CLI stack folds the knowledge corpus (`tale_knowledge`) + // into this one Postgres: the tale-db image creates that DB + the + // pg_search/pgvector schemas on init, and applies the knowledge-corpus + // migrations because `TALE_DB_ROLE` defaults to `knowledge` (no override + // is set here or in .env). The split `compose.yml` runs a SEPARATE + // `knowledge-db` service, so the in-process RAG/crawler code resolves its + // datastore at host `knowledge-db` by default + // (convex/lib/knowledge/db/knowledge_db.ts → getKnowledgeDatabaseUrl). We + // alias this service to `knowledge-db` so that same default URL resolves + // here, with no extra env wiring — keeping the runtime identical across + // both topologies. + networks: { internal: { aliases: ['knowledge-db'] } }, }; } diff --git a/tools/opengrep/config.yml b/tools/opengrep/config.yml index 74f396b970..ae31c1d4ad 100644 --- a/tools/opengrep/config.yml +++ b/tools/opengrep/config.yml @@ -61,7 +61,7 @@ rules: from source. pattern-regex: -----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY----- - # ---- Python (rag, crawler) --------------------------------------------------- + # ---- Python (bundled skill scripts) ------------------------------------------ - id: py-no-eval-exec languages: [python] severity: ERROR From f38b15fb4d7bf0ecd1eeb2041e61e785d735ab62 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Tue, 23 Jun 2026 12:50:34 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(platform):=20external-agent=20native?= =?UTF-8?q?=20web=20tools=20+=20bifrost=E2=86=92llm-gateway=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-agent `nativeWebTools` opt-in (external-agent only): lifts the managed-mode denial of Claude Code's native WebSearch/WebFetch, threaded config→adapter and exposed as a toggle on the agent editor's Instructions tab (en/de/fr). The integration-skill web-access guidance now matches the agent's actual toolset (native vs. governed), and a GitHub appendix routes a git-auth failure into the existing connect-integration guide flow. Default Claude Code agent cleanup: model is now openrouter:anthropic/claude-opus-4.8 (was a placeholder ccgateway ref that resolved against no provider), no integrations bound by default, and refreshed conversation starters. Bundles the in-progress bifrost→llm-gateway rename (the llm-gateway service + Dockerfile, llm_gateway_admin module, llmGatewayKeyId session field, compose, docs) — intermixed with the above in shared files, so committed together. --- .claude/skills/docker/SKILL.md | 4 +- .env.example | 24 +-- .github/workflows/build.yml | 21 +-- .github/workflows/cleanup-pr-images.yml | 1 + builtin-configs/agents/chat/claude-code.json | 29 ++-- ...ost.dev.yml => compose.llm-gateway.dev.yml | 14 +- compose.yml | 55 +++--- docs/de/self-hosted/contributing-docker.md | 2 +- .../install/docker-compose-reference.md | 2 +- .../operate/container-architecture.md | 4 +- docs/de/self-hosted/overview.md | 2 +- docs/en/self-hosted/contributing-docker.md | 2 +- .../install/docker-compose-reference.md | 2 +- .../operate/container-architecture.md | 4 +- docs/en/self-hosted/overview.md | 2 +- docs/fr/self-hosted/contributing-docker.md | 2 +- .../install/docker-compose-reference.md | 2 +- .../operate/container-architecture.md | 4 +- docs/fr/self-hosted/overview.md | 2 +- e2e-03-claude-code-working.png | Bin 0 -> 38190 bytes services/llm-gateway/Dockerfile | 30 ++++ services/llm-gateway/Dockerfile.dockerignore | 125 ++++++++++++++ services/llm-gateway/README.md | 38 +++++ services/llm-gateway/package.json | 13 ++ .../agents/components/agent-navigation.tsx | 1 + .../utils/next-config-for-behavior.test.ts | 6 +- .../agents/utils/next-config-for-behavior.ts | 5 +- .../$id/agents/$agentId/instructions.tsx | 16 ++ services/platform/convex/_generated/api.d.ts | 4 +- services/platform/convex/agents/config.ts | 1 + .../external_agent/run_external_agent.ts | 49 +++--- .../external_agent/turn_lifecycle.test.ts | 2 +- .../agents/external_agent/turn_lifecycle.ts | 8 +- services/platform/convex/agents/file_utils.ts | 7 + .../convex/governance/budget_enforcement.ts | 2 +- .../convex/governance/internal_queries.ts | 2 +- .../convex/integrations/dispatch_http.ts | 2 +- .../convex/lib/agent_chat/start_agent_chat.ts | 3 + .../platform/convex/lib/agent_chat/types.ts | 7 + .../sandbox/integration_skills.test.ts | 65 +++++++ .../node_only/sandbox/integration_skills.ts | 70 ++++++-- ...dmin.test.ts => llm_gateway_admin.test.ts} | 60 +++---- ...{bifrost_admin.ts => llm_gateway_admin.ts} | 158 +++++++++--------- .../convex/node_only/sandbox/run_agent.ts | 16 +- .../sandbox/session_admin_actions.ts | 8 +- .../node_only/sandbox/session_teardown.ts | 8 +- .../sandbox/workflow_sandbox_exec.ts | 37 ++-- .../platform/convex/providers/file_actions.ts | 28 ++-- .../convex/providers/resolve_model.ts | 2 +- .../convex/sandbox/session_lifecycle.test.ts | 26 +-- .../convex/sandbox/session_mutations.ts | 27 +-- .../convex/sandbox/sessions_schema.ts | 12 +- .../lib/agent-adapters/build-exec.test.ts | 37 +++- .../lib/agent-adapters/claude-code/adapter.ts | 6 +- services/platform/lib/agent-adapters/types.ts | 10 +- .../platform/lib/shared/schemas/agents.ts | 26 ++- services/platform/messages/de.json | 6 + services/platform/messages/en.json | 6 + services/platform/messages/fr.json | 6 + services/platform/playwright.config.ts | 2 +- services/platform/scripts/dev-engine.ts | 71 ++++---- services/platform/scripts/dev-gates.test.ts | 2 +- services/platform/scripts/dev-gates.ts | 2 +- services/sandbox-egress/entrypoint.sh | 4 +- services/sandbox-runtime/entrypoint.sh | 2 +- services/sandbox-runtime/tale-git-credential | 4 +- services/sandbox/docs/sessions.md | 8 +- .../kubernetes/k8s-session-pod-spec.ts | 2 +- .../src/session/docker-session-args.test.ts | 2 +- .../src/session/docker-session-args.ts | 4 +- .../src/lib/compose/generators/constants.ts | 2 + .../generators/generate-dev-compose.ts | 2 + .../generate-sandbox-color-compose.test.ts | 2 +- .../generators/generate-stateful-compose.ts | 6 + .../src/lib/compose/select-services.test.ts | 8 +- tools/cli/src/lib/compose/select-services.ts | 8 +- .../compose/services/create-convex-service.ts | 7 + .../create-llm-gateway-service.test.ts | 55 ++++++ .../services/create-llm-gateway-service.ts | 38 +++++ tools/cli/src/lib/compose/types.ts | 11 +- tools/cli/src/lib/config/ensure-env.ts | 20 +++ 81 files changed, 989 insertions(+), 386 deletions(-) rename compose.bifrost.dev.yml => compose.llm-gateway.dev.yml (56%) create mode 100644 e2e-03-claude-code-working.png create mode 100644 services/llm-gateway/Dockerfile create mode 100644 services/llm-gateway/Dockerfile.dockerignore create mode 100644 services/llm-gateway/README.md create mode 100644 services/llm-gateway/package.json create mode 100644 services/platform/convex/node_only/sandbox/integration_skills.test.ts rename services/platform/convex/node_only/sandbox/{bifrost_admin.test.ts => llm_gateway_admin.test.ts} (94%) rename services/platform/convex/node_only/sandbox/{bifrost_admin.ts => llm_gateway_admin.ts} (82%) create mode 100644 tools/cli/src/lib/compose/services/create-llm-gateway-service.test.ts create mode 100644 tools/cli/src/lib/compose/services/create-llm-gateway-service.ts diff --git a/.claude/skills/docker/SKILL.md b/.claude/skills/docker/SKILL.md index 72f76c46f7..62ddbce1da 100644 --- a/.claude/skills/docker/SKILL.md +++ b/.claude/skills/docker/SKILL.md @@ -28,8 +28,8 @@ never exposes; prod configs come from `tale deploy`). Overlay with `-f`: (`-f compose.yml -f compose.dev.yml up --build`). - `compose.test.yml` — container-e2e: shifts ports off the host to avoid CI collisions. - `compose.test.mock.yml` — DB-only port mock (`db` on `15432`). -- `compose.bifrost.dev.yml` — applied **only** when Convex + Vite run on the host (`scripts/dev.ts`), - never by the fully-dockerized dev command; publishes Bifrost on loopback (`127.0.0.1:8080`). +- `compose.llm-gateway.dev.yml` — applied **only** when Convex + Vite run on the host (`scripts/dev.ts`), + never by the fully-dockerized dev command; publishes the LLM gateway on loopback (`127.0.0.1:8080`). - `compose.docs.yml` / `compose.web.yml` (+ their `*.test.yml`) — standalone docs / marketing sites. Root `package.json` scripts: `docker:build` (turbo), `docker:up`, `docker:down`, `docker:logs`. diff --git a/.env.example b/.env.example index 987ab23cf8..711b88ae03 100644 --- a/.env.example +++ b/.env.example @@ -139,10 +139,10 @@ TALE_AUDIT_SIGNING_KEY=4f8c2a9e7b1d6035e4a8c2f9d7b3061a5e8c4f2a9d7b30615e4c8a2f9 # METRICS_BEARER_TOKEN= # ============================================================================ -# OPTIONAL: Sandbox LLM Gateway (Bifrost) management auth +# OPTIONAL: Sandbox LLM Gateway management auth # ============================================================================ -# Bifrost is the only path from an in-sandbox coding agent (Claude Code / -# OpenCode) to an LLM. The platform provisions it + mints per-session virtual +# The LLM gateway is the only path from an in-sandbox coding agent (Claude Code +# / OpenCode) to an LLM. The platform provisions it + mints per-session virtual # keys over the management API; inference is gated so the sandbox can only call # the model with a minted key (client.enforce_auth_on_inference, pushed # automatically — no env needed for that part). @@ -153,14 +153,18 @@ TALE_AUDIT_SIGNING_KEY=4f8c2a9e7b1d6035e4a8c2f9d7b3061a5e8c4f2a9d7b30615e4c8a2f9 # plane is open on the internal network (acceptable only for a single-tenant # local box). The username defaults to `admin`. # -# BIFROST_ADMIN_USERNAME=admin -# BIFROST_ADMIN_PASSWORD=change-me-to-a-strong-secret +# LLM_GATEWAY_ADMIN_PASSWORD is auto-generated by `tale init`; set it here only +# to pin your own value. # -# BIFROST_URL overrides where the platform reaches the management API; default -# http://bifrost:8080 works in-compose. The host bun-dev path sets it to the -# loopback publish (see compose.bifrost-dev.yml + services/platform/.env.local). -# BIFROST_ADMIN_USERNAME=admin -# BIFROST_ADMIN_PASSWORD= +# LLM_GATEWAY_ADMIN_USERNAME=admin +# LLM_GATEWAY_ADMIN_PASSWORD=change-me-to-a-strong-secret +# +# LLM_GATEWAY_URL overrides where the platform reaches the management API; +# default http://llm-gateway:8080 works in-compose. The host bun-dev path sets +# it to the loopback publish (see compose.llm-gateway.dev.yml + +# services/platform/.env.local). +# LLM_GATEWAY_ADMIN_USERNAME=admin +# LLM_GATEWAY_ADMIN_PASSWORD= # ============================================================================ # Provider Secrets Encryption (SOPS + age) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c214b27777..196efeaca0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -95,6 +95,8 @@ jobs: - 'services/convex/**' controller: - 'services/controller/**' + llm-gateway: + - 'services/llm-gateway/**' web: - 'services/web/**' - 'packages/ui/**' @@ -123,6 +125,7 @@ jobs: - 'services/convex/**' - 'services/platform/**' - 'services/proxy/**' + - 'services/llm-gateway/**' - 'services/sandbox/**' - 'services/sandbox-egress/**' - 'services/sandbox-runtime/**' @@ -139,7 +142,7 @@ jobs: # trio that `build` actually pushes to GHCR. Web and docs use their # own compose stacks and are reachable via security.yml's # filesystem scan. - SCANNABLE=$(echo "${SERVICES}" | jq -c '[.[] | select(. == "db" or . == "convex" or . == "controller" or . == "platform" or . == "proxy" or . == "sandbox" or . == "sandbox-egress" or . == "sandbox-runtime")]') + SCANNABLE=$(echo "${SERVICES}" | jq -c '[.[] | select(. == "db" or . == "convex" or . == "controller" or . == "platform" or . == "proxy" or . == "llm-gateway" or . == "sandbox" or . == "sandbox-egress" or . == "sandbox-runtime")]') echo "scannable=${SCANNABLE}" >> "$GITHUB_OUTPUT" echo "Services to scan: ${SCANNABLE}" @@ -196,6 +199,7 @@ jobs: controller, platform, proxy, + llm-gateway, sandbox, sandbox-egress, sandbox-runtime, @@ -341,7 +345,7 @@ jobs: # locally so smoke tests with PULL_POLICY=never find it. TAG="${{ needs.changes.outputs.image_tag }}" REGISTRY_PATH="${{ env.REGISTRY }}/${{ github.repository }}" - for svc in db convex controller platform proxy sandbox sandbox-egress sandbox-runtime; do + for svc in db convex controller platform proxy llm-gateway sandbox sandbox-egress sandbox-runtime; do IMAGE="${REGISTRY_PATH}/tale-${svc}:${TAG}" echo "Pulling ${IMAGE}..." docker pull "${IMAGE}" @@ -352,17 +356,6 @@ jobs: docker tag "ghcr.io/tale-project/tale/tale-sandbox-runtime:latest" \ "tale-sandbox-runtime:latest" - # bifrost is an external Docker Hub image (not built by us, not on GHCR). - # Its compose `pull_policy: ${PULL_POLICY:-missing}` collapses to `never` - # under the smoke stack's PULL_POLICY=never, so compose won't fetch it and - # `up` aborts with "No such image". Pre-pull the pinned tag (resolved from - # compose.yml's default) so it's present locally before the stack starts. - - name: Pull bifrost image (third-party) - run: | - BIFROST_VERSION="$(grep -oP 'maximhq/bifrost:\$\{BIFROST_VERSION:-\K[^}]+' compose.yml)" - echo "Pulling maximhq/bifrost:${BIFROST_VERSION}..." - docker pull "maximhq/bifrost:${BIFROST_VERSION}" - - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: @@ -573,7 +566,7 @@ jobs: # locally so PULL_POLICY=never validation finds it. TAG="${{ needs.changes.outputs.image_tag }}" REGISTRY_PATH="${{ env.REGISTRY }}/${{ github.repository }}" - for svc in db convex controller platform proxy sandbox sandbox-egress sandbox-runtime; do + for svc in db convex controller platform proxy llm-gateway sandbox sandbox-egress sandbox-runtime; do IMAGE="${REGISTRY_PATH}/tale-${svc}:${TAG}" echo "Pulling ${IMAGE}..." docker pull "${IMAGE}" diff --git a/.github/workflows/cleanup-pr-images.yml b/.github/workflows/cleanup-pr-images.yml index 9d373365f9..c306d71ae7 100644 --- a/.github/workflows/cleanup-pr-images.yml +++ b/.github/workflows/cleanup-pr-images.yml @@ -35,6 +35,7 @@ jobs: controller, platform, proxy, + llm-gateway, sandbox, sandbox-egress, sandbox-runtime, diff --git a/builtin-configs/agents/chat/claude-code.json b/builtin-configs/agents/chat/claude-code.json index 1b2d446a3a..d47f2bf1ab 100644 --- a/builtin-configs/agents/chat/claude-code.json +++ b/builtin-configs/agents/chat/claude-code.json @@ -10,38 +10,39 @@ "i18n": { "de": { "conversationStarters": [ - "Klone das Repo und behebe das in Issue #42 beschriebene Problem", - "Schreibe ein kleines CLI-Tool in Python und teste es", - "Refaktoriere dieses Modul und lass die Tests laufen", - "Erstelle einen Branch und öffne einen Pull Request mit deiner Änderung" + "Klone ein GitHub-Repository und behebe den Fehler in Issue #42", + "Schreibe ein kleines Python-CLI-Tool, führe es aus und zeig mir die Ausgabe", + "Refaktoriere dieses Modul und lass die Tests laufen, um zu beweisen, dass es noch funktioniert", + "Debugge diesen fehlschlagenden Test und erkläre die Ursache" ], "description": "Anthropics Coding-Agent Claude Code, ausgeführt in einer isolierten Sandbox — du chattest direkt mit ihm, während er Dateien bearbeitet, Befehle ausführt und über mehrere Runden weiterarbeitet.", "displayName": "Claude Code" }, "en": { "conversationStarters": [ - "Clone the repo and fix the bug described in issue #42", - "Write a small Python CLI tool and test it", - "Refactor this module and run the tests", - "Create a branch and open a pull request with your change" + "Clone a GitHub repo and fix the bug in issue #42", + "Write a small Python CLI tool, run it, and show me the output", + "Refactor this module and run the tests to prove it still works", + "Debug this failing test and explain the root cause" ], "description": "Anthropic's Claude Code coding agent, running in an isolated sandbox — chat with it directly as it edits files, runs commands, and continues across turns.", "displayName": "Claude Code" }, "fr": { "conversationStarters": [ - "Clone le dépôt et corrige le bug décrit dans l'issue #42", - "Écris un petit outil CLI en Python et teste-le", - "Refactorise ce module et lance les tests", - "Crée une branche et ouvre une pull request avec ta modification" + "Clone un dépôt GitHub et corrige le bug de l'issue #42", + "Écris un petit outil CLI en Python, exécute-le et montre-moi la sortie", + "Refactorise ce module et lance les tests pour prouver qu'il fonctionne toujours", + "Débogue ce test en échec et explique-en la cause racine" ], "description": "L'agent de code Claude Code d'Anthropic, exécuté dans un bac à sable isolé — discutez directement avec lui pendant qu'il modifie des fichiers, lance des commandes et poursuit le travail sur plusieurs tours.", "displayName": "Claude Code" } }, - "integrationBindings": ["github", "tavily"], + "integrationBindings": [], + "nativeWebTools": true, "primaryBehavior": "external-agent", - "supportedModels": ["ccgateway:claude-opus-4-8"], + "supportedModels": ["openrouter:anthropic/claude-opus-4.8"], "timeoutMs": 1800000, "visibleInChat": true } diff --git a/compose.bifrost.dev.yml b/compose.llm-gateway.dev.yml similarity index 56% rename from compose.bifrost.dev.yml rename to compose.llm-gateway.dev.yml index 57cac016fe..5551625507 100644 --- a/compose.bifrost.dev.yml +++ b/compose.llm-gateway.dev.yml @@ -3,16 +3,16 @@ # command (docker compose -f compose.yml -f compose.dev.yml -f compose.docs.yml). # It carries the deltas that only make sense when the host owns the backend. # -# 1) Publish Bifrost's management/data port on loopback so the host-running -# Convex (bun dev) can mint/revoke session virtual keys. compose.yml publishes -# no Bifrost port by design (prod posture). Pair with -# BIFROST_URL=http://127.0.0.1:8080 in services/platform/.env.local (synced -# into the Convex deployment env). +# 1) Publish the LLM gateway's management/data port on loopback so the +# host-running Convex (bun dev) can mint/revoke session virtual keys. +# compose.yml publishes no gateway port by design (prod posture). Pair with +# LLM_GATEWAY_URL=http://127.0.0.1:8080 in services/platform/.env.local +# (synced into the Convex deployment env). # # Usage (host bun-dev appends this automatically; manual equivalent): # docker compose -f compose.yml -f compose.dev.yml -f compose.docs.yml \ -# -f compose.bifrost.dev.yml up -d +# -f compose.llm-gateway.dev.yml up -d services: - bifrost: + llm-gateway: ports: - '127.0.0.1:8080:8080' diff --git a/compose.yml b/compose.yml index 2d2360d66a..fde78047c5 100644 --- a/compose.yml +++ b/compose.yml @@ -261,7 +261,7 @@ services: # Also on the sandbox network so the in-sandbox MCP integration bridge can # reach convex http actions directly (http://convex:3211/api/integrations), - # mirroring how `bifrost` is dual-homed for the gateway. Safe: convex is + # mirroring how `llm-gateway` is dual-homed for the gateway. Safe: convex is # single-instance/stateful (no LB) and the dispatch route is VK-authed. networks: - internal @@ -606,50 +606,53 @@ services: - sandbox # ============================================================================ - # Tale LLM Gateway — Bifrost (OSS, Apache-2.0) for sandbox AGENT SESSIONS + # Tale LLM Gateway (maximhq/bifrost core, OSS Apache-2.0) for sandbox AGENT SESSIONS # ---------------------------------------------------------------------------- # The only path from an in-sandbox coding agent (Claude Code / OpenCode) to an # LLM. Dual-homed onto `internal` (platform provisions providers + mints # session virtual keys via the governance API) and `sandbox` (agents reach it - # at http://bifrost:8080 over the internal bridge — NOT through tinyproxy). + # at http://llm-gateway:8080 over the internal bridge — NOT through tinyproxy). # # Raw provider API keys live ONLY here + in the platform; the sandbox holds a # session-scoped `sk-bf-*` virtual key (budget + model allowlist), revoked at # session destroy. The platform is the source of truth for providers/models; - # it provisions Bifrost via the governance API on session create - # (convex/node_only/sandbox/bifrost_admin.ts: provisionProviders + + # it provisions the gateway via the governance API on session create + # (convex/node_only/sandbox/llm_gateway_admin.ts: provisionProviders + # applyGatewayConfig). # - # SECURITY: Bifrost auth is config-store driven (NOT env vars — v1.4.8 never - # read the old BIFROST_MANAGEMENT_TOKEN / BIFROST_ENFORCE_VIRTUAL_KEYS). - # applyGatewayConfig() pushes, idempotently on session create: + # SECURITY: gateway auth is config-store driven (NOT env vars — the upstream + # maximhq/bifrost core never read its old management-token / enforce-virtual-keys + # env knobs). applyGatewayConfig() pushes, idempotently on session create: # - client.enforce_auth_on_inference=true → inference rejects anything but a # minted virtual key (the data-plane gate). - # - auth_config (admin basic-auth over /api/*) when BIFROST_ADMIN_PASSWORD is - # set → the management plane stops being anonymous on the internal network. - # No published port (loopback publish lives in compose.bifrost.dev.yml for the - # host bun-dev path). PINNED image — the project has had VK auth regressions. + # - auth_config (admin basic-auth over /api/*) when LLM_GATEWAY_ADMIN_PASSWORD + # is set → the management plane stops being anonymous on the internal network. + # No published port (loopback publish lives in compose.llm-gateway.dev.yml for + # the host bun-dev path). Built as a thin wrapper over the PINNED upstream image + # — the project has had VK auth regressions. # ============================================================================ - bifrost: - image: maximhq/bifrost:${BIFROST_VERSION:-v1.5.13} - # External image (no build: block here), so the repo-wide `build` default is - # invalid for this service — pull the pinned tag when absent instead. - pull_policy: ${PULL_POLICY:-missing} - container_name: tale-bifrost + llm-gateway: + image: ghcr.io/tale-project/tale/tale-llm-gateway:${VERSION:-latest} + build: + context: . + dockerfile: services/llm-gateway/Dockerfile + args: + VERSION: ${VERSION:-dev} + container_name: tale-llm-gateway env_file: - .env - # No bifrost-read env knobs: auth + VK enforcement are config-store fields + # No gateway-read env knobs: auth + VK enforcement are config-store fields # the platform pushes via applyGatewayConfig() (admin creds come from the - # platform's BIFROST_ADMIN_USERNAME / BIFROST_ADMIN_PASSWORD, never set on - # the bifrost container itself). + # platform's LLM_GATEWAY_ADMIN_USERNAME / LLM_GATEWAY_ADMIN_PASSWORD, never + # set on the gateway container itself). volumes: # SQLite config/log store (providers + minted keys + auth config survive # restarts). Wipe this volume to re-bootstrap auth from scratch. - - bifrost-data:/app/data + - llm-gateway-data:/app/data restart: unless-stopped mem_limit: 512m healthcheck: - # The bifrost image ships busybox wget but no curl. + # The gateway image ships busybox wget but no curl. test: [ 'CMD-SHELL', @@ -770,9 +773,9 @@ volumes: caddy-config: driver: local - # Bifrost LLM gateway SQLite store (provisioned providers + minted session - # virtual keys). Survives restarts so in-flight sessions keep working. - bifrost-data: + # LLM gateway SQLite store (provisioned providers + minted session virtual + # keys). Survives restarts so in-flight sessions keep working. + llm-gateway-data: driver: local # ============================================================================ diff --git a/docs/de/self-hosted/contributing-docker.md b/docs/de/self-hosted/contributing-docker.md index ff25e6a03f..efeb83fd53 100644 --- a/docs/de/self-hosted/contributing-docker.md +++ b/docs/de/self-hosted/contributing-docker.md @@ -22,7 +22,7 @@ Der Stack ist vollständig TypeScript — kein Python-Image. Jedes Image hat ein | `tale-sandbox-runtime` | `services/sandbox-runtime/` | Bun + Chromium + Playwright | | `tale-controller` | `services/controller/` | Bun + Docker-CLI | -Beide Datenbank-Container — `db` und `knowledge-db` — bauen aus demselben `tale-db`-ParadeDB-Image; der Unterschied ist die Datenbank, die jeder bedient. Das LLM-Gateway `tale-bifrost` ist ein gepinntes Upstream-Image (`maximhq/bifrost`), hat also kein Dockerfile im Repo. Die Compose-Dateien im Repo-Root (`compose.yml` für Development, die CLI-generierte Produktions-Compose) referenzieren diese über `ghcr.io/tale-project/tale/:`. Ein lokaler Build ersetzt den Registry-Pull mit einem `build:`-Block in Compose. +Beide Datenbank-Container — `db` und `knowledge-db` — bauen aus demselben `tale-db`-ParadeDB-Image; der Unterschied ist die Datenbank, die jeder bedient. Das LLM-Gateway `tale-llm-gateway` ist ein gepinntes Upstream-Image (`maximhq/bifrost`), hat also kein Dockerfile im Repo. Die Compose-Dateien im Repo-Root (`compose.yml` für Development, die CLI-generierte Produktions-Compose) referenzieren diese über `ghcr.io/tale-project/tale/:`. Ein lokaler Build ersetzt den Registry-Pull mit einem `build:`-Block in Compose. ## Lokal bauen diff --git a/docs/de/self-hosted/install/docker-compose-reference.md b/docs/de/self-hosted/install/docker-compose-reference.md index f028770af7..77da337d60 100644 --- a/docs/de/self-hosted/install/docker-compose-reference.md +++ b/docs/de/self-hosted/install/docker-compose-reference.md @@ -45,7 +45,7 @@ Der Basis-Graph fährt acht Container hoch: - `tale-convex` — das Convex-Backend. WebSocket, Queries, Mutationen, Actions — und die In-Process-RAG-Suche, Dokument-Ingestion, das Web-Crawling und die Dokumentgenerierung, die früher separate Services waren. - `tale-db` — operatives Postgres (ParadeDB). Der persistente Speicher des Convex-Backends. - `tale-knowledge-db` — Postgres des Wissens-Korpus (ParadeDB). Die `tale_knowledge`-Datenbank mit Dokument-Chunks, Embeddings und gecrawlten Seiten, auf Port 5433, damit sie nie mit `tale-db` auf 5432 kollidiert. -- `tale-bifrost` — das LLM-Gateway für In-Sandbox-Coding-Agents (gepinntes externes Image). +- `tale-llm-gateway` — das LLM-Gateway für In-Sandbox-Coding-Agents (gepinntes externes Image). - `tale-sandbox-egress` und `tale-sandbox` — die Sandbox-Ebene. Run-Code-Container hinter einem Egress-Proxy (standardmäßig offen; sperrbar mit `SANDBOX_EGRESS_ALLOWLIST`), zugleich die Headless-Browser-Laufzeit, die das Convex-Backend für Web-Render und Dokumentgenerierung aufruft. Der Stack ist jetzt vollständig TypeScript — es gibt keinen Python-Service im Graph. [Container-Architektur](/de/self-hosted/operate/container-architecture) vertieft, was was besitzt. diff --git a/docs/de/self-hosted/operate/container-architecture.md b/docs/de/self-hosted/operate/container-architecture.md index a87a9d3498..cc7ba5e5bb 100644 --- a/docs/de/self-hosted/operate/container-architecture.md +++ b/docs/de/self-hosted/operate/container-architecture.md @@ -16,7 +16,7 @@ Lies das, wenn du Bereitschaft hast. Komm zurück, wenn du entscheidest, welchen | `tale-convex` | Backend Actions/Queries/Mutations + WebSocket, plus In-Process-RAG, Crawling und Dokumentgen | UI lädt, aber ohne Daten; laufende Chats stocken; Ingestion stockt | | `tale-db` | Operatives Postgres für Convex | Convex fällt in Read-only; Writes blockieren | | `tale-knowledge-db` | Postgres des Wissens-Korpus (Dokument-Chunks, Embeddings, gecrawlte Seiten) | Wissens-Suche liefert leer; Ingestion scheitert | -| `tale-bifrost` | LLM-Gateway für In-Sandbox-Coding-Agents | Sandboxierte Agents erreichen kein Modell; Chat ist unbetroffen | +| `tale-llm-gateway` | LLM-Gateway für In-Sandbox-Coding-Agents | Sandboxierte Agents erreichen kein Modell; Chat ist unbetroffen | | `tale-sandbox-egress` | Netzwerk-Egress für sandboxierten Code | **Code-ausführen**-Tool scheitert mit „Egress denied"; Web-Render scheitert | | `tale-sandbox` | Sandbox-Laufzeit + Headless-Browser für Web-Render und Dokumentgenerierung | **Code-ausführen**, Web-Crawl-Render und Dokumentgenerierung scheitern alle | @@ -55,7 +55,7 @@ Die Sandbox-Laufzeit trägt Chromium und Playwright, also nutzt das Convex-Backe **`tale-sandbox` / `tale-sandbox-egress` down.** **Code-ausführen**-Tool-Aufrufe geben einen Fehler zurück und Fähigkeits-Skripte scheitern. Weil das Convex-Backend Webseiten rendert und Dokumente über die Sandbox-Laufzeit generiert, scheitern auch ein Web-Crawl, der JavaScript-Rendering braucht, und die Dokumentgenerierung geschlossen, solange die Sandbox down ist. Agents, die keines davon nutzen, arbeiten weiter. -**`tale-bifrost` down.** In-Sandbox-Coding-Agents verlieren ihren Pfad zu einem Modell-Provider. Regulärer Chat — der Provider direkt aus Convex aufruft, nicht über Bifrost — ist unbetroffen. +**`tale-llm-gateway` down.** In-Sandbox-Coding-Agents verlieren ihren Pfad zu einem Modell-Provider. Regulärer Chat — der Provider direkt aus Convex aufruft, nicht über das LLM-Gateway — ist unbetroffen. ## Wo das hingehört diff --git a/docs/de/self-hosted/overview.md b/docs/de/self-hosted/overview.md index bad9d2dbf2..00098f4c07 100644 --- a/docs/de/self-hosted/overview.md +++ b/docs/de/self-hosted/overview.md @@ -19,7 +19,7 @@ Lies das, bevor du `docker compose up` ausführst. Komm zurück, wenn du einen A **tale-knowledge-db** ist das Postgres des Wissens-Korpus (ParadeDB), die `tale_knowledge`-Datenbank mit zwei Schemata: `private_knowledge` (Chunks hochgeladener Dokumente, Embeddings, der BM25-Index, der semantische Cache) und `public_web` (gecrawlte Webseiten). Es ist von `tale-db` getrennt, damit der Korpus — der datenresidenz-sensible Speicher — sich für sich allein verlagern oder ersetzen lässt. Das Convex-Backend verbindet sich direkt mit ihm; nichts sonst tut das. -**tale-bifrost** ist das LLM-Gateway für In-Sandbox-Coding-Agents. Es ist der einzige Pfad von einem sandboxierten Agent zu einem Modell-Provider; die Plattform stellt es bereit und prägt Per-Session-Keys. +**tale-llm-gateway** ist das LLM-Gateway für In-Sandbox-Coding-Agents. Es ist der einzige Pfad von einem sandboxierten Agent zu einem Modell-Provider; die Plattform stellt es bereit und prägt Per-Session-Keys. **tale-sandbox** und **tale-sandbox-egress** führen sandboxierten Code für das **Code-ausführen**-Tool und Fähigkeits-Skripte aus und dienen als die Headless-Browser-Laufzeit, die das Convex-Backend für Web-Render und Dokumentgenerierung aufruft. Der Egress-Container ist der einzige Netzwerkweg, den die Sandbox hat. Egress ist standardmäßig offen — sandboxierter Code erreicht jeden öffentlichen Host über HTTPS, Cloud-Metadaten und private Adressbereiche bleiben auf IP-Ebene blockiert. Einschränken kannst du das mit `SANDBOX_EGRESS_ALLOWLIST` auf eine Hostname-Allowlist; die Anleitung steht in [Hardening](/de/self-hosted/operate/security/hardening). diff --git a/docs/en/self-hosted/contributing-docker.md b/docs/en/self-hosted/contributing-docker.md index 6d833e607c..f878f23212 100644 --- a/docs/en/self-hosted/contributing-docker.md +++ b/docs/en/self-hosted/contributing-docker.md @@ -22,7 +22,7 @@ The stack is entirely TypeScript — no Python image. Each image has one Dockerf | `tale-sandbox-runtime` | `services/sandbox-runtime/` | Bun + Chromium + Playwright | | `tale-controller` | `services/controller/` | Bun + Docker CLI | -Both database containers — `db` and `knowledge-db` — build from the same `tale-db` ParadeDB image; the difference is the database each one serves. The LLM gateway, `tale-bifrost`, is a pinned upstream image (`maximhq/bifrost`), so it has no Dockerfile in the repo. The compose files at the repo root (`compose.yml` for development, the CLI-generated production compose) reference these by `ghcr.io/tale-project/tale/:`. A local build replaces the registry pull with a `build:` block in compose. +Both database containers — `db` and `knowledge-db` — build from the same `tale-db` ParadeDB image; the difference is the database each one serves. The LLM gateway, `tale-llm-gateway`, is a pinned upstream image (`maximhq/bifrost`), so it has no Dockerfile in the repo. The compose files at the repo root (`compose.yml` for development, the CLI-generated production compose) reference these by `ghcr.io/tale-project/tale/:`. A local build replaces the registry pull with a `build:` block in compose. ## Building locally diff --git a/docs/en/self-hosted/install/docker-compose-reference.md b/docs/en/self-hosted/install/docker-compose-reference.md index 5bd1681fa3..53363b4e44 100644 --- a/docs/en/self-hosted/install/docker-compose-reference.md +++ b/docs/en/self-hosted/install/docker-compose-reference.md @@ -45,7 +45,7 @@ The base graph brings up eight containers: - `tale-convex` — the Convex backend. WebSocket, queries, mutations, actions — and the in-process RAG search, document ingestion, web crawling, and document generation that used to be separate services. - `tale-db` — operational Postgres (ParadeDB). The Convex backend's persistent store. - `tale-knowledge-db` — knowledge corpus Postgres (ParadeDB). The `tale_knowledge` database holding document chunks, embeddings, and crawled pages, on port 5433 so it never clashes with `tale-db` on 5432. -- `tale-bifrost` — the LLM gateway for in-sandbox coding agents (pinned external image). +- `tale-llm-gateway` — the LLM gateway for in-sandbox coding agents (pinned external image). - `tale-sandbox-egress` and `tale-sandbox` — the sandbox plane. Run-code containers behind an egress proxy (open by default; lock down with `SANDBOX_EGRESS_ALLOWLIST`), also the headless-browser runtime the convex backend calls for web rendering and document generation. The stack is now entirely TypeScript — there is no Python service in the graph. [Container architecture](/self-hosted/operate/container-architecture) goes deeper on what owns what. diff --git a/docs/en/self-hosted/operate/container-architecture.md b/docs/en/self-hosted/operate/container-architecture.md index 7dddcf0e17..2acbec2b41 100644 --- a/docs/en/self-hosted/operate/container-architecture.md +++ b/docs/en/self-hosted/operate/container-architecture.md @@ -16,7 +16,7 @@ Read this when you are on call. Come back when you are deciding which container | `tale-convex` | Backend actions/queries/mutations + WebSocket, plus in-process RAG, crawling, and document gen | UI loads, but no data; in-flight chats stall; ingestion stalls | | `tale-db` | Operational Postgres for Convex | Convex falls back to read-only; writes block | | `tale-knowledge-db` | Knowledge corpus Postgres (document chunks, embeddings, crawled pages) | Knowledge search returns empty; ingestion fails | -| `tale-bifrost` | LLM gateway for in-sandbox coding agents | Sandboxed agents can't reach a model; chat is unaffected | +| `tale-llm-gateway` | LLM gateway for in-sandbox coding agents | Sandboxed agents can't reach a model; chat is unaffected | | `tale-sandbox-egress` | Network egress for sandboxed code | `Run code` tool errors with "egress denied"; web render fails | | `tale-sandbox` | Sandbox runtime + headless browser for web render and document generation | `Run code`, web crawl render, and document generation all fail | @@ -55,7 +55,7 @@ The sandbox runtime carries Chromium and Playwright, so the convex backend reuse **`tale-sandbox` / `tale-sandbox-egress` down.** `Run code` tool calls return an error and skill scripts fail. Because the convex backend renders web pages and generates documents through the sandbox runtime, a web crawl that needs JavaScript rendering and document generation also fail closed while the sandbox is down. Agents that use none of these keep working. -**`tale-bifrost` down.** In-sandbox coding agents lose their path to a model provider. Regular chat — which calls providers directly from convex, not through Bifrost — is unaffected. +**`tale-llm-gateway` down.** In-sandbox coding agents lose their path to a model provider. Regular chat — which calls providers directly from convex, not through the LLM gateway — is unaffected. ## Where this fits diff --git a/docs/en/self-hosted/overview.md b/docs/en/self-hosted/overview.md index 3bd66fa700..d68b42a989 100644 --- a/docs/en/self-hosted/overview.md +++ b/docs/en/self-hosted/overview.md @@ -19,7 +19,7 @@ Read this before you `docker compose up`. Come back when you are debugging an ou **tale-knowledge-db** is the knowledge corpus Postgres (ParadeDB), the `tale_knowledge` database with two schemas: `private_knowledge` (uploaded-document chunks, embeddings, the BM25 index, the semantic cache) and `public_web` (crawled web pages). It is split from `tale-db` so the corpus — the data-residency-sensitive store — can be relocated or replaced on its own. The Convex backend connects to it directly; nothing else does. -**tale-bifrost** is the LLM gateway for in-sandbox coding agents. It is the only path from a sandboxed agent to a model provider; the platform provisions it and mints per-session keys. +**tale-llm-gateway** is the LLM gateway for in-sandbox coding agents. It is the only path from a sandboxed agent to a model provider; the platform provisions it and mints per-session keys. **tale-sandbox** and **tale-sandbox-egress** run sandboxed code on behalf of the `Run code` tool and skill scripts, and serve as the headless-browser runtime the convex backend calls for web rendering and document generation. The egress container is the only path the sandbox has to the network. Egress is open by default — sandboxed code reaches any public host over HTTPS while cloud-metadata and private-range targets stay blocked at the IP layer; lock it down to a hostname allowlist with `SANDBOX_EGRESS_ALLOWLIST`, described in [Hardening](/self-hosted/operate/security/hardening). diff --git a/docs/fr/self-hosted/contributing-docker.md b/docs/fr/self-hosted/contributing-docker.md index 1cb39bffe4..523f92ecf2 100644 --- a/docs/fr/self-hosted/contributing-docker.md +++ b/docs/fr/self-hosted/contributing-docker.md @@ -22,7 +22,7 @@ La stack est entièrement TypeScript — pas d'image Python. Chaque image a un D | `tale-sandbox-runtime` | `services/sandbox-runtime/` | Bun + Chromium + Playwright | | `tale-controller` | `services/controller/` | Bun + CLI Docker | -Les deux conteneurs de base de données — `db` et `knowledge-db` — se construisent depuis la même image ParadeDB `tale-db` ; la différence est la base que chacun sert. La gateway LLM, `tale-bifrost`, est une image amont pinnée (`maximhq/bifrost`), elle n'a donc pas de Dockerfile dans le repo. Les fichiers compose à la racine du repo (`compose.yml` pour développement, le compose de production généré par la CLI) les référencent via `ghcr.io/tale-project/tale/:`. Un build local remplace le pull de registre par un bloc `build:` dans compose. +Les deux conteneurs de base de données — `db` et `knowledge-db` — se construisent depuis la même image ParadeDB `tale-db` ; la différence est la base que chacun sert. La gateway LLM, `tale-llm-gateway`, est une image amont pinnée (`maximhq/bifrost`), elle n'a donc pas de Dockerfile dans le repo. Les fichiers compose à la racine du repo (`compose.yml` pour développement, le compose de production généré par la CLI) les référencent via `ghcr.io/tale-project/tale/:`. Un build local remplace le pull de registre par un bloc `build:` dans compose. ## Construire localement diff --git a/docs/fr/self-hosted/install/docker-compose-reference.md b/docs/fr/self-hosted/install/docker-compose-reference.md index 07e5db9925..0a5b6daf22 100644 --- a/docs/fr/self-hosted/install/docker-compose-reference.md +++ b/docs/fr/self-hosted/install/docker-compose-reference.md @@ -45,7 +45,7 @@ Le graphe de base démarre huit conteneurs : - `tale-convex` — le backend Convex. WebSocket, queries, mutations, actions — et la recherche RAG, l'ingestion de documents, le crawling web et la génération de documents en in-process, qui étaient autrefois des services séparés. - `tale-db` — Postgres opérationnel (ParadeDB). Le stockage persistant du backend Convex. - `tale-knowledge-db` — Postgres du corpus de connaissances (ParadeDB). La base `tale_knowledge` qui détient les fragments de documents, les embeddings et les pages crawlées, sur le port 5433 pour ne jamais entrer en conflit avec `tale-db` sur 5432. -- `tale-bifrost` — la gateway LLM pour les agents de code en sandbox (image externe pinnée). +- `tale-llm-gateway` — la gateway LLM pour les agents de code en sandbox (image externe pinnée). - `tale-sandbox-egress` et `tale-sandbox` — le plan sandbox. Conteneurs Run-code derrière un proxy de sortie (ouvert par défaut ; verrouillable avec `SANDBOX_EGRESS_ALLOWLIST`), aussi le runtime de navigateur headless que le backend convex appelle pour le rendu web et la génération de documents. La stack est désormais entièrement TypeScript — il n'y a pas de service Python dans le graphe. [Architecture des conteneurs](/fr/self-hosted/operate/container-architecture) creuse qui possède quoi. diff --git a/docs/fr/self-hosted/operate/container-architecture.md b/docs/fr/self-hosted/operate/container-architecture.md index 62b0a7fbd7..1c50fcd58a 100644 --- a/docs/fr/self-hosted/operate/container-architecture.md +++ b/docs/fr/self-hosted/operate/container-architecture.md @@ -16,7 +16,7 @@ Lis ceci quand tu es d'astreinte. Reviens-y quand tu décides quel conteneur rou | `tale-convex` | Actions/queries/mutations backend + WebSocket, plus RAG, crawling et génération de documents en in-process | L'UI charge mais sans données ; les chats en vol stagnent ; l'ingestion stagne | | `tale-db` | Postgres opérationnel pour Convex | Convex bascule en lecture seule ; les écritures bloquent | | `tale-knowledge-db` | Postgres du corpus de connaissances (fragments de documents, embeddings, pages crawlées) | La recherche de connaissances renvoie vide ; l'ingestion échoue | -| `tale-bifrost` | Gateway LLM pour les agents de code en sandbox | Les agents en sandbox ne joignent aucun modèle ; le chat n'est pas affecté | +| `tale-llm-gateway` | Gateway LLM pour les agents de code en sandbox | Les agents en sandbox ne joignent aucun modèle ; le chat n'est pas affecté | | `tale-sandbox-egress` | Sortie réseau pour code sandbox | L'outil **Exécuter du code** échoue avec « egress denied » ; le rendu web échoue | | `tale-sandbox` | Runtime sandbox + navigateur headless pour le rendu web et la génération de documents | **Exécuter du code**, le rendu de crawl web et la génération de documents échouent | @@ -55,7 +55,7 @@ Le runtime sandbox embarque Chromium et Playwright, donc le backend convex le r **`tale-sandbox` / `tale-sandbox-egress` en panne.** Les appels de l'outil **Exécuter du code** retournent une erreur et les scripts de compétence échouent. Parce que le backend convex rend les pages web et génère les documents via le runtime sandbox, un crawl web qui a besoin de rendu JavaScript et la génération de documents échouent aussi en mode fermé tant que la sandbox est down. Les agents qui n'utilisent aucun de ces éléments continuent de marcher. -**`tale-bifrost` en panne.** Les agents de code en sandbox perdent leur chemin vers un fournisseur de modèles. Le chat ordinaire — qui appelle les fournisseurs directement depuis convex, pas via Bifrost — n'est pas affecté. +**`tale-llm-gateway` en panne.** Les agents de code en sandbox perdent leur chemin vers un fournisseur de modèles. Le chat ordinaire — qui appelle les fournisseurs directement depuis convex, pas via la gateway LLM — n'est pas affecté. ## Où cela s'inscrit diff --git a/docs/fr/self-hosted/overview.md b/docs/fr/self-hosted/overview.md index b84203fb5b..40b4ab2598 100644 --- a/docs/fr/self-hosted/overview.md +++ b/docs/fr/self-hosted/overview.md @@ -19,7 +19,7 @@ Lis ceci avant de `docker compose up`. Reviens-y quand tu débogues un incident **tale-knowledge-db** est le Postgres du corpus de connaissances (ParadeDB), la base `tale_knowledge` avec deux schémas : `private_knowledge` (fragments de documents téléversés, embeddings, index BM25, cache sémantique) et `public_web` (pages web crawlées). Il est séparé de `tale-db` pour que le corpus — la banque sensible à la résidence des données — puisse être relocalisé ou remplacé tout seul. Le backend Convex s'y connecte directement ; rien d'autre ne le fait. -**tale-bifrost** est la gateway LLM pour les agents de code en sandbox. C'est le seul chemin d'un agent en sandbox vers un fournisseur de modèles ; la plateforme le provisionne et frappe des clés par session. +**tale-llm-gateway** est la gateway LLM pour les agents de code en sandbox. C'est le seul chemin d'un agent en sandbox vers un fournisseur de modèles ; la plateforme le provisionne et frappe des clés par session. **tale-sandbox** et **tale-sandbox-egress** exécutent du code en sandbox pour le compte de l'outil **Exécuter du code** et des scripts de compétence, et servent de runtime de navigateur headless que le backend convex appelle pour le rendu web et la génération de documents. Le conteneur egress est le seul chemin que la sandbox a vers le réseau. L'egress est ouvert par défaut — le code en sandbox atteint n'importe quel hôte public en HTTPS, tandis que les métadonnées cloud et les plages d'adresses privées restent bloquées au niveau IP ; restreins-le à une allowlist d'hôtes avec `SANDBOX_EGRESS_ALLOWLIST`, décrite dans [Durcissement](/fr/self-hosted/operate/security/hardening). diff --git a/e2e-03-claude-code-working.png b/e2e-03-claude-code-working.png new file mode 100644 index 0000000000000000000000000000000000000000..64d918e78772c60fb57f87407284c711fce30aa1 GIT binary patch literal 38190 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ν-k~Yjnhs^;xP7z3grwbf4i3x5!xFNbhH|& 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 literal 0 HcmV?d00001 diff --git a/services/llm-gateway/Dockerfile b/services/llm-gateway/Dockerfile new file mode 100644 index 0000000000..dc7bac1924 --- /dev/null +++ b/services/llm-gateway/Dockerfile @@ -0,0 +1,30 @@ +# ============================================================================ +# Tale LLM Gateway +# ============================================================================ +# Thin wrapper around the upstream maximhq/bifrost gateway image. The gateway +# is the only path from an in-sandbox coding agent (Claude Code / OpenCode) to +# an LLM: the platform provisions providers + mints per-session virtual keys +# over its management API. We re-tag the upstream image as tale-llm-gateway so +# it carries Tale OCI metadata and ships through the same build/scan pipeline +# as the other services; the runtime is unchanged. +# ============================================================================ + +# Version argument - injected by CI from git tag, defaults to 'dev' for local builds +ARG VERSION=dev + +# Upstream gateway core (vendor image — NOT renamed; this is the literal +# upstream artifact). Pinned because the project has had VK auth regressions. +FROM maximhq/bifrost:v1.5.13 + +# Re-declare VERSION arg (ARGs don't persist after FROM) +ARG VERSION=dev +LABEL org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.title="tale-llm-gateway" \ + org.opencontainers.image.description="Tale LLM gateway — managed provider auth + per-session virtual keys" \ + org.opencontainers.image.source="https://github.com/tale-project/tale" \ + org.opencontainers.image.vendor="Tale" \ + org.opencontainers.image.licenses="MIT" + +# The base image ships busybox wget but no curl. +HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1 diff --git a/services/llm-gateway/Dockerfile.dockerignore b/services/llm-gateway/Dockerfile.dockerignore new file mode 100644 index 0000000000..2ecfdd567e --- /dev/null +++ b/services/llm-gateway/Dockerfile.dockerignore @@ -0,0 +1,125 @@ +# ============================================================================= +# Tale LLM Gateway — Dockerfile.dockerignore +# ============================================================================= +# BuildKit picks this file (adjacent to the Dockerfile) over the root +# .dockerignore. It does NOT merge — so this file must list everything we want +# excluded from the gateway image's build context. +# +# Build (from repo root): +# docker build -f services/llm-gateway/Dockerfile . + +# ============================================================================= +# Local environment files +# ============================================================================= +**/.env +**/.env.* + +# ============================================================================= +# Git +# ============================================================================= +.git +.gitignore +.gitattributes + +# ============================================================================= +# CI / tooling +# ============================================================================= +.github/ +.husky/ +.claude/ +.agents/ +.vscode/ +.idea/ +.ruff_cache/ +.turbo/ +.trivyignore +.oxlintrc.json +.oxfmtrc.json + +# ============================================================================= +# Documentation +# ============================================================================= +*.md +docs/ + +# ============================================================================= +# IDE / OS +# ============================================================================= +*.swp +*.swo +*~ +.DS_Store + +# ============================================================================= +# Node +# ============================================================================= +node_modules/ +**/node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# ============================================================================= +# Build artifacts +# ============================================================================= +*.tsbuildinfo +**/dist/ +**/build/ +**/.output/ +**/.vinxi/ + +# ============================================================================= +# Testing +# ============================================================================= +tests/ +**/coverage/ +.nyc_output/ +*.test.ts +*.test.js +*.spec.ts +*.spec.js + +# ============================================================================= +# Storybook +# ============================================================================= +.storybook/ +**/.storybook/ +**/*.stories.tsx +**/*.stories.ts +**/*.stories.jsx +**/*.stories.js +storybook-static/ + +# ============================================================================= +# Logs / temp / cache / misc +# ============================================================================= +*.log +*.tmp +*.temp +.cache/ +.playwright-mcp/ +knip-results.json +designs/ + +# ============================================================================= +# Docker files +# ============================================================================= +docker-compose.yml +docker-compose.*.yml +compose.yml +compose.*.yml +.dockerignore +**/Dockerfile.dockerignore + +# ============================================================================= +# LLM-gateway-specific: the upstream image needs no project source code +# ============================================================================= +services/platform/ +services/web/ +services/convex/ +services/db/ +packages/ +tools/ +builtin-configs/ +patches/ diff --git a/services/llm-gateway/README.md b/services/llm-gateway/README.md new file mode 100644 index 0000000000..e4ab21c3a2 --- /dev/null +++ b/services/llm-gateway/README.md @@ -0,0 +1,38 @@ +# @tale/llm-gateway + +The LLM gateway ([maximhq/bifrost](https://github.com/maximhq/bifrost) core). The single path from an in-sandbox coding agent (Claude Code / OpenCode) to an LLM. + +## Overview + +Raw provider API keys live ONLY here and in the platform. The sandbox holds a session-scoped `sk-bf-*` virtual key (budget + model allowlist), revoked at session destroy. The platform is the source of truth for providers/models; it provisions the gateway via the management API on session create (`convex/node_only/sandbox/llm_gateway_admin.ts`). + +Dual-homed onto two Docker networks: + +- `internal` — the platform provisions providers + mints session virtual keys via the management API. +- `sandbox` — in-sandbox agents reach it at `http://llm-gateway:8080` over the internal bridge (NOT through the tinyproxy egress). + +## Interface + +Ports: + +- `8080` — management API (`/api/*`) + inference. No published port in `compose.yml` by design (production posture); the host bun-dev path publishes it on loopback via `compose.llm-gateway.dev.yml`. + +## Configuration + +- `LLM_GATEWAY_URL` — where the platform reaches the management API (default `http://llm-gateway:8080`). +- `LLM_GATEWAY_ADMIN_USERNAME` — management API basic-auth user (default `admin`). +- `LLM_GATEWAY_ADMIN_PASSWORD` — management API basic-auth password. When set, the management plane stops being anonymous on the internal network. +- `LLM_GATEWAY_STREAM_IDLE_TIMEOUT_SECONDS` — per-stream idle timeout passed to the gateway. + +Auth + virtual-key enforcement are config-store fields the platform pushes via `applyGatewayConfig()`, not env knobs on this container. + +## Development + +```bash +bun run logs --filter=@tale/llm-gateway # docker compose logs -f llm-gateway +bun run shell --filter=@tale/llm-gateway # exec into the running container +``` + +## Layout + +- `Dockerfile` — thin wrapper re-tagging the upstream `maximhq/bifrost` image with Tale OCI metadata + a healthcheck. diff --git a/services/llm-gateway/package.json b/services/llm-gateway/package.json new file mode 100644 index 0000000000..5ef6e02458 --- /dev/null +++ b/services/llm-gateway/package.json @@ -0,0 +1,13 @@ +{ + "name": "@tale/llm-gateway", + "version": "0.1.0", + "private": true, + "scripts": { + "setup": "echo 'Docker service - no local setup'", + "serve": "docker compose up llm-gateway -d && docker compose logs -f llm-gateway", + "build": "echo 'No build step'", + "docker:build": "docker compose build llm-gateway", + "logs": "docker compose logs -f llm-gateway", + "shell": "docker exec -it tale-llm-gateway sh" + } +} diff --git a/services/platform/app/features/agents/components/agent-navigation.tsx b/services/platform/app/features/agents/components/agent-navigation.tsx index 78bcc477d9..4f3903bc79 100644 --- a/services/platform/app/features/agents/components/agent-navigation.tsx +++ b/services/platform/app/features/agents/components/agent-navigation.tsx @@ -63,6 +63,7 @@ const AGENT_TAB_DIRTY_KEYS = { 'supportedModels', 'provider', 'authMode', + 'nativeWebTools', 'structuredResponsesEnabled', 'maxSteps', 'timeoutMs', diff --git a/services/platform/app/features/agents/utils/next-config-for-behavior.test.ts b/services/platform/app/features/agents/utils/next-config-for-behavior.test.ts index c3169d4ba3..f415dcf43f 100644 --- a/services/platform/app/features/agents/utils/next-config-for-behavior.test.ts +++ b/services/platform/app/features/agents/utils/next-config-for-behavior.test.ts @@ -30,6 +30,7 @@ const externalAgentConfig: AgentJsonConfig = { primaryBehavior: 'external-agent', agentKind: 'claude-code', authMode: 'managed', + nativeWebTools: true, integrationBindings: ['github'], supportedModels: ['openrouter:anthropic/claude-sonnet-4.6'], }; @@ -63,13 +64,16 @@ describe('nextConfigForBehavior', () => { expect(applyPatch(opencode, patch).agentKind).toBe('opencode'); }); - it('external-agent → chat clears agentKind/authMode and stays valid', () => { + it('external-agent → chat clears agentKind/authMode/nativeWebTools and stays valid', () => { const patch = nextConfigForBehavior(externalAgentConfig, 'chat'); const merged = applyPatch(externalAgentConfig, patch); expect(merged.primaryBehavior).toBe('chat'); expect(merged.agentKind).toBeUndefined(); expect(merged.authMode).toBeUndefined(); + // nativeWebTools is external-agent-only (superRefine); leaving it set would + // fail validation on the now-chat agent. + expect(merged.nativeWebTools).toBeUndefined(); expect(agentJsonSchema.safeParse(merged).success).toBe(true); }); diff --git a/services/platform/app/features/agents/utils/next-config-for-behavior.ts b/services/platform/app/features/agents/utils/next-config-for-behavior.ts index 51380c8ac5..2a9abb74cc 100644 --- a/services/platform/app/features/agents/utils/next-config-for-behavior.ts +++ b/services/platform/app/features/agents/utils/next-config-for-behavior.ts @@ -17,7 +17,8 @@ export type AgentPrimaryBehavior = NonNullable< * `toolNames` / `workflows` must be empty (hard error otherwise). * `image-generation` additionally forbids `integrationBindings`; * `external-agent` KEEPS them (they are the sandbox MCP grant set). - * - `agentKind` / `authMode` are valid ONLY for `external-agent`. + * - `agentKind` / `authMode` / `nativeWebTools` are valid ONLY for + * `external-agent`. * * Tool-loop-only retrieval/tuning fields (`webSearchMode`, `knowledgeMode`, * `responseTuning`, `skillBindings`, `structuredResponsesEnabled`, …) are NOT @@ -39,6 +40,7 @@ export function nextConfigForBehavior( primaryBehavior: 'chat', agentKind: undefined, authMode: undefined, + nativeWebTools: undefined, }; case 'external-agent': return { @@ -57,6 +59,7 @@ export function nextConfigForBehavior( integrationBindings: undefined, agentKind: undefined, authMode: undefined, + nativeWebTools: undefined, }; default: { const _exhaustive: never = target; diff --git a/services/platform/app/routes/dashboard/$id/agents/$agentId/instructions.tsx b/services/platform/app/routes/dashboard/$id/agents/$agentId/instructions.tsx index 9e125fb50f..cf0114ec96 100644 --- a/services/platform/app/routes/dashboard/$id/agents/$agentId/instructions.tsx +++ b/services/platform/app/routes/dashboard/$id/agents/$agentId/instructions.tsx @@ -496,6 +496,22 @@ function InstructionsTab() { )} + {isExternalAgent && !isByo && ( + + + updateConfig({ nativeWebTools: checked }) + } + label={t('agents.form.webTools.nativeLabel')} + description={t('agents.form.webTools.nativeDescription')} + /> + + )} + __` for custom providers; `/` for // standard) — must match mintVirtualKey's resolver. BYO: pass the raw // model id straight through to the provider (no slug / catalog). @@ -802,6 +810,9 @@ export const runExternalAgentTurn = internalAction({ : undefined : resolveGatewayRoutingFromRef(args.modelRef).gatewayModel, authMode: byo ? 'byo' : 'managed', + ...(args.nativeWebTools !== undefined && { + nativeWebTools: args.nativeWebTools, + }), // Raise the browser-handoff card the moment the agent calls // request_human_control — mid-stream, not at turn end (a lingering // session may not terminate for a while). Idempotent on the mutation diff --git a/services/platform/convex/agents/external_agent/turn_lifecycle.test.ts b/services/platform/convex/agents/external_agent/turn_lifecycle.test.ts index 1e6790ced0..dde3c9e111 100644 --- a/services/platform/convex/agents/external_agent/turn_lifecycle.test.ts +++ b/services/platform/convex/agents/external_agent/turn_lifecycle.test.ts @@ -44,7 +44,7 @@ vi.mock('../../_generated/api', () => ({ }, })); -vi.mock('../../node_only/sandbox/bifrost_admin', () => ({ +vi.mock('../../node_only/sandbox/llm_gateway_admin', () => ({ getVirtualKeySpendCents: vi.fn().mockResolvedValue(0), revokeVirtualKey: vi.fn().mockResolvedValue(undefined), })); diff --git a/services/platform/convex/agents/external_agent/turn_lifecycle.ts b/services/platform/convex/agents/external_agent/turn_lifecycle.ts index 0a13e96c47..1db92a33c2 100644 --- a/services/platform/convex/agents/external_agent/turn_lifecycle.ts +++ b/services/platform/convex/agents/external_agent/turn_lifecycle.ts @@ -18,11 +18,11 @@ import { components, internal } from '../../_generated/api'; import type { Id } from '../../_generated/dataModel'; import type { ActionCtx } from '../../_generated/server'; import type { AgentAssistantContent } from '../../node_only/sandbox/agent_message_parts'; +import { sessionListFiles } from '../../node_only/sandbox/helpers/session_client'; import { getVirtualKeySpendCents, revokeVirtualKey, -} from '../../node_only/sandbox/bifrost_admin'; -import { sessionListFiles } from '../../node_only/sandbox/helpers/session_client'; +} from '../../node_only/sandbox/llm_gateway_admin'; import type { RunAgentInSessionResult } from '../../node_only/sandbox/run_agent'; import { matchConsumedSteerFiles, @@ -135,7 +135,7 @@ export async function finalizeTurnSideEffects( if (turn.mintedKeyId) { try { - // Bifrost aggregates per-VK spend asynchronously (~seconds); poll briefly. + // The gateway aggregates per-VK spend asynchronously (~seconds); poll briefly. let costCents: number | null = null; for (let attempt = 0; attempt < 5; attempt++) { costCents = await getVirtualKeySpendCents(turn.mintedKeyId); @@ -167,7 +167,7 @@ export async function finalizeTurnSideEffects( // cumulative Spend column reflects this turn — the seam writes only cover // multi-segment turns; a single-segment turn lands its spend here. Same // `> 0` guard as the ledger write above (never stamp a misleading $0.00 - // before Bifrost has aggregated). Own try/catch so a stamp failure can't + // before the gateway has aggregated). Own try/catch so a stamp failure can't // block the VK revoke below. const finalSpendCents = costCents ?? 0; if (finalSpendCents > 0) { diff --git a/services/platform/convex/agents/file_utils.ts b/services/platform/convex/agents/file_utils.ts index 0ec57642a8..d308505382 100644 --- a/services/platform/convex/agents/file_utils.ts +++ b/services/platform/convex/agents/file_utils.ts @@ -78,6 +78,13 @@ export interface AgentJsonConfig { * there is no separate org-level gate. */ authMode?: 'managed' | 'byo'; + /** + * For `primaryBehavior: 'external-agent'` only — opt into the runtime's native + * web tools (Claude Code WebSearch/WebFetch). Managed runs force-disable these + * by default (governed routing through a search integration); `true` lifts the + * denial. Absent/`false` keeps the governed default; BYO is unaffected. + */ + nativeWebTools?: boolean; systemInstructions?: string; toolNames?: string[]; integrationBindings?: string[]; diff --git a/services/platform/convex/governance/budget_enforcement.ts b/services/platform/convex/governance/budget_enforcement.ts index ede37c5cc3..3a880b05b3 100644 --- a/services/platform/convex/governance/budget_enforcement.ts +++ b/services/platform/convex/governance/budget_enforcement.ts @@ -599,7 +599,7 @@ export async function checkBudget( /** * The tightest remaining COST headroom (cents) across every applicable budget - * period/scope — for sizing a per-task hard ceiling (the external-agent Bifrost + * period/scope — for sizing a per-task hard ceiling (the external-agent gateway * VK budget) so the gateway's own cap can't exceed the rolling cap, even * between the seam-level budget checks. * diff --git a/services/platform/convex/governance/internal_queries.ts b/services/platform/convex/governance/internal_queries.ts index 7337c801aa..dcc4718260 100644 --- a/services/platform/convex/governance/internal_queries.ts +++ b/services/platform/convex/governance/internal_queries.ts @@ -150,7 +150,7 @@ export const checkBudgetForRequest = internalQuery({ * counts toward the cap at each continuation seam — not just retrospective * ledger rows. Over budget → the caller pauses the turn cleanly at the seam. * - `rollingRemainingCents`: the tightest remaining cost headroom, for sizing - * the per-turn Bifrost VK so the gateway's hard cap == the rolling cap + * the per-turn gateway VK so the gateway's hard cap == the rolling cap * (null = uncapped → caller uses its flat default). */ export const evaluateExternalAgentBudget = internalQuery({ diff --git a/services/platform/convex/integrations/dispatch_http.ts b/services/platform/convex/integrations/dispatch_http.ts index 14dcc476d8..22d3b7c997 100644 --- a/services/platform/convex/integrations/dispatch_http.ts +++ b/services/platform/convex/integrations/dispatch_http.ts @@ -6,7 +6,7 @@ * POST /api/integrations/execute body {slug, operation, args} * POST /api/integrations/status (no body) * - * Auth: the bridge presents the per-session Bifrost virtual key (already in the + * Auth: the bridge presents the per-session gateway virtual key (already in the * container env) as `Authorization: Bearer `. We hash it (sha256, matching * `hashVirtualKey`) and look it up in sandboxSessionTokens; organizationId and * the dispatch grant set (scope.integrationGrants = the agent's diff --git a/services/platform/convex/lib/agent_chat/start_agent_chat.ts b/services/platform/convex/lib/agent_chat/start_agent_chat.ts index f162874207..912630bbd5 100644 --- a/services/platform/convex/lib/agent_chat/start_agent_chat.ts +++ b/services/platform/convex/lib/agent_chat/start_agent_chat.ts @@ -656,6 +656,9 @@ export async function startAgentChat( ...(enforcedConfig.authMode !== undefined && { authMode: enforcedConfig.authMode, }), + ...(enforcedConfig.nativeWebTools !== undefined && { + nativeWebTools: enforcedConfig.nativeWebTools, + }), // Single mode-resolution point: every turn entry (composer send, queue // drain, plan approval) re-enters here and reads the thread's sticky // plan/act posture fresh. diff --git a/services/platform/convex/lib/agent_chat/types.ts b/services/platform/convex/lib/agent_chat/types.ts index 9522d8ed8d..5165a05fcb 100644 --- a/services/platform/convex/lib/agent_chat/types.ts +++ b/services/platform/convex/lib/agent_chat/types.ts @@ -37,6 +37,13 @@ export interface SerializableAgentConfig { * there is no separate org-level gate. */ authMode?: 'managed' | 'byo'; + /** + * For `primaryBehavior: 'external-agent'` only — opt into the runtime's native + * web tools (Claude Code WebSearch/WebFetch). Managed runs force-disable these + * by default; `true` lifts the denial. Absent/`false` keeps the governed + * default; BYO is unaffected. + */ + nativeWebTools?: boolean; /** System instructions for the agent (empty for image-generation agents with no style prefix) */ instructions: string; /** List of Convex tool names to enable */ diff --git a/services/platform/convex/node_only/sandbox/integration_skills.test.ts b/services/platform/convex/node_only/sandbox/integration_skills.test.ts new file mode 100644 index 0000000000..40adb26f9a --- /dev/null +++ b/services/platform/convex/node_only/sandbox/integration_skills.test.ts @@ -0,0 +1,65 @@ +// Unit tests for the integration SKILL.md builder. The web-access guidance MUST +// match the agent's actual toolset (native vs. governed) so the agent never gets +// told its working tools are disabled, and the github appendix must route a git +// auth failure into the perceive→guide flow. + +import { describe, expect, it } from 'vitest'; + +import type { IntegrationCatalogEntry } from '../../integrations/file_actions'; +import { buildIntegrationSkillMd } from './integration_skills'; + +const tavily: IntegrationCatalogEntry = { + slug: 'tavily', + title: 'Tavily Search', + description: 'Web search and page extraction', + operations: [ + { name: 'search', description: 'Search the web', operationType: 'read' }, + ], +}; + +const github: IntegrationCatalogEntry = { + slug: 'github', + title: 'GitHub', + description: 'Repos, issues, and pull requests', + operations: [{ name: 'list_issues', operationType: 'read' }], +}; + +describe('buildIntegrationSkillMd — web-access guidance', () => { + it('says web tools are DISABLED when the agent has no native web tools (governed default)', () => { + const md = buildIntegrationSkillMd(tavily, { nativeWebTools: false }); + expect(md).toContain('WebSearch and WebFetch tools are DISABLED'); + expect(md).not.toContain('You have native WebSearch and WebFetch'); + }); + + it('says native web tools are available (and not "disabled") when the agent has them', () => { + const md = buildIntegrationSkillMd(tavily, { nativeWebTools: true }); + expect(md).toContain('You have native WebSearch and WebFetch'); + expect(md).not.toContain('DISABLED'); + // Native agents must not be told to connect a search integration for + // ordinary public-web lookups. + expect(md).toContain('public-web lookups'); + }); + + it('always carries the not_bound / not_configured perceive→guide guidance regardless of web-tool mode', () => { + for (const nativeWebTools of [true, false]) { + const md = buildIntegrationSkillMd(tavily, { nativeWebTools }); + expect(md).toContain('not_bound'); + expect(md).toContain('not_configured'); + expect(md).toContain('integration_status'); + } + }); +}); + +describe('buildIntegrationSkillMd — github appendix', () => { + it('appends the git-clone/auth-failure guidance only for github', () => { + const md = buildIntegrationSkillMd(github, { nativeWebTools: true }); + expect(md).toContain('## Cloning or pushing a repo'); + expect(md).toContain('git clone'); + expect(md).toContain('integration_status'); + }); + + it('omits the github appendix for other integrations', () => { + const md = buildIntegrationSkillMd(tavily, { nativeWebTools: true }); + expect(md).not.toContain('## Cloning or pushing a repo'); + }); +}); diff --git a/services/platform/convex/node_only/sandbox/integration_skills.ts b/services/platform/convex/node_only/sandbox/integration_skills.ts index 74b41ba817..e26ff53c38 100644 --- a/services/platform/convex/node_only/sandbox/integration_skills.ts +++ b/services/platform/convex/node_only/sandbox/integration_skills.ts @@ -75,9 +75,54 @@ function yamlInline(value: string): string { return value.replace(/"/g, "'").replace(/\s+/g, ' ').trim().slice(0, 280); } -/** Build one CC-native SKILL.md for an integration (readiness-independent). */ +/** Web-access guidance when the agent's native web tools are FORCE-DISABLED + * (managed default). Routes all web access through a connected integration. */ +const WEB_ACCESS_DISABLED = `The built-in WebSearch and WebFetch tools are DISABLED — route ALL web access +through a connected integration: search the web via a search integration's +\`search\` operation, and read a specific page via its \`extract\`/fetch +operation. Never use the browser to scrape a search engine or fetch pages as a +substitute; if no suitable integration is connected, guide the user to add one.`; + +/** Web-access guidance when the agent HAS native web tools (BYO, or a managed + * agent that opted in via nativeWebTools). Integrations are for authenticated / + * governed data sources, NOT ordinary public-web lookups. */ +const WEB_ACCESS_NATIVE = `You have native WebSearch and WebFetch for general web reading and search — +use them directly for open-web facts, docs, and public pages. Use an integration +ONLY for AUTHENTICATED or governed data sources: a private API, or your own +accounts and their data. Do NOT push the user to connect a web-search +integration for ordinary public-web lookups.`; + +/** Per-integration extra guidance, appended after the generic "If it is not + * available" section. Keyed by slug; absent ⇒ no appendix. Lives here (not in + * the integration-agnostic body) for integrations whose perception of a missing + * capability differs from the standard bridge-tool blocker flow. */ +const SLUG_APPENDIX: Record = { + // GitHub is a BROKER GRANT: git access uses an injected token, not the + // bridge tool, so a clone/push auth failure surfaces as a RAW git error + // rather than a structured blocker. Teach the agent to recognize that and + // route into the same perceive→guide flow (integration_status + connectUrl). + github: ` +## Cloning or pushing a repo + +GitHub also backs \`git\` here: when this agent has github both enabled AND +connected, a token is injected so \`git clone\`/\`fetch\`/\`push\` over HTTPS just +works. Public repos clone without a token. + +If a \`git\` operation fails with an auth error ("could not read Username", +"Authentication failed", or an unexpected "Repository not found" on a repo you +expect to exist), do NOT retry blindly or give up — that almost always means +GitHub is not enabled for this agent or has no connected credential. Call +\`integration_status\`, then relay github's \`not_bound\`/\`not_configured\` +guidance and its \`connectUrl\` to the user in ONE message and stop until they +fix it. +`, +}; + +/** Build one CC-native SKILL.md for an integration (readiness-independent). The + * web-access guidance varies with whether the agent has native web tools. */ export function buildIntegrationSkillMd( entry: IntegrationCatalogEntry, + opts: { nativeWebTools: boolean }, ): string { const title = entry.title ?? entry.slug; const summary = entry.description ?? `The ${title} integration.`; @@ -121,12 +166,8 @@ in a single message rather than one at a time: \`connectUrl\`). Call \`integration_status\` anytime to see which integrations are usable now. -The built-in WebSearch and WebFetch tools are DISABLED — route ALL web access -through a connected integration: search the web via a search integration's -\`search\` operation, and read a specific page via its \`extract\`/fetch -operation. Never use the browser to scrape a search engine or fetch pages as a -substitute; if no suitable integration is connected, guide the user to add one. -`; +${opts.nativeWebTools ? WEB_ACCESS_NATIVE : WEB_ACCESS_DISABLED} +${SLUG_APPENDIX[entry.slug] ?? ''}`; } /** @@ -137,7 +178,13 @@ substitute; if no suitable integration is connected, guide the user to add one. */ export async function stageIntegrationSkills( ctx: ActionCtx, - args: { organizationId: string; sessionId: string }, + args: { + organizationId: string; + sessionId: string; + /** Whether the agent has native web tools — selects the skill's web-access + * guidance so it never contradicts the agent's actual toolset. */ + nativeWebTools: boolean; + }, ): Promise { const orgSlug = await orgSlugFromId(ctx, args.organizationId); const catalog: IntegrationCatalogEntry[] = await ctx.runAction( @@ -170,9 +217,10 @@ export async function stageIntegrationSkills( if (catalog.length === 0) return; const files: SessionStageFile[] = catalog.map((entry) => ({ path: `${SKILLS_DIR}/${INTEGRATION_SKILL_PREFIX}${entry.slug}/SKILL.md`, - contentBase64: Buffer.from(buildIntegrationSkillMd(entry), 'utf8').toString( - 'base64', - ), + contentBase64: Buffer.from( + buildIntegrationSkillMd(entry, { nativeWebTools: args.nativeWebTools }), + 'utf8', + ).toString('base64'), })); const result = await sessionStageFiles(args.sessionId, files); if (result.skipped.length > 0) { diff --git a/services/platform/convex/node_only/sandbox/bifrost_admin.test.ts b/services/platform/convex/node_only/sandbox/llm_gateway_admin.test.ts similarity index 94% rename from services/platform/convex/node_only/sandbox/bifrost_admin.test.ts rename to services/platform/convex/node_only/sandbox/llm_gateway_admin.test.ts index 4d1f030697..2cd604dcc0 100644 --- a/services/platform/convex/node_only/sandbox/bifrost_admin.test.ts +++ b/services/platform/convex/node_only/sandbox/llm_gateway_admin.test.ts @@ -23,7 +23,7 @@ interface RecordedCall { * PUT /api/providers/:p/keys/* → rotate key (200) * Returns the recorded calls, in order. */ -function stubBifrost(opts: { +function stubGateway(opts: { keyExists?: boolean; writeStatus?: number; }): RecordedCall[] { @@ -68,7 +68,7 @@ function stubBifrost(opts: { * (the "new Node process" state). */ async function loadModule() { vi.resetModules(); - return import('./bifrost_admin'); + return import('./llm_gateway_admin'); } function writes(calls: RecordedCall[]): RecordedCall[] { @@ -84,7 +84,7 @@ afterEach(() => { describe('provisionProviders', () => { it('creates an absent org key: config PUT + key POST, stable per-org name, models translated', async () => { const mod = await loadModule(); - const calls = stubBifrost({ keyExists: false }); + const calls = stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [PROVIDER]); const w = writes(calls); expect(w.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([ @@ -93,7 +93,7 @@ describe('provisionProviders', () => { ]); // provider config PUT carries no keys[] (keys are a sub-resource now) and, // for a standard provider, no base_url override + no custom_provider_config - // (Bifrost would 400 the latter) — the proven native path is unchanged. + // (the gateway would 400 the latter) — the proven native path is unchanged. expect(w[0]?.body?.keys).toBeUndefined(); expect(w[0]?.body?.custom_provider_config).toBeUndefined(); const networkConfig = w[0]?.body?.network_config as Record; @@ -115,7 +115,7 @@ describe('provisionProviders', () => { it('rotates a present org key with PUT to /keys/:id (not POST)', async () => { const mod = await loadModule(); // Present key, but fresh memo → rewrite once. - const calls = stubBifrost({ keyExists: true }); + const calls = stubGateway({ keyExists: true }); await mod.provisionProviders(ORG, [PROVIDER]); const w = writes(calls); expect(w.map((c) => c.method)).toEqual(['PUT', 'PUT']); @@ -126,9 +126,9 @@ describe('provisionProviders', () => { it('skips entirely when the key exists and the fingerprint matches (one GET, no writes)', async () => { const mod = await loadModule(); - stubBifrost({ keyExists: false }); + stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [PROVIDER]); // first push - const calls = stubBifrost({ keyExists: true }); + const calls = stubGateway({ keyExists: true }); await mod.provisionProviders(ORG, [PROVIDER]); // memo + key present expect(writes(calls)).toEqual([]); expect(calls.map((c) => c.method)).toEqual(['GET']); @@ -136,19 +136,19 @@ describe('provisionProviders', () => { it('rewrites when the gateway lost the key even though the memo matches', async () => { const mod = await loadModule(); - stubBifrost({ keyExists: false }); + stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [PROVIDER]); - // e.g. bifrost-data volume wiped while this process stayed alive - const calls = stubBifrost({ keyExists: false }); + // e.g. llm-gateway-data volume wiped while this process stayed alive + const calls = stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [PROVIDER]); expect(writes(calls).map((c) => c.method)).toEqual(['PUT', 'POST']); }); it('rewrites when the key rotates', async () => { const mod = await loadModule(); - stubBifrost({ keyExists: false }); + stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [PROVIDER]); - const calls = stubBifrost({ keyExists: true }); + const calls = stubGateway({ keyExists: true }); await mod.provisionProviders(ORG, [{ ...PROVIDER, apiKey: 'key-B' }]); const w = writes(calls); expect(w.map((c) => c.method)).toEqual(['PUT', 'PUT']); @@ -158,7 +158,7 @@ describe('provisionProviders', () => { it('a failed write warns + leaves no memo (no throw), so the next provision retries', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const mod = await loadModule(); - stubBifrost({ keyExists: false, writeStatus: 500 }); + stubGateway({ keyExists: false, writeStatus: 500 }); // provisionProviders is per-provider resilient: warns + continues, never throws. await expect( mod.provisionProviders(ORG, [PROVIDER]), @@ -168,17 +168,17 @@ describe('provisionProviders', () => { expect.anything(), ); // memo unset on failure → next provision retries the full write. - const retry = stubBifrost({ keyExists: false }); + const retry = stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [PROVIDER]); expect(writes(retry).map((c) => c.method)).toEqual(['PUT', 'POST']); - const third = stubBifrost({ keyExists: true }); + const third = stubGateway({ keyExists: true }); await mod.provisionProviders(ORG, [PROVIDER]); expect(writes(third)).toEqual([]); }); it('sends no attribution extra_headers for a non-OpenRouter provider', async () => { const mod = await loadModule(); - const calls = stubBifrost({ keyExists: false }); + const calls = stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [ { name: 'anthropic', @@ -194,9 +194,9 @@ describe('provisionProviders', () => { it('two orgs coexist under one provider (distinct per-org key names)', async () => { const mod = await loadModule(); - const a = stubBifrost({ keyExists: false }); + const a = stubGateway({ keyExists: false }); await mod.provisionProviders('orgA', [PROVIDER]); - const b = stubBifrost({ keyExists: false }); + const b = stubGateway({ keyExists: false }); await mod.provisionProviders('orgB', [PROVIDER]); expect(writes(a)[1]?.body?.name).toBe('tale-orgA-openrouter'); expect(writes(b)[1]?.body?.name).toBe('tale-orgB-openrouter'); @@ -211,7 +211,7 @@ describe('provisionProviders', () => { it('provisions a custom (non-standard) provider as OpenAI-compatible with base_url + custom_provider_config', async () => { const mod = await loadModule(); - const calls = stubBifrost({ keyExists: false }); + const calls = stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [CUSTOM]); const w = writes(calls); expect(w.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([ @@ -219,7 +219,7 @@ describe('provisionProviders', () => { 'POST /api/providers/deepseek/keys', ]); const networkConfig = w[0]?.body?.network_config as Record; - // Trailing /v1 stripped — Bifrost's openai handler appends it itself. + // Trailing /v1 stripped — the gateway's openai handler appends it itself. expect(networkConfig.base_url).toBe('https://api.deepseek.com'); expect(w[0]?.body?.custom_provider_config).toEqual({ base_provider_type: 'openai', @@ -239,7 +239,7 @@ describe('provisionProviders', () => { it('leaves a custom base_url without a trailing /v1 unchanged', async () => { const mod = await loadModule(); - const calls = stubBifrost({ keyExists: false }); + const calls = stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [ { ...CUSTOM, baseUrl: 'https://api.deepseek.com' }, ]); @@ -252,7 +252,7 @@ describe('provisionProviders', () => { it('provisions an apiFormat:"anthropic" custom provider with base_provider_type anthropic, no allowed_requests, un-stripped base_url', async () => { const mod = await loadModule(); - const calls = stubBifrost({ keyExists: false }); + const calls = stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [ { ...CUSTOM, @@ -394,9 +394,9 @@ describe('provisionProviders', () => { warn.mockRestore(); }); - it('treats a Bifrost standard provider (fireworks) natively — no base_url, no custom_provider_config', async () => { + it('treats a gateway standard provider (fireworks) natively — no base_url, no custom_provider_config', async () => { const mod = await loadModule(); - const calls = stubBifrost({ keyExists: false }); + const calls = stubGateway({ keyExists: false }); await mod.provisionProviders(ORG, [ { name: 'fireworks', @@ -521,7 +521,7 @@ describe('provisionProviders', () => { describe('reprovisionProvider', () => { it('creates the org key on a fresh process', async () => { const mod = await loadModule(); - const calls = stubBifrost({ keyExists: false }); + const calls = stubGateway({ keyExists: false }); await mod.reprovisionProvider(ORG, PROVIDER); expect(writes(calls).map((c) => c.method)).toEqual(['PUT', 'POST']); expect(writes(calls)[1]?.body?.name).toBe(KEY_NAME); @@ -530,7 +530,7 @@ describe('reprovisionProvider', () => { it('skips a custom provider with no base URL (warns, no gateway calls)', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const mod = await loadModule(); - const calls = stubBifrost({}); + const calls = stubGateway({}); // Non-standard name + no baseUrl → nothing to point a custom provider at. await mod.reprovisionProvider(ORG, { ...PROVIDER, name: 'my-custom-llm' }); expect(calls).toEqual([]); @@ -539,10 +539,10 @@ describe('reprovisionProvider', () => { ); }); - it('sends Basic auth when BIFROST_ADMIN_PASSWORD is set', async () => { - vi.stubEnv('BIFROST_ADMIN_PASSWORD', 'hunter2'); + it('sends Basic auth when LLM_GATEWAY_ADMIN_PASSWORD is set', async () => { + vi.stubEnv('LLM_GATEWAY_ADMIN_PASSWORD', 'hunter2'); const mod = await loadModule(); - const calls = stubBifrost({ keyExists: false }); + const calls = stubGateway({ keyExists: false }); await mod.reprovisionProvider(ORG, PROVIDER); expect(calls[0]?.headers.authorization).toBe( `Basic ${Buffer.from('admin:hunter2').toString('base64')}`, @@ -551,7 +551,7 @@ describe('reprovisionProvider', () => { it('throws on a failed write (eager push owns the degrade posture)', async () => { const mod = await loadModule(); - stubBifrost({ keyExists: false, writeStatus: 500 }); + stubGateway({ keyExists: false, writeStatus: 500 }); // Unlike provisionProviders (resilient), reprovisionProvider surfaces the // failure to its caller — the provider-save action decides how to degrade. await expect(mod.reprovisionProvider(ORG, PROVIDER)).rejects.toThrow( diff --git a/services/platform/convex/node_only/sandbox/bifrost_admin.ts b/services/platform/convex/node_only/sandbox/llm_gateway_admin.ts similarity index 82% rename from services/platform/convex/node_only/sandbox/bifrost_admin.ts rename to services/platform/convex/node_only/sandbox/llm_gateway_admin.ts index be6c5740f5..91b3b6791b 100644 --- a/services/platform/convex/node_only/sandbox/bifrost_admin.ts +++ b/services/platform/convex/node_only/sandbox/llm_gateway_admin.ts @@ -1,8 +1,8 @@ 'use node'; -// Bifrost governance-API client. The platform is the source of truth for -// providers + models; Bifrost is a derived cache. This module: -// - provisions/reconciles the org's providers + upstream keys into Bifrost, +// LLM gateway governance-API client. The platform is the source of truth for +// providers + models; the gateway is a derived cache. This module: +// - provisions/reconciles the org's providers + upstream keys into the gateway, // - mints a session-scoped virtual key (budget + model allowlist) at session // create, returning the plaintext `sk-bf-*` (injected into the sandbox) // plus the key id (stored, with the key's sha256, in sandboxSessionTokens), @@ -10,7 +10,7 @@ // - pulls per-key usage for the watchdog → usageLedger sync. // // Raw provider API keys + the management token are Tier-0 secrets — they live -// only here (Convex) and in Bifrost, never in the sandbox. +// only here (Convex) and in the gateway, never in the sandbox. // // NOTE: endpoint paths + field names are verified against the pinned // maximhq/bifrost:v1.5.13 (spike 2026-06-13; see @@ -30,28 +30,28 @@ import { createHash } from 'node:crypto'; import { sanitizeError } from '../../lib/utils/sanitize_secrets'; import { providerAttributionHeaders } from '../../providers/provider_attribution'; -function bifrostUrl(): string { - return process.env.BIFROST_URL ?? 'http://bifrost:8080'; +function llmGatewayUrl(): string { + return process.env.LLM_GATEWAY_URL ?? 'http://llm-gateway:8080'; } -/** Admin username for the Bifrost management plane (auth_config). */ +/** Admin username for the gateway management plane (auth_config). */ function adminUsername(): string { - return process.env.BIFROST_ADMIN_USERNAME ?? 'admin'; + return process.env.LLM_GATEWAY_ADMIN_USERNAME ?? 'admin'; } /** Plaintext admin password, or '' when management auth is not configured * (dev). When set, applyGatewayConfig enables auth_config and every /api/* * call must carry HTTP Basic auth. */ function adminPassword(): string { - return process.env.BIFROST_ADMIN_PASSWORD ?? ''; + return process.env.LLM_GATEWAY_ADMIN_PASSWORD ?? ''; } /** Total per-request timeout pushed to every provider's `network_config`. */ const REQUEST_TIMEOUT_SECONDS = 600; -/** Per-stream IDLE timeout (Bifrost `stream_idle_timeout_in_seconds`): how long - * Bifrost waits for ANY byte from the upstream mid-stream before it aborts with +/** Per-stream IDLE timeout (gateway `stream_idle_timeout_in_seconds`): how long + * the gateway waits for ANY byte from the upstream mid-stream before it aborts with * `ErrStreamIdleTimeout` ("stream idle timeout: no data received within - * configured window"). Bifrost defaults this to 60s. That 60s is fine for a + * configured window"). The gateway defaults this to 60s. That 60s is fine for a * native Anthropic upstream (it pings every ~15-30s), but a CUSTOM * OpenAI-compatible upstream (e.g. an Anthropic↔OpenAI translation gateway) sends * NO keepalive during a long prefill or a silent reasoning gap — so a large-context @@ -60,7 +60,7 @@ const REQUEST_TIMEOUT_SECONDS = 600; * request budget so a silent gap is bounded only by the total timeout, never a * premature idle abort. Operator-tunable. */ const STREAM_IDLE_TIMEOUT_SECONDS = Number( - process.env.BIFROST_STREAM_IDLE_TIMEOUT_SECONDS ?? + process.env.LLM_GATEWAY_STREAM_IDLE_TIMEOUT_SECONDS ?? String(REQUEST_TIMEOUT_SECONDS), ); @@ -68,9 +68,9 @@ function managementHeaders(): Record { const headers: Record = { 'content-type': 'application/json', }; - // Bifrost v1.4.8's APIMiddleware authenticates /api/* with HTTP Basic - // (admin_username/admin_password) — NOT a bearer token (the old - // BIFROST_MANAGEMENT_TOKEN env was never read by bifrost). Send Basic when a + // The gateway (maximhq/bifrost v1.4.8) APIMiddleware authenticates /api/* + // with HTTP Basic (admin_username/admin_password) — NOT a bearer token (the + // old management-token env was never read by the gateway). Send Basic when a // password is configured; harmless before auth_config is enabled, required // after. Omit entirely in dev (no password → management plane open). const pw = adminPassword(); @@ -83,7 +83,7 @@ function managementHeaders(): Record { /** * Tale model refs are colon-qualified (`openrouter:anthropic/claude-sonnet-4.6`, - * optionally with a quantization qualifier like `@fp8`) but Bifrost routes on + * optionally with a quantization qualifier like `@fp8`) but the gateway routes on * the first slash (`provider/model`) and rejects the colon form as an invalid * model ID (verified against v1.4.8). Upstreams don't understand the Tale * quantization qualifier either (the in-platform chat path strips it before @@ -94,19 +94,19 @@ export function toGatewayModelRef(taleModelRef: string): string { return taleModelRef.replace(':', '/').replace(/@[^@/]+$/, ''); } -/** Whether Bifrost has a built-in provider implementation for this name (and so +/** Whether the gateway has a built-in provider implementation for this name (and so * owns its wire format + rejects custom_provider_config). Exported so the * gateway loader can group standard providers (one native record per slug) vs * custom ones (per-model upstreams). */ export function isStandardGatewayProvider(name: string): boolean { - return BIFROST_STANDARD_PROVIDERS.has(name); + return LLM_GATEWAY_STANDARD_PROVIDERS.has(name); } -/** Bifrost provider name for a CUSTOM model's per-model upstream. The model's +/** Gateway provider name for a CUSTOM model's per-model upstream. The model's * effective (baseUrl, apiFormat, key) lives on its own provider record so that - * model-level overrides actually route (Bifrost holds one base_url + + * model-level overrides actually route (the gateway holds one base_url + * base_provider_type per record). Sanitize `/` out of the NAME segment — - * Bifrost routes on the FIRST `/`, so the provider-name part must contain none; + * the gateway routes on the FIRST `/`, so the provider-name part must contain none; * the model id keeps its own form (matched against the key catalog after the * prefix is stripped). */ function customGatewayProviderName(slug: string, modelId: string): string { @@ -114,7 +114,7 @@ function customGatewayProviderName(slug: string, modelId: string): string { } export interface GatewayRouting { - /** Bifrost provider name the request routes to. */ + /** Gateway provider name the request routes to. */ gatewayProvider: string; /** Full gateway model ref (`/`) for ANTHROPIC_MODEL * + the VK allowed_models. */ @@ -122,7 +122,7 @@ export interface GatewayRouting { } /** - * Map a Tale (providerSlug, modelId) onto Bifrost routing. Standard slug → the + * Map a Tale (providerSlug, modelId) onto gateway routing. Standard slug → the * native provider record (`/`); custom slug → the model's own * per-model upstream (`__/`). Single source of truth * shared by the adapter (ANTHROPIC_MODEL), the mint (VK provider binding), and @@ -154,7 +154,7 @@ export function resolveGatewayRoutingFromRef( } export interface MintVirtualKeyArgs { - /** Hard spend cap; Bifrost rejects inference once exhausted. */ + /** Hard spend cap; the gateway rejects inference once exhausted. */ budgetCents: number; /** Models the key may call (org allowlist). */ allowedModels: string[]; @@ -235,8 +235,8 @@ export async function mintVirtualKey( }); } const body = { - // team_id/customer_id are mutually-exclusive FK references in Bifrost; - // we anchor attribution in the (required) name instead. Bifrost has no + // team_id/customer_id are mutually-exclusive FK references in the gateway; + // we anchor attribution in the (required) name instead. The gateway has no // native TTL; the session watchdog revokes on expiry. name: `tale-${args.organizationId}-${args.sessionId}-${Date.now().toString(36)}`, provider_configs: providerConfigs, @@ -248,14 +248,14 @@ export async function mintVirtualKey( }, is_active: true, }; - const res = await fetch(`${bifrostUrl()}/api/governance/virtual-keys`, { + const res = await fetch(`${llmGatewayUrl()}/api/governance/virtual-keys`, { method: 'POST', headers: managementHeaders(), body: JSON.stringify(body), signal: AbortSignal.timeout(15_000), }); if (!res.ok) { - throw new Error(`bifrost mint key failed (${res.status})`); + throw new Error(`llm-gateway mint key failed (${res.status})`); } // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion const parsed = (await res.json()) as { @@ -264,7 +264,7 @@ export async function mintVirtualKey( const key = parsed.virtual_key?.value; const keyId = parsed.virtual_key?.id; if (!key || !keyId) { - throw new Error('bifrost mint key returned no key/id'); + throw new Error('llm-gateway mint key returned no key/id'); } return { key, keyId }; } @@ -273,7 +273,7 @@ export async function mintVirtualKey( * watchdog). Best-effort: a 404 means it's already gone. */ export async function revokeVirtualKey(keyId: string): Promise { const res = await fetch( - `${bifrostUrl()}/api/governance/virtual-keys/${encodeURIComponent(keyId)}`, + `${llmGatewayUrl()}/api/governance/virtual-keys/${encodeURIComponent(keyId)}`, { method: 'DELETE', headers: managementHeaders(), @@ -281,7 +281,7 @@ export async function revokeVirtualKey(keyId: string): Promise { }, ); if (!res.ok && res.status !== 404) { - throw new Error(`bifrost revoke key failed (${res.status})`); + throw new Error(`llm-gateway revoke key failed (${res.status})`); } } @@ -297,7 +297,7 @@ export async function getVirtualKeySpendCents( keyId: string, ): Promise { const res = await fetch( - `${bifrostUrl()}/api/governance/virtual-keys/${encodeURIComponent(keyId)}`, + `${llmGatewayUrl()}/api/governance/virtual-keys/${encodeURIComponent(keyId)}`, { method: 'GET', headers: managementHeaders(), @@ -309,7 +309,7 @@ export async function getVirtualKeySpendCents( // a down gateway is otherwise indistinguishable from "key not found" and // would silently stamp costEstimateCents:0. keyId is an id, not a secret. console.warn( - `[bifrost] spend read failed (${res.status}) for key ${keyId}; degrading to agent-stream spend`, + `[llm-gateway] spend read failed (${res.status}) for key ${keyId}; degrading to agent-stream spend`, ); return null; } @@ -327,17 +327,17 @@ export async function getVirtualKeySpendCents( return dollars === null ? null : dollars * 100; } -/** Provider names Bifrost handles with a built-in implementation (its own base - * URL + request shaping). Mirrors Bifrost's `StandardProviders` - * (core/schemas/bifrost.go @ core/v1.5.13). This is NOT a Tale allowlist of - * "permitted" providers — users may add ANY provider (the chat path treats - * every provider as OpenAI-compatible against its own base URL). It is the set - * Bifrost RESERVES: it rejects `custom_provider_config` on these names with a - * 400 ("cannot be created on standard providers"), and overriding their +/** Provider names the gateway handles with a built-in implementation (its own base + * URL + request shaping). Mirrors the gateway's `StandardProviders` + * (maximhq/bifrost core/schemas/bifrost.go @ core/v1.5.13). This is NOT a Tale + * allowlist of "permitted" providers — users may add ANY provider (the chat path + * treats every provider as OpenAI-compatible against its own base URL). It is the + * set the gateway RESERVES: it rejects `custom_provider_config` on these names with + * a 400 ("cannot be created on standard providers"), and overriding their * `network_config.base_url` breaks the built-in URL construction. So a standard * provider keeps native dispatch; every OTHER provider is provisioned as a * custom OpenAI-compatible upstream (see ensureProviderConfig). */ -const BIFROST_STANDARD_PROVIDERS = new Set([ +const LLM_GATEWAY_STANDARD_PROVIDERS = new Set([ 'openai', 'azure', 'anthropic', @@ -364,14 +364,14 @@ const BIFROST_STANDARD_PROVIDERS = new Set([ ]); export interface ProviderProvision { - /** Bifrost provider name. A standard Bifrost name (see - * BIFROST_STANDARD_PROVIDERS) uses native dispatch; any other name is + /** Gateway provider name. A standard gateway name (see + * LLM_GATEWAY_STANDARD_PROVIDERS) uses native dispatch; any other name is * provisioned as a custom upstream (see resolveGatewayRouting / per-model * naming) and so requires a `baseUrl`. */ name: string; baseUrl?: string; - /** Wire format for a CUSTOM upstream → Bifrost `base_provider_type`. Absent ⇒ - * 'openai'. Ignored for standard providers (Bifrost owns their format). */ + /** Wire format for a CUSTOM upstream → gateway `base_provider_type`. Absent ⇒ + * 'openai'. Ignored for standard providers (the gateway owns their format). */ apiFormat?: 'openai' | 'anthropic'; apiKey: string; /** Tale model refs the key may serve; translated to the gateway spelling. */ @@ -407,7 +407,7 @@ function providerFingerprint(p: ProviderProvision): string { .digest('hex'); } -/** Whether Bifrost has a built-in implementation for this provider name (and so +/** Whether the gateway has a built-in implementation for this provider name (and so * rejects custom_provider_config / a base_url override on it). Standard names * keep native dispatch; everything else is provisioned as a custom * OpenAI-compatible upstream. */ @@ -416,23 +416,23 @@ function isStandardProvider(p: ProviderProvision): boolean { } /** True when a provider cannot be provisioned at all: a non-standard (custom) - * upstream with no base URL. Bifrost requires `network_config.base_url` for a + * upstream with no base URL. The gateway requires `network_config.base_url` for a * custom provider, so there is nothing to point it at — warn + skip. Standard * providers (no base_url needed) and custom providers WITH a base_url both * proceed. */ function skipUnprovisionable(p: ProviderProvision): boolean { if (isStandardProvider(p) || p.baseUrl) return false; console.warn( - `[bifrost] skipping custom provider '${p.name}' (no base URL to route to)`, + `[llm-gateway] skipping custom provider '${p.name}' (no base URL to route to)`, ); return true; } -/** Bifrost's OpenAI handler always appends `/v1/chat/completions` to a custom +/** The gateway's OpenAI handler always appends `/v1/chat/completions` to a custom * provider's base_url, but Tale provider configs store the base URL WITH a * `/v1` (the chat path appends only `/chat/completions`). Strip a trailing * `/v1` (or `/v1/`) before pushing so the gateway builds `/v1/chat/ - * completions`, not `/v1/v1/chat/completions` (Bifrost issue #2356). + * completions`, not `/v1/v1/chat/completions` (maximhq/bifrost issue #2356). * Assumes the upstream exposes chat at `/v1/chat/completions` — the * DeepSeek/Together/standard OpenAI-compatible convention. */ function stripTrailingV1(url: string): string { @@ -447,7 +447,7 @@ function stripTrailingV1(url: string): string { * and the provider: `tale--`. This lets each org's key * coexist under one shared provider record (no last-writer-wins clobber) AND * keeps one org's per-provider keys from colliding with each other. The id is - * bifrost-side state (changes if its store is reset); the NAME is the durable + * gateway-side state (changes if its store is reset); the NAME is the durable * handle the mint path resolves by. */ function gatewayKeyName(organizationId: string, provider: string): string { @@ -465,7 +465,7 @@ interface GatewayKey { * absent. */ async function listProviderKeys(provider: string): Promise { const res = await fetch( - `${bifrostUrl()}/api/providers/${encodeURIComponent(provider)}/keys`, + `${llmGatewayUrl()}/api/providers/${encodeURIComponent(provider)}/keys`, { method: 'GET', headers: managementHeaders(), @@ -477,7 +477,7 @@ async function listProviderKeys(provider: string): Promise { // otherwise look like a clean empty set (e.g. the mint path's fail-closed // resolve), masking the real cause. console.warn( - `[bifrost] list keys for provider ${provider} failed (${res.status}); treating as none`, + `[llm-gateway] list keys for provider ${provider} failed (${res.status}); treating as none`, ); return []; } @@ -500,11 +500,11 @@ async function resolveOrgProviderKeyId( /** DELETE /api/providers/:name — remove a provider RECORD (and its key * sub-resources). v1.5.13 actually deletes the record (verified: GET 404s * after). Used to recreate a custom provider whose immutable - * `base_provider_type` must change (openai↔anthropic) — Bifrost forbids + * `base_provider_type` must change (openai↔anthropic) — the gateway forbids * mutating it in place. Tolerates 404 (already gone). */ async function deleteGatewayProvider(name: string): Promise { const res = await fetch( - `${bifrostUrl()}/api/providers/${encodeURIComponent(name)}`, + `${llmGatewayUrl()}/api/providers/${encodeURIComponent(name)}`, { method: 'DELETE', headers: managementHeaders(), @@ -513,7 +513,7 @@ async function deleteGatewayProvider(name: string): Promise { ); if (!res.ok && res.status !== 404) { throw new Error( - `bifrost delete provider ${name} failed (${res.status}): ${sanitizeError(await res.text())}`, + `llm-gateway delete provider ${name} failed (${res.status}): ${sanitizeError(await res.text())}`, ); } } @@ -524,8 +524,8 @@ async function deleteGatewayProvider(name: string): Promise { * be > 0 or the config validator 400s. Idempotent; safe to call every * provision. * - * A STANDARD Bifrost provider carries its own base URL — overriding it breaks - * the built-in URL construction, and Bifrost rejects custom_provider_config on + * A STANDARD gateway provider carries its own base URL — overriding it breaks + * the built-in URL construction, and the gateway rejects custom_provider_config on * it (400) — so we only widen the timeout and, for OpenRouter, add the Tale * attribution headers: `extra_headers` ride every upstream request the gateway * makes (v1.5.13 NetworkConfig.ExtraHeaders), so sandbox-agent traffic shows up @@ -561,7 +561,7 @@ async function ensureProviderConfig( network_config: { default_request_timeout_in_seconds: REQUEST_TIMEOUT_SECONDS, // Don't let a custom OpenAI-compatible upstream's silent prefill / reasoning - // gap trip Bifrost's 60s default idle abort mid-stream (see the constant). + // gap trip the gateway's 60s default idle abort mid-stream (see the constant). stream_idle_timeout_in_seconds: STREAM_IDLE_TIMEOUT_SECONDS, ...(baseUrl ? { base_url: baseUrl } : {}), ...(Object.keys(attribution).length > 0 @@ -590,7 +590,7 @@ async function ensureProviderConfig( : {}), }; const putConfig = () => - fetch(`${bifrostUrl()}/api/providers/${encodeURIComponent(p.name)}`, { + fetch(`${llmGatewayUrl()}/api/providers/${encodeURIComponent(p.name)}`, { method: 'PUT', headers: managementHeaders(), body: JSON.stringify(body), @@ -601,26 +601,26 @@ async function ensureProviderConfig( if (res.ok) return { recreated: false }; // `base_provider_type` and the presence of `custom_provider_config` are - // IMMUTABLE in Bifrost — a PUT that changes them 400s ("base_provider_type + // IMMUTABLE in the gateway — a PUT that changes them 400s ("base_provider_type // cannot be changed from X to Y after creation"). This happens when a custom // provider's apiFormat flips (openai↔anthropic). Recreate: delete the record // (its keys go too — caller re-POSTs) then PUT fresh. const errBody = sanitizeError(await res.text()); if (res.status === 400 && /cannot be (changed|removed)/i.test(errBody)) { console.warn( - `[bifrost] provider '${p.name}' base type is immutable; recreating: ${errBody}`, + `[llm-gateway] provider '${p.name}' base type is immutable; recreating: ${errBody}`, ); await deleteGatewayProvider(p.name); const retry = await putConfig(); if (!retry.ok) { throw new Error( - `bifrost provider config ${p.name} failed after recreate (${retry.status}): ${sanitizeError(await retry.text())}`, + `llm-gateway provider config ${p.name} failed after recreate (${retry.status}): ${sanitizeError(await retry.text())}`, ); } return { recreated: true }; } throw new Error( - `bifrost provider config ${p.name} failed (${res.status}): ${errBody}`, + `llm-gateway provider config ${p.name} failed (${res.status}): ${errBody}`, ); } @@ -641,8 +641,8 @@ async function writeProviderKey( weight: 1, }; const url = existing - ? `${bifrostUrl()}/api/providers/${encodeURIComponent(p.name)}/keys/${encodeURIComponent(existing.id)}` - : `${bifrostUrl()}/api/providers/${encodeURIComponent(p.name)}/keys`; + ? `${llmGatewayUrl()}/api/providers/${encodeURIComponent(p.name)}/keys/${encodeURIComponent(existing.id)}` + : `${llmGatewayUrl()}/api/providers/${encodeURIComponent(p.name)}/keys`; const res = await fetch(url, { method: existing ? 'PUT' : 'POST', headers: managementHeaders(), @@ -651,7 +651,7 @@ async function writeProviderKey( }); if (!res.ok) { throw new Error( - `bifrost ${existing ? 'update' : 'create'} key for ${p.name}/org ${organizationId} failed (${res.status}): ${sanitizeError(await res.text())}`, + `llm-gateway ${existing ? 'update' : 'create'} key for ${p.name}/org ${organizationId} failed (${res.status}): ${sanitizeError(await res.text())}`, ); } } @@ -704,7 +704,7 @@ export async function reprovisionProvider( } /** - * Reconcile the org's providers into Bifrost: ensure each provider's record + * Reconcile the org's providers into the gateway: ensure each provider's record * config + this org's upstream key (per-org key sub-resource — see * ensureProviderKey). Called once per session create so a fresh gateway (or a * rotated key) is in place before the first mint; the provider-save actions @@ -733,7 +733,7 @@ export async function provisionProviders( await provisionOne(organizationId, p); } catch (err) { console.warn( - `[bifrost] provisioning provider '${p.name}' for org '${organizationId}' failed (continuing):`, + `[llm-gateway] provisioning provider '${p.name}' for org '${organizationId}' failed (continuing):`, err, ); } @@ -743,11 +743,11 @@ export async function provisionProviders( /** * Harden the gateway's auth posture (idempotent; safe to call every provision). * - client_config.enforce_auth_on_inference = true → inference REQUIRES a - * minted virtual key (the env BIFROST_ENFORCE_VIRTUAL_KEYS never did this; - * bifrost only reads this config field). Closes the open-inference hole. - * - auth_config (admin basic-auth over /api/*) when BIFROST_ADMIN_PASSWORD is + * minted virtual key (a legacy enforce-virtual-keys env never did this; + * the gateway only reads this config field). Closes the open-inference hole. + * - auth_config (admin basic-auth over /api/*) when LLM_GATEWAY_ADMIN_PASSWORD is * set → the management plane stops being anonymous. The stored password is - * a bcrypt hash (bifrost compares with bcrypt.CompareHashAndPassword); + * a bcrypt hash (the gateway compares with bcrypt.CompareHashAndPassword); * managementHeaders() sends the plaintext as Basic auth. * * GET-merge-PUT: `PUT /api/config` reads several client_config fields directly @@ -755,13 +755,13 @@ export async function provisionProviders( * the FULL current client_config with only enforce flipped, never a partial. */ export async function applyGatewayConfig(): Promise { - const getRes = await fetch(`${bifrostUrl()}/api/config`, { + const getRes = await fetch(`${llmGatewayUrl()}/api/config`, { method: 'GET', headers: managementHeaders(), signal: AbortSignal.timeout(15_000), }); if (!getRes.ok) { - throw new Error(`bifrost get config failed (${getRes.status})`); + throw new Error(`llm-gateway get config failed (${getRes.status})`); } // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion const cfg = (await getRes.json()) as { @@ -790,7 +790,7 @@ export async function applyGatewayConfig(): Promise { const body: Record = { client_config: clientConfig }; const pw = adminPassword(); if (pw) { - // Send the PLAINTEXT password — bifrost hashes it itself (encrypt.Hash) on + // Send the PLAINTEXT password — the gateway hashes it itself (encrypt.Hash) on // store and compares with bcrypt at request time. Pre-hashing would // double-hash and every Basic-auth call would 401. body.auth_config = { @@ -801,14 +801,14 @@ export async function applyGatewayConfig(): Promise { disable_auth_on_inference: true, }; } - const putRes = await fetch(`${bifrostUrl()}/api/config`, { + const putRes = await fetch(`${llmGatewayUrl()}/api/config`, { method: 'PUT', headers: managementHeaders(), body: JSON.stringify(body), signal: AbortSignal.timeout(15_000), }); if (!putRes.ok) { - throw new Error(`bifrost apply config failed (${putRes.status})`); + throw new Error(`llm-gateway apply config failed (${putRes.status})`); } } diff --git a/services/platform/convex/node_only/sandbox/run_agent.ts b/services/platform/convex/node_only/sandbox/run_agent.ts index 1876e2367e..f2a8ea4d4b 100644 --- a/services/platform/convex/node_only/sandbox/run_agent.ts +++ b/services/platform/convex/node_only/sandbox/run_agent.ts @@ -109,7 +109,7 @@ const IDLE_EOF_GRACE_MS = Number( process.env.EXTERNAL_AGENT_IDLE_EOF_MS ?? 60_000, ); // Stalled-turn watchdog (claude-code stdin-hold). A mid-stream API failure — the -// gateway injecting an error into the open SSE (e.g. Bifrost's stream-idle abort), +// gateway injecting an error into the open SSE (e.g. the gateway's stream-idle abort), // a connection drop, an upstream 5xx — surfaces in the CLI's stream as an // "API Error" and ends the turn WITHOUT a terminal `result` and WITHOUT exiting // (the CLI keeps its held-open stdin, waiting for the next message; Claude Code @@ -185,7 +185,11 @@ export interface RunAgentInSessionArgs { systemPromptAppend?: string; /** Credential mode (default 'managed'). 'byo' skips the gateway entirely. */ authMode?: 'managed' | 'byo'; - /** Bifrost gateway root + the session virtual key. Present for managed runs; + /** Managed only: opt into the runtime's native web tools (WebSearch/WebFetch), + * lifting the governed deny. Absent/false keeps the governed default; BYO is + * native regardless. */ + nativeWebTools?: boolean; + /** LLM gateway root + the session virtual key. Present for managed runs; * omitted for byo (the agent uses user-injected session credentials). */ gatewayBaseUrl?: string; gatewayToken?: string; @@ -267,7 +271,7 @@ export interface RunAgentInSessionResult { agentSessionId?: string; finalText?: string; /** Token/cost totals the agent reported in its `result` event — the - * authoritative per-turn usage (Bifrost v1.4.8 has no per-VK usage endpoint). + * authoritative per-turn usage (the gateway core v1.4.8 has no per-VK usage endpoint). * Absent if the run errored before producing a result. */ usage?: { inputTokens: number; @@ -376,6 +380,9 @@ export async function runAgentInSessionImpl( systemPromptAppend: args.systemPromptAppend, }), ...(args.authMode !== undefined && { authMode: args.authMode }), + ...(args.nativeWebTools !== undefined && { + nativeWebTools: args.nativeWebTools, + }), ...(args.gatewayBaseUrl !== undefined && args.gatewayToken !== undefined && { gateway: { @@ -1582,7 +1589,8 @@ export const runAgentInSession = internalAction({ maxTurns: v.optional(v.number()), browserMcp: v.optional(v.boolean()), authMode: v.optional(v.union(v.literal('managed'), v.literal('byo'))), - /** Bifrost gateway root + the session virtual key. Present for managed + nativeWebTools: v.optional(v.boolean()), + /** LLM gateway root + the session virtual key. Present for managed * runs; omitted for byo. */ gatewayBaseUrl: v.optional(v.string()), gatewayToken: v.optional(v.string()), diff --git a/services/platform/convex/node_only/sandbox/session_admin_actions.ts b/services/platform/convex/node_only/sandbox/session_admin_actions.ts index aa4f2b56f7..063c3e2bba 100644 --- a/services/platform/convex/node_only/sandbox/session_admin_actions.ts +++ b/services/platform/convex/node_only/sandbox/session_admin_actions.ts @@ -12,13 +12,13 @@ import { ConvexError, v } from 'convex/values'; import { internal } from '../../_generated/api'; import { action, type ActionCtx } from '../../_generated/server'; import { requireOrgAdminOrDeveloper } from '../../lib/auth/require_org_admin_or_developer'; -import { revokeVirtualKey } from './bifrost_admin'; import { sessionCancelExec, sessionDestroy, sessionIsAlive, sessionSetPinned, } from './helpers/session_client'; +import { revokeVirtualKey } from './llm_gateway_admin'; /** Assert the session exists AND belongs to the caller's org before any spawner * call touches it (the spawner id travels through the browser). */ @@ -96,14 +96,14 @@ export const destroySandbox = action({ { organizationId: args.organizationId, sessionId: args.sessionId }, ); try { - const { bifrostKeyIds } = await ctx.runMutation( + const { llmGatewayKeyIds } = await ctx.runMutation( internal.sandbox.session_mutations.revokeTokensForSession, { sessionId: args.sessionId }, ); - // Delete the live Bifrost VK(s), not just the bookkeeping mark — a destroy + // Delete the live gateway VK(s), not just the bookkeeping mark — a destroy // racing a live turn deletes the op row the per-turn finalize + recovery // watchdog key on, so this is the only path that revokes a mid-turn VK. - for (const keyId of bifrostKeyIds) { + for (const keyId of llmGatewayKeyIds) { await revokeVirtualKey(keyId).catch((err) => console.warn(`[destroySandbox] revoke VK ${keyId}:`, err), ); diff --git a/services/platform/convex/node_only/sandbox/session_teardown.ts b/services/platform/convex/node_only/sandbox/session_teardown.ts index 94a37a40b5..4346707361 100644 --- a/services/platform/convex/node_only/sandbox/session_teardown.ts +++ b/services/platform/convex/node_only/sandbox/session_teardown.ts @@ -10,8 +10,8 @@ import { v } from 'convex/values'; import { internal } from '../../_generated/api'; import { internalAction } from '../../_generated/server'; -import { revokeVirtualKey } from './bifrost_admin'; import { sessionDestroy } from './helpers/session_client'; +import { revokeVirtualKey } from './llm_gateway_admin'; export const teardownThreadSessions = internalAction({ args: { sessionIds: v.array(v.string()) }, @@ -27,14 +27,14 @@ export const teardownThreadSessions = internalAction({ ); } try { - const { bifrostKeyIds } = await ctx.runMutation( + const { llmGatewayKeyIds } = await ctx.runMutation( internal.sandbox.session_mutations.revokeTokensForSession, { sessionId }, ); - // Delete the live Bifrost VK(s), not just the bookkeeping mark — see + // Delete the live gateway VK(s), not just the bookkeeping mark — see // destroySandbox: teardown deletes the op rows the per-turn finalize + // recovery watchdog key on, so this is the only mid-turn revoke path. - for (const keyId of bifrostKeyIds) { + for (const keyId of llmGatewayKeyIds) { await revokeVirtualKey(keyId).catch((err) => console.warn( `[teardownThreadSessions] revoke VK ${keyId} failed:`, diff --git a/services/platform/convex/node_only/sandbox/workflow_sandbox_exec.ts b/services/platform/convex/node_only/sandbox/workflow_sandbox_exec.ts index 8d9466098e..6e32a1d073 100644 --- a/services/platform/convex/node_only/sandbox/workflow_sandbox_exec.ts +++ b/services/platform/convex/node_only/sandbox/workflow_sandbox_exec.ts @@ -27,7 +27,7 @@ * session orchestration mirroring `run_external_agent`: create → provision → * inject creds/VK → run autonomous → harvest `output/summary.md` → teardown). * Behavioral correctness of the agent path is gated on live e2e verification - * (real sandbox + Bifrost); the type/dispatch surface is exercised by units. + * (real sandbox + the LLM gateway); the type/dispatch surface is exercised by units. */ import { readFile } from 'node:fs/promises'; @@ -52,14 +52,6 @@ import { isRetryableExecutionError, isRotatableApiError, } from './agent_run_outcome'; -import { - applyGatewayConfig, - hashVirtualKey, - mintVirtualKey, - provisionProviders, - resolveGatewayRoutingFromRef, - revokeVirtualKey, -} from './bifrost_admin'; import { type SessionStageFile, SessionDuplicateError, @@ -72,6 +64,14 @@ import { sessionStageFiles, } from './helpers/session_client'; import { stageIntegrationSkills } from './integration_skills'; +import { + applyGatewayConfig, + hashVirtualKey, + mintVirtualKey, + provisionProviders, + resolveGatewayRoutingFromRef, + revokeVirtualKey, +} from './llm_gateway_admin'; import { runAgentInSessionImpl } from './run_agent'; import { shouldForceSummaryReentry, @@ -89,7 +89,7 @@ import { // in-sandbox agent reaches over the sandbox network, and the Tier-2 grants that // can be brokered into the container env (gated per-run by the agent's bindings). const EXTERNAL_AGENT_GATEWAY_URL = - process.env.EXTERNAL_AGENT_GATEWAY_URL ?? 'http://bifrost:8080'; + process.env.EXTERNAL_AGENT_GATEWAY_URL ?? 'http://llm-gateway:8080'; const INTEGRATIONS_BASE_URL = ( process.env.EXTERNAL_AGENT_INTEGRATIONS_URL || 'http://convex:3211' ).replace(/\/$/, ''); @@ -177,11 +177,11 @@ async function reapStaleWorkflowRunSessions( console.warn('[reapStaleWorkflowRunSessions] destroy failed:', e); } try { - const { bifrostKeyIds } = await ctx.runMutation( + const { llmGatewayKeyIds } = await ctx.runMutation( internal.sandbox.session_mutations.revokeTokensForSession, { sessionId }, ); - for (const keyId of bifrostKeyIds) { + for (const keyId of llmGatewayKeyIds) { await revokeVirtualKey(keyId).catch((e) => console.warn('[reapStaleWorkflowRunSessions] VK revoke failed:', e), ); @@ -280,11 +280,11 @@ async function teardownAgentSession( console.warn('[runSandboxAgent] session destroy failed:', e); } try { - const { bifrostKeyIds } = await ctx.runMutation( + const { llmGatewayKeyIds } = await ctx.runMutation( internal.sandbox.session_mutations.revokeTokensForSession, { sessionId }, ); - for (const keyId of bifrostKeyIds) { + for (const keyId of llmGatewayKeyIds) { await revokeVirtualKey(keyId).catch((e) => console.warn('[runSandboxAgent] VK revoke failed:', e), ); @@ -569,6 +569,10 @@ export const runSandboxAgent = internalAction({ const agentConfig = delegate.agentConfig; const agentKind = agentConfig.agentKind ?? 'claude-code'; const byo = agentConfig.authMode === 'byo'; + // Native web tools: the raw per-agent opt-in (managed agents deny WebSearch/ + // WebFetch by default; this lifts it). Passed to the adapter as-is; the skill + // guidance uses `byo || === true` (the agent's ACTUAL native-tool state). + const nativeWebTools = agentConfig.nativeWebTools; const modelRef = args.model ?? delegate.model; const integrationBindings = agentConfig.integrationBindings ?? []; const brokerGrants = BROKERABLE_GRANTS.filter((g) => @@ -912,6 +916,7 @@ export const runSandboxAgent = internalAction({ await stageIntegrationSkills(ctx, { organizationId: args.organizationId, sessionId, + nativeWebTools: byo || nativeWebTools === true, }); } catch (skillErr) { console.warn( @@ -965,7 +970,7 @@ export const runSandboxAgent = internalAction({ organizationId: args.organizationId, sessionId, tokenHash: hashVirtualKey(vk.key), - bifrostKeyId: vk.keyId, + llmGatewayKeyId: vk.keyId, scope: { agentKind, allowedModels: [modelRef], @@ -1092,6 +1097,7 @@ export const runSandboxAgent = internalAction({ prompt, ...(useModel !== undefined && { model: useModel }), authMode: byo ? 'byo' : 'managed', + ...(nativeWebTools !== undefined && { nativeWebTools }), interactionMode: 'autonomous', captureLiveTimeline: true, systemPromptAppend, @@ -1294,6 +1300,7 @@ export const runSandboxAgent = internalAction({ }), ...(useModel !== undefined && { model: useModel }), authMode: byo ? 'byo' : 'managed', + ...(nativeWebTools !== undefined && { nativeWebTools }), interactionMode: 'autonomous', captureLiveTimeline: true, maxTurns: SUMMARY_REENTRY_MAX_TURNS, diff --git a/services/platform/convex/providers/file_actions.ts b/services/platform/convex/providers/file_actions.ts index 34e9009102..9a34b0a64e 100644 --- a/services/platform/convex/providers/file_actions.ts +++ b/services/platform/convex/providers/file_actions.ts @@ -50,7 +50,7 @@ import { isStandardGatewayProvider, reprovisionProvider, resolveGatewayRouting, -} from '../node_only/sandbox/bifrost_admin'; +} from '../node_only/sandbox/llm_gateway_admin'; import { resolveOrgSlug } from '../organizations/resolve_org_slug'; import { requireDeveloperSettingsAccess, @@ -929,7 +929,7 @@ export const saveProvider = action({ for (const model of config.models) { if (model.baseUrl !== undefined) checkProviderHostPolicy(model.baseUrl); } - // `apiFormat` only governs CUSTOM (non-standard) providers — Bifrost owns + // `apiFormat` only governs CUSTOM (non-standard) providers — the gateway owns // the wire format for its standard names and would ignore (or 400 on) a // custom_provider_config. Reject it on a standard slug so the field never // silently misleads (e.g. `anthropic` on `openrouter`). @@ -967,7 +967,7 @@ export const saveProvider = action({ const content = serializeProviderJson(config); const filePath = resolveProviderFilePath(orgSlug, args.providerName); await atomicWrite(filePath, content); - // Model-list changes must reach the gateway too — the Bifrost provider + // Model-list changes must reach the gateway too — the gateway provider // record freezes keys[].models at provision time. await syncProviderToGateway(ctx, args.organizationId, args.providerName); return { hash: sha256(content) }; @@ -1188,31 +1188,31 @@ export async function resolveModelDataInline( }); } -/** One upstream provider, ready to push into the Bifrost gateway. */ +/** One upstream provider, ready to push into the LLM gateway. */ export interface GatewayProvider { - /** Bifrost provider record name: the slug for a standard provider, or the + /** Gateway provider record name: the slug for a standard provider, or the * per-model gateway name (`resolveGatewayRouting`) for a custom one. */ name: string; baseUrl?: string; - /** Wire format for a custom record → Bifrost base_provider_type. */ + /** Wire format for a custom record → the gateway base_provider_type. */ apiFormat?: 'openai' | 'anthropic'; apiKey: string; models: string[]; } /** - * Load the org's configured providers as Bifrost gateway records. Reuses the + * Load the org's configured providers as LLM gateway records. Reuses the * same loader + key-resolution the chat path uses (`loadAllProviders` + * `resolveModelApiKeyOrNull`) so the gateway tracks exactly what the platform * would call directly. Returns [] when the org has no usable providers. * * Grouping follows the gateway resolution rule (see resolveGatewayRouting): * - STANDARD slug → ONE native record per provider, exposing every model with - * a resolvable key (Bifrost owns the base URL + wire format). + * a resolvable key (the gateway owns the base URL + wire format). * - CUSTOM slug → ONE record PER MODEL, named `__`, carrying * that model's effective (baseUrl ?? provider.baseUrl, apiFormat, key) — so - * model-level overrides actually route on the agent path (Bifrost holds one - * base_url + base_provider_type per record). + * model-level overrides actually route on the agent path (the gateway holds + * one base_url + base_provider_type per record). */ export async function loadOrgGatewayProviders( ctx: ActionCtx, @@ -1261,12 +1261,12 @@ export async function loadOrgGatewayProviders( } /** - * Best-effort push of one provider's current key + model list into the Bifrost + * Best-effort push of one provider's current key + model list into the LLM * gateway. The gateway's provider record is a derived cache: without this - * write-time hook, a rotated key would reach Bifrost only via the + * write-time hook, a rotated key would reach the gateway only via the * session-create reconcile — and running sandbox sessions would keep the stale - * key until then. Never throws — a deployment without Bifrost just hits a fast - * connection error here, and the operator's save must succeed regardless (same + * key until then. Never throws — a deployment without the gateway just hits a + * fast connection error here, and the operator's save must succeed regardless (same * degrade posture as the session-create provisioning in run_external_agent.ts). * Known follow-up: deleteProvider leaves the gateway record orphaned. */ diff --git a/services/platform/convex/providers/resolve_model.ts b/services/platform/convex/providers/resolve_model.ts index cd5cf4f2f4..42b1cc92d2 100644 --- a/services/platform/convex/providers/resolve_model.ts +++ b/services/platform/convex/providers/resolve_model.ts @@ -40,7 +40,7 @@ export interface ResolvedModelData { * factory is OpenAI-compatible-only today, so this is informational for the * chat path (an 'anthropic' provider falls back to the OpenAI client and * errors at the wire level); the external-agent gateway uses it to pick the - * Bifrost base_provider_type. See `apiFormatSchema` in shared/schemas. + * gateway base_provider_type. See `apiFormatSchema` in shared/schemas. */ apiFormat: 'openai' | 'anthropic'; tags: string[]; diff --git a/services/platform/convex/sandbox/session_lifecycle.test.ts b/services/platform/convex/sandbox/session_lifecycle.test.ts index 94a7a01abb..932a1db56b 100644 --- a/services/platform/convex/sandbox/session_lifecycle.test.ts +++ b/services/platform/convex/sandbox/session_lifecycle.test.ts @@ -440,15 +440,15 @@ describe('recoverStuckSessions', () => { describe('revokeTokensForSession', () => { async function insertToken( t: T, - overrides: { bifrostKeyId?: string; revokedAt?: number }, + overrides: { llmGatewayKeyId?: string; revokedAt?: number }, ) { return t.run((ctx) => ctx.db.insert('sandboxSessionTokens', { organizationId: ORG, sessionId: SID, - tokenHash: `hash_${overrides.bifrostKeyId ?? 'none'}`, - ...(overrides.bifrostKeyId !== undefined && { - bifrostKeyId: overrides.bifrostKeyId, + tokenHash: `hash_${overrides.llmGatewayKeyId ?? 'none'}`, + ...(overrides.llmGatewayKeyId !== undefined && { + llmGatewayKeyId: overrides.llmGatewayKeyId, }), scope: { agentKind: 'claude-code', @@ -465,17 +465,17 @@ describe('revokeTokensForSession', () => { ); } - it('marks unrevoked tokens revoked and returns their bifrostKeyIds (the gateway DELETE list)', async () => { + it('marks unrevoked tokens revoked and returns their llmGatewayKeyIds (the gateway DELETE list)', async () => { const t = convexTest(schema, modules); - await insertToken(t, { bifrostKeyId: 'vk_1' }); - await insertToken(t, { bifrostKeyId: 'vk_2' }); + await insertToken(t, { llmGatewayKeyId: 'vk_1' }); + await insertToken(t, { llmGatewayKeyId: 'vk_2' }); const res = await t.mutation( internal.sandbox.session_mutations.revokeTokensForSession, { sessionId: SID }, ); expect(res.revoked).toBe(2); - expect([...res.bifrostKeyIds].sort()).toEqual(['vk_1', 'vk_2']); + expect([...res.llmGatewayKeyIds].sort()).toEqual(['vk_1', 'vk_2']); const tokens = await t.run((ctx) => ctx.db.query('sandboxSessionTokens').collect(), ); @@ -484,18 +484,18 @@ describe('revokeTokensForSession', () => { it('skips already-revoked tokens and omits keyless tokens from the DELETE list', async () => { const t = convexTest(schema, modules); - await insertToken(t, { bifrostKeyId: 'vk_live' }); - await insertToken(t, { bifrostKeyId: 'vk_already', revokedAt: 5 }); - await insertToken(t, {}); // a token row with no bifrostKeyId + await insertToken(t, { llmGatewayKeyId: 'vk_live' }); + await insertToken(t, { llmGatewayKeyId: 'vk_already', revokedAt: 5 }); + await insertToken(t, {}); // a token row with no llmGatewayKeyId const res = await t.mutation( internal.sandbox.session_mutations.revokeTokensForSession, { sessionId: SID }, ); // The keyless live token is still marked revoked (count 2), but only the one - // carrying a bifrostKeyId is returned for the gateway DELETE; the + // carrying a llmGatewayKeyId is returned for the gateway DELETE; the // already-revoked one is untouched. expect(res.revoked).toBe(2); - expect(res.bifrostKeyIds).toEqual(['vk_live']); + expect(res.llmGatewayKeyIds).toEqual(['vk_live']); }); }); diff --git a/services/platform/convex/sandbox/session_mutations.ts b/services/platform/convex/sandbox/session_mutations.ts index 827bcef15e..e6300b6581 100644 --- a/services/platform/convex/sandbox/session_mutations.ts +++ b/services/platform/convex/sandbox/session_mutations.ts @@ -110,13 +110,14 @@ export const setSessionStatus = internalMutation({ v.literal('expired'), v.literal('failed'), ), - bifrostKeyId: v.optional(v.string()), + llmGatewayKeyId: v.optional(v.string()), lastActivityAt: v.optional(v.number()), }, returns: v.null(), handler: async (ctx, args) => { const patch: Record = { status: args.status }; - if (args.bifrostKeyId !== undefined) patch.bifrostKeyId = args.bifrostKeyId; + if (args.llmGatewayKeyId !== undefined) + patch.llmGatewayKeyId = args.llmGatewayKeyId; if (args.lastActivityAt !== undefined) { patch.lastActivityAt = args.lastActivityAt; } @@ -174,7 +175,7 @@ export const setSessionPinned = internalMutation({ * Watchdog: mark sessions past their hard lifetime as `expired` so a leaked * row (a throw between reserve and the spawner create returning) can't pin the * owner/org cap forever. The actual container teardown + token revoke is the - * caller's job (an action that reads these and calls the spawner + Bifrost). + * caller's job (an action that reads these and calls the spawner + the gateway). * * `stopped` rows are EXEMPT: a hibernated session's workspace is preserved * indefinitely until an explicit Destroy (it holds no compute and doesn't pin @@ -345,7 +346,7 @@ export const insertSessionToken = internalMutation({ organizationId: v.string(), sessionId: v.string(), tokenHash: v.string(), - bifrostKeyId: v.optional(v.string()), + llmGatewayKeyId: v.optional(v.string()), scope: v.object({ agentKind: v.string(), allowedModels: v.array(v.string()), @@ -360,8 +361,8 @@ export const insertSessionToken = internalMutation({ organizationId: args.organizationId, sessionId: args.sessionId, tokenHash: args.tokenHash, - ...(args.bifrostKeyId !== undefined && { - bifrostKeyId: args.bifrostKeyId, + ...(args.llmGatewayKeyId !== undefined && { + llmGatewayKeyId: args.llmGatewayKeyId, }), scope: args.scope, createdAt: Date.now(), @@ -371,8 +372,8 @@ export const insertSessionToken = internalMutation({ /** * Revoke every token for a session (on destroy / watchdog reap). Marks each - * unrevoked row `revokedAt` and returns the `bifrostKeyId`s it just revoked so - * the caller (a `'use node'` teardown action) can also delete the live Bifrost + * unrevoked row `revokedAt` and returns the `llmGatewayKeyId`s it just revoked so + * the caller (a `'use node'` teardown action) can also delete the live gateway * VK. This mark alone is bookkeeping — the VK stays a spendable credential on * the gateway until that API delete runs. Teardown deletes the op rows that the * per-turn finalize + recovery watchdog key on, so without this the destroy- @@ -382,23 +383,23 @@ export const revokeTokensForSession = internalMutation({ args: { sessionId: v.string() }, returns: v.object({ revoked: v.number(), - bifrostKeyIds: v.array(v.string()), + llmGatewayKeyIds: v.array(v.string()), }), handler: async (ctx, args) => { const now = Date.now(); let revoked = 0; - const bifrostKeyIds: string[] = []; + const llmGatewayKeyIds: string[] = []; for await (const row of ctx.db .query('sandboxSessionTokens') .withIndex('by_sessionId', (q) => q.eq('sessionId', args.sessionId))) { if (row.revokedAt === undefined) { await ctx.db.patch(row._id, { revokedAt: now }); revoked += 1; - if (row.bifrostKeyId !== undefined) - bifrostKeyIds.push(row.bifrostKeyId); + if (row.llmGatewayKeyId !== undefined) + llmGatewayKeyIds.push(row.llmGatewayKeyId); } } - return { revoked, bifrostKeyIds }; + return { revoked, llmGatewayKeyIds }; }, }); diff --git a/services/platform/convex/sandbox/sessions_schema.ts b/services/platform/convex/sandbox/sessions_schema.ts index 3d77bdaa5c..d31b7f85ec 100644 --- a/services/platform/convex/sandbox/sessions_schema.ts +++ b/services/platform/convex/sandbox/sessions_schema.ts @@ -47,8 +47,8 @@ export const sandboxSessionsTable = defineTable({ ownerId: v.string(), createdBy: v.string(), agentKind: v.optional(v.string()), // 'claude-code' | 'opencode' | … - /** Bifrost virtual-key id (NOT the plaintext key). */ - bifrostKeyId: v.optional(v.string()), + /** Gateway virtual-key id (NOT the plaintext key). */ + llmGatewayKeyId: v.optional(v.string()), createdAt: v.number(), expiresAt: v.number(), lastActivityAt: v.optional(v.number()), @@ -75,7 +75,7 @@ export const sandboxSessionsTable = defineTable({ .index('by_sessionId', ['sessionId']); /** - * Session-scoped LLM gateway token (the Bifrost virtual key) — only the + * Session-scoped LLM gateway token (the gateway virtual key) — only the * sha256 hash is persisted. Scope bounds what the in-sandbox agent can do; * revoked on session destroy / watchdog reap. */ @@ -83,7 +83,7 @@ export const sandboxSessionTokensTable = defineTable({ organizationId: v.string(), sessionId: v.string(), tokenHash: v.string(), - bifrostKeyId: v.optional(v.string()), + llmGatewayKeyId: v.optional(v.string()), scope: v.object({ agentKind: v.string(), allowedModels: v.array(v.string()), @@ -158,7 +158,7 @@ export const sandboxSessionOpsTable = defineTable({ // + additive (existing rows validate; non-agent execs leave them unset). /** The streaming assistant message this turn patches/finalizes. */ assistantMessageId: v.optional(v.string()), - /** Bifrost virtual-key id to revoke on finalize (spend attribution). */ + /** Gateway virtual-key id to revoke on finalize (spend attribution). */ mintedKeyId: v.optional(v.string()), /** Usage-attribution + finalize context for a recovery-path finalize. */ userId: v.optional(v.string()), @@ -196,7 +196,7 @@ export const sandboxSessionOpsTable = defineTable({ finalizedAt: v.optional(v.number()), /** How many cross-action handoffs this turn has done (runaway cap). */ continuationCount: v.optional(v.number()), - /** Cumulative in-task LLM spend (cents) polled from the turn's Bifrost VK, + /** Cumulative in-task LLM spend (cents) polled from the turn's gateway VK, * stamped at each continuation seam so the management page can show live * rolling spend without polling the gateway from a reactive query. */ spentCents: v.optional(v.number()), diff --git a/services/platform/lib/agent-adapters/build-exec.test.ts b/services/platform/lib/agent-adapters/build-exec.test.ts index e2b7d8558a..267fcc4e89 100644 --- a/services/platform/lib/agent-adapters/build-exec.test.ts +++ b/services/platform/lib/agent-adapters/build-exec.test.ts @@ -11,7 +11,7 @@ import type { AgentRunSpec } from './types'; const base = { prompt: 'Fix issue #1 and open a PR', model: 'claude-sonnet-4-6', - gateway: { baseUrl: 'http://bifrost:8080', token: 'sk-bf-test' }, + gateway: { baseUrl: 'http://llm-gateway:8080', token: 'sk-bf-test' }, workdir: '/user/workspace', } satisfies AgentRunSpec; @@ -75,7 +75,7 @@ describe('ClaudeCodeAdapter.buildExec', () => { }); expect(argv).not.toContain(base.prompt); // gateway env + key + blanked API key + default-model slots. - expect(env.ANTHROPIC_BASE_URL).toBe('http://bifrost:8080/anthropic'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://llm-gateway:8080/anthropic'); expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-bf-test'); expect(env.ANTHROPIC_API_KEY).toBe(''); expect(env.CLAUDE_CONFIG_DIR).toBe('/user/.runtime/home/.claude'); @@ -230,6 +230,27 @@ describe('ClaudeCodeAdapter.buildExec', () => { 'AskUserQuestion,WebSearch,WebFetch', ); }); + + it('lifts the WebSearch/WebFetch denial for a managed agent that opts in via nativeWebTools', () => { + const { argv } = new ClaudeCodeAdapter().buildExec({ + ...base, + nativeWebTools: true, + }); + // Only AskUserQuestion remains denied (no chat answer path); the web tools + // are now the agent's native ones. + expect(argv).toContain('--disallowedTools'); + expect(argv[argv.indexOf('--disallowedTools') + 1]).toBe('AskUserQuestion'); + }); + + it('keeps the managed web-tools denial when nativeWebTools is explicitly false (only === true lifts it)', () => { + const { argv } = new ClaudeCodeAdapter().buildExec({ + ...base, + nativeWebTools: false, + }); + expect(argv[argv.indexOf('--disallowedTools') + 1]).toBe( + 'AskUserQuestion,WebSearch,WebFetch', + ); + }); }); describe('ClaudeCodeAdapter.buildExec — BYO mode', () => { @@ -274,6 +295,14 @@ describe('ClaudeCodeAdapter.buildExec — BYO mode', () => { expect(argv[argv.indexOf('--disallowedTools') + 1]).toBe('AskUserQuestion'); }); + it('stays native for BYO even when nativeWebTools is false (the flag is a managed-only lift, never a byo re-deny)', () => { + const { argv } = new ClaudeCodeAdapter().buildExec({ + ...byoBase, + nativeWebTools: false, + }); + expect(argv[argv.indexOf('--disallowedTools') + 1]).toBe('AskUserQuestion'); + }); + it('omits the integration bridge even if integrationsBaseUrl is set (no session key to auth it)', () => { const { argv } = new ClaudeCodeAdapter().buildExec({ ...byoBase, @@ -289,7 +318,7 @@ describe('ClaudeCodeAdapter.buildExec — BYO mode', () => { const { argv, env } = new ClaudeCodeAdapter().buildExec({ ...byoBase, authMode: 'managed', - gateway: { baseUrl: 'http://bifrost:8080', token: 'sk-bf-test' }, + gateway: { baseUrl: 'http://llm-gateway:8080', token: 'sk-bf-test' }, }); expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-bf-test'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-8'); @@ -321,7 +350,7 @@ describe('OpenCodeAdapter.buildExec', () => { expect(config.model).toBe('tale/claude-sonnet-4-6'); expect(config.permission).toBe('allow'); expect(config.provider.tale.options.baseURL).toBe( - 'http://bifrost:8080/openai/v1', + 'http://llm-gateway:8080/openai/v1', ); // token referenced via {env:…}, not inlined into the (loggable) config. expect(config.provider.tale.options.apiKey).toBe( diff --git a/services/platform/lib/agent-adapters/claude-code/adapter.ts b/services/platform/lib/agent-adapters/claude-code/adapter.ts index e283df5afd..aae1dcdc31 100644 --- a/services/platform/lib/agent-adapters/claude-code/adapter.ts +++ b/services/platform/lib/agent-adapters/claude-code/adapter.ts @@ -162,8 +162,10 @@ export class ClaudeCodeAdapter implements AgentAdapter { // BYO opts out of platform governance, so this governance-motivated denial // is lifted — the agent runs with its native toolset (web tools work on the // user's own credential). The container + egress policy stay the isolation - // boundary. - if (!byo) { + // boundary. A managed agent can also opt in explicitly (spec.nativeWebTools) + // — e.g. on a gateway model that supports native web tools (OpenRouter) where + // ungoverned web access is acceptable; then the deny is lifted for it too. + if (!byo && spec.nativeWebTools !== true) { disallowedTools.push('WebSearch', 'WebFetch'); } if (disallowedTools.length > 0) { diff --git a/services/platform/lib/agent-adapters/types.ts b/services/platform/lib/agent-adapters/types.ts index 316350d50c..428be53728 100644 --- a/services/platform/lib/agent-adapters/types.ts +++ b/services/platform/lib/agent-adapters/types.ts @@ -2,11 +2,11 @@ import type { AgentEventParser, AgentSlug } from './events'; -/** The platform LLM gateway (Bifrost) endpoint + the session-scoped key. The +/** The platform LLM gateway endpoint + the session-scoped key. The * adapter appends its own protocol route (Claude → /anthropic, OpenCode → * /openai/v1) so callers pass one base. */ export interface GatewayTarget { - /** Gateway root, no trailing slash, e.g. http://bifrost:8080 */ + /** Gateway root, no trailing slash, e.g. http://llm-gateway:8080 */ baseUrl: string; /** Session virtual key minted at session create. */ token: string; @@ -41,6 +41,12 @@ export interface AgentRunSpec { * passthrough and native web tools enabled. */ authMode?: 'managed' | 'byo'; + /** Managed only: opt in to the runtime's NATIVE web tools (Claude Code + * WebSearch/WebFetch). Managed runs force-disable these by default and route + * web access through a connected integration (governed: audit + metering + + * untrusted-source wrapping); `true` lifts that denial. Absent/false keeps the + * deny. BYO already runs with native web tools, so this is ignored for byo. */ + nativeWebTools?: boolean; /** Platform LLM gateway. Present for managed runs; ABSENT for byo. */ gateway?: GatewayTarget; /** Platform base URL for the integration-dispatch bridge (/api/integrations). diff --git a/services/platform/lib/shared/schemas/agents.ts b/services/platform/lib/shared/schemas/agents.ts index d97af91c17..dc0cedd1dd 100644 --- a/services/platform/lib/shared/schemas/agents.ts +++ b/services/platform/lib/shared/schemas/agents.ts @@ -188,7 +188,7 @@ export const agentJsonSchema = z agentKind: agentKindSchema.optional(), /** * For `primaryBehavior: 'external-agent'` only — credential / auth mode. - * 'managed' (default): the platform mints a Bifrost virtual key, routes the + * 'managed' (default): the platform mints a gateway virtual key, routes the * agent through the gateway, and enforces allowed_models + usage metering + * the budget gate. 'byo': the platform injects no virtual key or gateway; * the agent authenticates with whatever credentials the user injected into @@ -198,6 +198,17 @@ export const agentJsonSchema = z * already a privileged action, so there is no separate org-level gate. */ authMode: z.enum(['managed', 'byo']).optional(), + /** + * For `primaryBehavior: 'external-agent'` only — opt the agent into its + * runtime's NATIVE web tools (Claude Code `WebSearch`/`WebFetch`). Managed + * runs force-disable these by default and route web access through a + * connected search integration (governed: audit + metering + untrusted-source + * wrapping). `true` lifts that denial so the agent uses its native web tools + * directly — appropriate when the gateway model supports them (e.g. OpenRouter) + * and ungoverned web access is acceptable. Absent/`false` keeps the governed + * default. BYO is unaffected (already native). + */ + nativeWebTools: z.boolean().optional(), systemInstructions: z.string().optional(), toolNames: z.array(z.string()).optional(), integrationBindings: z.array(z.string().min(1)).optional(), @@ -435,6 +446,19 @@ export const agentJsonSchema = z }); } + // nativeWebTools only applies to external-agent. + if ( + data.nativeWebTools !== undefined && + data.primaryBehavior !== 'external-agent' + ) { + ctx.addIssue({ + code: 'custom', + path: ['nativeWebTools'], + message: + 'nativeWebTools is only valid when primaryBehavior is "external-agent".', + }); + } + // `preferDurableStepForTasks` (durable sandbox dispatch) and `runtime` // (external daemon dispatch) are two different task-run dispatch paths — // an agent picks at most one. diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json index 80f21ec53d..ef660c851e 100644 --- a/services/platform/messages/de.json +++ b/services/platform/messages/de.json @@ -5269,6 +5269,12 @@ "description": "Welcher Coding-Agent die Turns dieses Agenten in der Sandbox ausführt.", "claudeCode": "Claude Code", "openCode": "OpenCode" + }, + "webTools": { + "sectionTitle": "Web-Tools", + "sectionDescription": "Wie dieser Agent auf das Web zugreift.", + "nativeLabel": "Native Websuche und -abruf verwenden", + "nativeDescription": "Lass den Agenten die in seiner Laufzeit integrierten WebSearch- und WebFetch-Tools direkt nutzen — am besten, wenn das Modell sie unterstützt (z. B. OpenRouter). Wenn deaktiviert, sind die nativen Web-Tools gesperrt und der Webzugriff muss über eine verbundene Such-Integration laufen (auditiert und abgerechnet)." } }, "delegation": { diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index ac870f4cad..b7f40b963b 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -5531,6 +5531,12 @@ "description": "Which coding agent runs this agent's turns in the sandbox.", "claudeCode": "Claude Code", "openCode": "OpenCode" + }, + "webTools": { + "sectionTitle": "Web tools", + "sectionDescription": "How this agent accesses the web.", + "nativeLabel": "Use native web search and fetch", + "nativeDescription": "Let the agent use its runtime's built-in WebSearch and WebFetch tools directly — best when the model supports them (e.g. OpenRouter). When off, the native web tools are disabled and web access must go through a connected search integration (audited and metered)." } }, "delegation": { diff --git a/services/platform/messages/fr.json b/services/platform/messages/fr.json index 873967b940..972ad10cab 100644 --- a/services/platform/messages/fr.json +++ b/services/platform/messages/fr.json @@ -5270,6 +5270,12 @@ "description": "Quel agent de code exécute les tours de cet agent dans le bac à sable.", "claudeCode": "Claude Code", "openCode": "OpenCode" + }, + "webTools": { + "sectionTitle": "Outils web", + "sectionDescription": "Comment cet agent accède au web.", + "nativeLabel": "Utiliser la recherche et la récupération web natives", + "nativeDescription": "Laisse l'agent utiliser directement les outils WebSearch et WebFetch intégrés à son runtime — idéal quand le modèle les prend en charge (par ex. OpenRouter). Désactivé, les outils web natifs sont bloqués et l'accès web doit passer par une intégration de recherche connectée (audité et facturé)." } }, "delegation": { diff --git a/services/platform/playwright.config.ts b/services/platform/playwright.config.ts index 0e97b3a6ab..5d016ddfe0 100644 --- a/services/platform/playwright.config.ts +++ b/services/platform/playwright.config.ts @@ -100,7 +100,7 @@ export default createPlaywrightConfig({ env: { // The E2E stack is hermetic (anonymous Convex + mock LLM, no external // services). The docker backing services dev.ts brings up for full - // local dev (bifrost/sandbox/db/rag/crawler) have no built images in + // local dev (llm-gateway/sandbox/db/rag/crawler) have no built images in // the E2E CI job, so the bring-up can only fail and waste the cold-boot // budget — skip it. Applies in both mock and live-stack modes. TALE_DEV_SKIP_DOCKER: '1', diff --git a/services/platform/scripts/dev-engine.ts b/services/platform/scripts/dev-engine.ts index bd4cf00a0e..22d8c4ecdc 100644 --- a/services/platform/scripts/dev-engine.ts +++ b/services/platform/scripts/dev-engine.ts @@ -80,21 +80,21 @@ const sinceBoot = (): string => // Docker backing services the HOST `bun dev` depends on (Convex + Vite run on // the host; these run in docker). Excludes the host-run convex/platform and the -// dev-irrelevant proxy/docs/controller. `bifrost` is the one with no published -// port in base compose.yml — see DEV_COMPOSE_FILES. +// dev-irrelevant proxy/docs/controller. `llm-gateway` is the one with no +// published port in base compose.yml — see DEV_COMPOSE_FILES. // // Note: knowledge-db `depends_on convex` in base compose.yml only to wait for it -// to seed the shared convex-data config volume. compose.bifrost.dev.yml (host -// bun-dev only) drops that edge via `!override` — the host backend owns config -// here, not the docker convex — so this bring-up does NOT pull up a redundant -// convex container alongside the host one. +// to seed the shared convex-data config volume. compose.llm-gateway.dev.yml +// (host bun-dev only) drops that edge via `!override` — the host backend owns +// config here, not the docker convex — so this bring-up does NOT pull up a +// redundant convex container alongside the host one. const DEV_DOCKER_SERVICES = [ 'db', // ParadeDB for the knowledge base / RAG search corpus (formerly the separate // rag + crawler services, consolidated into the tale-db image — see the // knowledge-db migration wiring). 'knowledge-db', - 'bifrost', + 'llm-gateway', 'sandbox', 'sandbox-egress', // socat relay aliased `convex` on the sandbox net → host-run convex :3211, @@ -103,18 +103,18 @@ const DEV_DOCKER_SERVICES = [ 'convex-relay', ]; // Overlay chain for local dev (matches docs/.../docker-compose-reference): base -// + source-mounts/debug/extra_hosts (dev) + the loopback bifrost port publish -// (bifrost.dev). compose.docs.yml is required because compose.dev.yml carries a -// `docs` override whose base service lives only in compose.docs.yml — omit it -// and compose rejects the whole project ("docs has neither an image nor a build -// context"), even though we never start the docs service here. The base file -// alone leaves bifrost unreachable from the host, which kills every +// + source-mounts/debug/extra_hosts (dev) + the loopback gateway port publish +// (llm-gateway.dev). compose.docs.yml is required because compose.dev.yml +// carries a `docs` override whose base service lives only in compose.docs.yml — +// omit it and compose rejects the whole project ("docs has neither an image nor +// a build context"), even though we never start the docs service here. The base +// file alone leaves the LLM gateway unreachable from the host, which kills every // external-agent turn. const DEV_COMPOSE_FILES = [ 'compose.yml', 'compose.dev.yml', 'compose.docs.yml', - 'compose.bifrost.dev.yml', + 'compose.llm-gateway.dev.yml', ]; function parseDotEnv(filePath: string): Record { @@ -516,15 +516,15 @@ async function startDockerDaemon(): Promise<'ok' | 'no-daemon'> { return ready ? 'ok' : 'no-daemon'; } -/** Probe the bifrost gateway on its host-published loopback port until it - * accepts connections — this is the axis that breaks when the dev overlay's - * port binding is missing. Honours BIFROST_URL; warn-and-continue on timeout. */ -async function waitForBifrostGateway( - timeoutMs = DEV_GATES.bifrost.timeoutMs, +/** Probe the LLM gateway on its host-published loopback port until it accepts + * connections — this is the axis that breaks when the dev overlay's port + * binding is missing. Honours LLM_GATEWAY_URL; warn-and-continue on timeout. */ +async function waitForLlmGateway( + timeoutMs = DEV_GATES.llmGateway.timeoutMs, ): Promise { let host = '127.0.0.1'; let port = 8080; - const raw = process.env.BIFROST_URL; + const raw = process.env.LLM_GATEWAY_URL; if (raw) { try { const u = new URL(raw); @@ -532,7 +532,7 @@ async function waitForBifrostGateway( port = u.port ? Number(u.port) : port; } catch { warnLine( - `BIFROST_URL=${raw} is not a valid URL; probing ${host}:${port}`, + `LLM_GATEWAY_URL=${raw} is not a valid URL; probing ${host}:${port}`, ); } } @@ -543,23 +543,24 @@ async function waitForBifrostGateway( await new Promise((resolve) => setTimeout(resolve, 500)); } warnLine( - `Bifrost gateway not reachable at ${host}:${port} within ${timeoutMs / 1000}s — external-agent turns may fail with "fetch failed".`, + `LLM gateway not reachable at ${host}:${port} within ${timeoutMs / 1000}s — external-agent turns may fail with "fetch failed".`, ); } /** Bring up the docker backing services the host `bun dev` depends on, WITH the * dev overlays. Host bun dev runs Convex + Vite on the host, but the LLM - * gateway (bifrost), sandbox spawner, db and knowledge-db run in docker. The - * base compose.yml publishes NO bifrost port (prod posture) — only - * compose.bifrost.dev.yml maps 127.0.0.1:8080 — so a plain `docker compose up` - * silently drops the loopback binding and the host Convex action can't reach - * the gateway (every external-agent turn then dies with "fetch failed"). Doing - * the bring-up here, with the overlay chain, makes `bun dev` self-sufficient - * and keeps the port from drifting. + * gateway, sandbox spawner, db and knowledge-db run in docker. The base + * compose.yml publishes NO gateway port (prod posture) — only + * compose.llm-gateway.dev.yml maps 127.0.0.1:8080 — so a plain `docker compose + * up` silently drops the loopback binding and the host Convex action can't + * reach the gateway (every external-agent turn then dies with "fetch failed"). + * Doing the bring-up here, with the overlay chain, makes `bun dev` + * self-sufficient and keeps the port from drifting. * * Idempotent: an already-overlay stack recreates nothing; after a prior bare - * `up` it recreates whatever config drifted (bifrost gains its port, the rest - * gain source mounts / extra_hosts) — the intended convergence to dev config. + * `up` it recreates whatever config drifted (the gateway gains its port, the + * rest gain source mounts / extra_hosts) — the intended convergence to dev + * config. * * A stopped engine is auto-started first (Docker Desktop / systemd) so a dev * machine where Docker simply isn't running doesn't have to start it by hand. @@ -585,8 +586,8 @@ async function ensureDockerDependencies(): Promise { // even a 100%-cached build re-exports a NEW image manifest digest. compose // then sees the service image no longer matches the running container's image // and recreates the container — every single run. (External-image services - // like bifrost/convex-relay are never built, so they stay put — which is why - // only the build-services churned.) Disabling the default attestation makes + // like llm-gateway/convex-relay are never built, so they stay put — which is + // why only the build-services churned.) Disabling the default attestation makes // the cached build reproduce a stable image ID, so an already-up stack // converges to a no-op. Scoped to dev: CI/release builds run in their own // processes and keep provenance for supply-chain integrity. Explicit override @@ -662,7 +663,7 @@ async function ensureDockerDependencies(): Promise { }, ); - if (dockerUp) await waitForBifrostGateway(); + if (dockerUp) await waitForLlmGateway(); } /** Probe the Better Auth HTTP surface (served by the Convex site proxy on @@ -848,7 +849,7 @@ export async function runDevFleet() { // Bring up the docker backing stack (gateway, sandbox, db, knowledge-db) // WITH the dev overlays before Convex/Vite. Host bun dev runs Convex+Vite on - // the host but depends on these in docker; the bifrost gateway in particular + // the host but depends on these in docker; the LLM gateway in particular // has no published port in base compose.yml, so without this an external // agent turn dies with "fetch failed". Runs in BOTH local and external // Convex modes; non-fatal if docker is absent (warns + continues). diff --git a/services/platform/scripts/dev-gates.test.ts b/services/platform/scripts/dev-gates.test.ts index 3a048a3558..33a77bd269 100644 --- a/services/platform/scripts/dev-gates.test.ts +++ b/services/platform/scripts/dev-gates.test.ts @@ -9,7 +9,7 @@ describe('DEV_GATES severity/timeout table (the soft→hard fence)', () => { ).toEqual([ ['assertPortFree', 'hard', 1_000], ['wait-on convex tcp', 'hard', 180_000], - ['bifrost gateway', 'soft', 30_000], + ['llm-gateway', 'soft', 30_000], ['/api/auth/ok', 'soft', 90_000], ['vite bind', 'soft', 180_000], ]); diff --git a/services/platform/scripts/dev-gates.ts b/services/platform/scripts/dev-gates.ts index b9bc48ce35..957d83c452 100644 --- a/services/platform/scripts/dev-gates.ts +++ b/services/platform/scripts/dev-gates.ts @@ -29,7 +29,7 @@ export const DEV_GATES = { timeoutMs: 180_000, }, /** LLM gateway is best-effort — pure frontend/Convex work survives without it. */ - bifrost: { name: 'bifrost gateway', severity: 'soft', timeoutMs: 30_000 }, + llmGateway: { name: 'llm-gateway', severity: 'soft', timeoutMs: 30_000 }, /** Auth HTTP readiness — SOFT on purpose: the client retries; a hard fail here * would abort the boot and strand the WS (cold-start-auth-recovery). */ authOk: { name: '/api/auth/ok', severity: 'soft', timeoutMs: 90_000 }, diff --git a/services/sandbox-egress/entrypoint.sh b/services/sandbox-egress/entrypoint.sh index a06e5897e5..e4ff781833 100644 --- a/services/sandbox-egress/entrypoint.sh +++ b/services/sandbox-egress/entrypoint.sh @@ -14,7 +14,7 @@ set -e # unset or empty => open egress: no hostname filtering at all. # The IP-layer SSRF firewall (IMDS + link-local + RFC1918 REJECT, installed # by docker-entrypoint.sh) applies in BOTH modes. LLM traffic never transits -# this proxy either way (NO_PROXY=bifrost on the runtime containers). +# this proxy either way (NO_PROXY=llm-gateway on the runtime containers). if [ -n "$SANDBOX_EGRESS_ALLOWLIST" ]; then echo "$SANDBOX_EGRESS_ALLOWLIST" | tr '|' '\n' > /etc/tinyproxy/allowlist FILTER_BLOCK='# Host-name allow-list (default-deny), rendered from SANDBOX_EGRESS_ALLOWLIST. @@ -52,7 +52,7 @@ sed 's/^/ /' /etc/tinyproxy/tinyproxy.conf # DNS forwarder for the internal sandbox network. The runtime session and its # nested DinD containers live on `tale-sandbox-net` (internal-only) and cannot # resolve external hostnames — their embedded DNS forwards to public resolvers -# that the internal bridge can't reach, so things like `getbifrost.ai` or +# that the internal bridge can't reach, so things like `example.com` or # `deb.debian.org` fail to resolve. This proxy is dual-homed (also on a network # with real egress), so its own resolver (`/etc/resolv.conf` -> 127.0.0.11) # resolves the public internet. Run dnsmasq forwarding to it, listening on all diff --git a/services/sandbox-runtime/entrypoint.sh b/services/sandbox-runtime/entrypoint.sh index 1fa9996923..56e1f653e2 100644 --- a/services/sandbox-runtime/entrypoint.sh +++ b/services/sandbox-runtime/entrypoint.sh @@ -253,7 +253,7 @@ _ensure_default_route() { # reach its upstream ExtServers (8.8.8.8 …) — external lookups time out. The # egress sidecar's dnsmasq CAN resolve external names. DNAT the embedded # resolver's FORWARDED queries (anything to :53 that isn't the embedded resolver -# itself) to the egress dnsmasq, so: Docker service names (bifrost, convex) are +# itself) to the egress dnsmasq, so: Docker service names (llm-gateway, convex) are # still answered LOCALLY by 127.0.0.11 with their correct on-network IPs, while # only external names get forwarded to the egress resolver. Gated on 127.0.0.11 # being the resolver — on k8s (kube-dns) external DNS already works and DNAT'ing diff --git a/services/sandbox-runtime/tale-git-credential b/services/sandbox-runtime/tale-git-credential index 2f7a527983..560f52a8c9 100755 --- a/services/sandbox-runtime/tale-git-credential +++ b/services/sandbox-runtime/tale-git-credential @@ -9,8 +9,8 @@ # into the session env store — see convex/node_only/sandbox/session_credentials.ts). # # v1 reads the host-specific token from env (GITHUB_TOKEN for github.com). The -# token is rotated/revoked by the platform via the session env-patch + Bifrost -# revoke on destroy; a finer per-operation broker fetch is a documented +# token is rotated/revoked by the platform via the session env-patch + LLM +# gateway revoke on destroy; a finer per-operation broker fetch is a documented # follow-up. git ignores unknown lines, so on no-match we emit nothing and git # falls back (then fails loudly), never leaking. diff --git a/services/sandbox/docs/sessions.md b/services/sandbox/docs/sessions.md index cb9c993fe0..7f2675f373 100644 --- a/services/sandbox/docs/sessions.md +++ b/services/sandbox/docs/sessions.md @@ -50,10 +50,10 @@ from the reaper entirely. Secrets entering a sandbox is a graded decision, documented and enforced: -- **Tier 0 — platform-global secrets** (`SANDBOX_TOKEN`, Bifrost management +- **Tier 0 — platform-global secrets** (`SANDBOX_TOKEN`, LLM gateway management token, SOPS age key, raw provider API keys): **never enter a sandbox**, ever. - **Tier 1 — proxiable credentials** (LLM provider keys): stay outside. The - sandbox holds only a session-scoped Bifrost virtual key (`sk-bf-*`); LLM + sandbox holds only a session-scoped gateway virtual key (`sk-bf-*`); LLM traffic transits the gateway, which attaches the real key. Bought: per-key budget, model allowlist, instant revoke, server-side usage metering. - **Tier 2 — managed-entry credentials** (integration secrets — git tokens, @@ -114,7 +114,7 @@ The session backend needs, in the sandbox namespace, on `pods` and `secrets`: ### NetworkPolicy - Session Pods (`tale.sandbox/role: session`): egress to the egress proxy, the - Bifrost gateway Service (`:8080`), and DNS only — same shape as the one-shot + LLM gateway Service (`:8080`), and DNS only — same shape as the one-shot runtime egress allowance plus the gateway. - Ingress to session Pods on `:8200` (runnerd) is allowed **from the spawner Deployment only**. @@ -129,5 +129,5 @@ The session backend needs, in the sandbox namespace, on `pods` and `secrets`: idle-reap-stop → resume (workspace + PVC preserved) → explicit destroy (PVC deleted); cross-replica exec/destroy. (Pending — requires a kind cluster + the built agent image.) -- Live agent smoke (secret-gated, needs real provider creds via Bifrost): +- Live agent smoke (secret-gated, needs real provider creds via the LLM gateway): one real `claude -p` + `opencode run` turn end-to-end. (Pending.) diff --git a/services/sandbox/src/backend/kubernetes/k8s-session-pod-spec.ts b/services/sandbox/src/backend/kubernetes/k8s-session-pod-spec.ts index 966d1e76b3..3d299aed04 100644 --- a/services/sandbox/src/backend/kubernetes/k8s-session-pod-spec.ts +++ b/services/sandbox/src/backend/kubernetes/k8s-session-pod-spec.ts @@ -259,7 +259,7 @@ export function buildSessionPod( { name: 'HTTPS_PROXY', value: cfg.egressProxy }, { name: 'HTTP_PROXY', value: cfg.egressProxy }, // Gateway reached directly on the cluster network, not via proxy. - { name: 'NO_PROXY', value: '127.0.0.1,localhost,bifrost' }, + { name: 'NO_PROXY', value: '127.0.0.1,localhost,llm-gateway' }, // DinD signal + tier for the entrypoint (sysbox/kata only). ...(dind ? [ diff --git a/services/sandbox/src/session/docker-session-args.test.ts b/services/sandbox/src/session/docker-session-args.test.ts index 263260eb3e..33fa568556 100644 --- a/services/sandbox/src/session/docker-session-args.test.ts +++ b/services/sandbox/src/session/docker-session-args.test.ts @@ -79,7 +79,7 @@ describe('buildDockerSessionRunArgs', () => { expect(args).toContain('--read-only'); expect(args).toContain('no-new-privileges'); // Gateway + convex http-actions reachable directly (not via tinyproxy). - expect(args).toContain('NO_PROXY=127.0.0.1,localhost,bifrost,convex'); + expect(args).toContain('NO_PROXY=127.0.0.1,localhost,llm-gateway,convex'); // Runnerd token in env. expect(args).toContain(`TALE_RUNNERD_TOKEN=${'a'.repeat(64)}`); // Container + workspace mount. diff --git a/services/sandbox/src/session/docker-session-args.ts b/services/sandbox/src/session/docker-session-args.ts index 49c0a64f36..e322294759 100644 --- a/services/sandbox/src/session/docker-session-args.ts +++ b/services/sandbox/src/session/docker-session-args.ts @@ -325,14 +325,14 @@ export function buildDockerSessionRunArgs( `HTTPS_PROXY=${cfg.egressProxy}`, '--env', `HTTP_PROXY=${cfg.egressProxy}`, - // Session execs reach the LLM gateway (bifrost) and the convex http-actions + // Session execs reach the LLM gateway (llm-gateway) and the convex http-actions // (the in-sandbox integration bridge → /api/integrations/*) directly on the // internal bridge — not through tinyproxy. The agent adapters set // ANTHROPIC_BASE_URL at the gateway and the bridge calls http://convex:3211, // so both must be in NO_PROXY or the CONNECT would be denied. If // EXTERNAL_AGENT_INTEGRATIONS_URL overrides the host, this list must match. '--env', - `NO_PROXY=127.0.0.1,localhost,bifrost,convex`, + `NO_PROXY=127.0.0.1,localhost,llm-gateway,convex`, // Per-org shared dep caches (empty under DinD — see cacheEnv above). ...cacheEnv, // HOME on the persistent workspace volume so agent state (~/.claude, diff --git a/tools/cli/src/lib/compose/generators/constants.ts b/tools/cli/src/lib/compose/generators/constants.ts index 2cb6e08003..ea21589765 100644 --- a/tools/cli/src/lib/compose/generators/constants.ts +++ b/tools/cli/src/lib/compose/generators/constants.ts @@ -14,6 +14,7 @@ export const DEV_VOLUME_NAMES = [ 'convex-data', 'caddy-data', 'caddy-config', + 'llm-gateway-data', ] as const; // All volumes that must exist before any `docker compose up` in production. @@ -27,6 +28,7 @@ export const REQUIRED_VOLUMES = [ 'caddy-config', 'db-data', 'db-backup', + 'llm-gateway-data', ] as const; // Enables containers to reach host services (e.g. Ollama on localhost:11434) diff --git a/tools/cli/src/lib/compose/generators/generate-dev-compose.ts b/tools/cli/src/lib/compose/generators/generate-dev-compose.ts index 450c1c4965..583c011442 100644 --- a/tools/cli/src/lib/compose/generators/generate-dev-compose.ts +++ b/tools/cli/src/lib/compose/generators/generate-dev-compose.ts @@ -12,6 +12,7 @@ import { } from '../../project/org-dirs'; import { createConvexService } from '../services/create-convex-service'; import { createDbService } from '../services/create-db-service'; +import { createLlmGatewayService } from '../services/create-llm-gateway-service'; import { createPlatformService } from '../services/create-platform-service'; import { createProxyService } from '../services/create-proxy-service'; import { createSandboxEgressService } from '../services/create-sandbox-egress-service'; @@ -161,6 +162,7 @@ export function generateDevCompose( proxy, convex, platform, + 'llm-gateway': createLlmGatewayService(config), 'sandbox-egress': createSandboxEgressService(config), sandbox, }, diff --git a/tools/cli/src/lib/compose/generators/generate-sandbox-color-compose.test.ts b/tools/cli/src/lib/compose/generators/generate-sandbox-color-compose.test.ts index dfebf21cb1..8c12bbfe4c 100644 --- a/tools/cli/src/lib/compose/generators/generate-sandbox-color-compose.test.ts +++ b/tools/cli/src/lib/compose/generators/generate-sandbox-color-compose.test.ts @@ -51,7 +51,7 @@ describe('generateSandboxColorCompose', () => { 'tale-sandbox-egress-green', ); - // Shared sandbox network (not per-colour) so bifrost/convex stay single-homed. + // Shared sandbox network (not per-colour) so llm-gateway/convex stay single-homed. expect(parsed.networks.sandbox.name).toBe('tale-sandbox-net'); expect(parsed.networks.internal.name).toBe('tale_internal'); }); diff --git a/tools/cli/src/lib/compose/generators/generate-stateful-compose.ts b/tools/cli/src/lib/compose/generators/generate-stateful-compose.ts index f3575b42a6..52c1b5cb8d 100644 --- a/tools/cli/src/lib/compose/generators/generate-stateful-compose.ts +++ b/tools/cli/src/lib/compose/generators/generate-stateful-compose.ts @@ -4,6 +4,7 @@ import { getProjectId } from '../../../utils/load-env'; import { createControllerService } from '../services/create-controller-service'; import { createConvexService } from '../services/create-convex-service'; import { createDbService } from '../services/create-db-service'; +import { createLlmGatewayService } from '../services/create-llm-gateway-service'; import { createProxyService } from '../services/create-proxy-service'; import { createSandboxEgressService } from '../services/create-sandbox-egress-service'; import { createSandboxService } from '../services/create-sandbox-service'; @@ -20,6 +21,7 @@ export function generateStatefulCompose( db: createDbService(config), proxy: createProxyService(config, hostAlias), convex, + 'llm-gateway': createLlmGatewayService(config), 'sandbox-egress': createSandboxEgressService(config), sandbox: createSandboxService(config), }; @@ -38,6 +40,10 @@ export function generateStatefulCompose( 'caddy-data': { external: true, name: `${prefix}caddy-data` }, 'caddy-config': { external: true, name: `${prefix}caddy-config` }, 'convex-data': { external: true, name: `${prefix}convex-data` }, + 'llm-gateway-data': { + external: true, + name: `${prefix}llm-gateway-data`, + }, }, networks: { internal: { external: true, name: `${prefix}internal` }, diff --git a/tools/cli/src/lib/compose/select-services.test.ts b/tools/cli/src/lib/compose/select-services.test.ts index 2836a04683..7bd4097b46 100644 --- a/tools/cli/src/lib/compose/select-services.test.ts +++ b/tools/cli/src/lib/compose/select-services.test.ts @@ -6,7 +6,7 @@ const ALL_RUNNING = () => true; const NONE_RUNNING = () => false; describe('selectDefaultServices', () => { - test('always rolls platform + the always-roll tier (convex); sandbox flips separately', () => { + test('always rolls platform + the always-roll tier (convex, llm-gateway); sandbox flips separately', () => { const sel = selectDefaultServices({ isFirstDeploy: false, stop: false, @@ -14,7 +14,7 @@ describe('selectDefaultServices', () => { }); expect(sel.rotatable).toEqual(['platform']); // sandbox / sandbox-egress are NOT here — they roll via the blue-green flip. - expect(sel.stateful).toEqual(['convex']); + expect(sel.stateful).toEqual(['convex', 'llm-gateway']); }); test('running db/proxy are left untouched without --stop', () => { @@ -35,7 +35,7 @@ describe('selectDefaultServices', () => { isStopGatedRunning: ALL_RUNNING, }); expect(sel.leftRunning).toEqual([]); - expect(sel.stateful).toEqual(['convex', 'db', 'proxy']); + expect(sel.stateful).toEqual(['convex', 'llm-gateway', 'db', 'proxy']); }); test('stopped db/proxy are updated without --stop', () => { @@ -67,6 +67,6 @@ describe('selectDefaultServices', () => { isStopGatedRunning: ALL_RUNNING, }); expect(sel.leftRunning).toEqual([]); - expect(sel.stateful).toEqual(['convex', 'db', 'proxy']); + expect(sel.stateful).toEqual(['convex', 'llm-gateway', 'db', 'proxy']); }); }); diff --git a/tools/cli/src/lib/compose/select-services.ts b/tools/cli/src/lib/compose/select-services.ts index 7045cb296e..12374e493f 100644 --- a/tools/cli/src/lib/compose/select-services.ts +++ b/tools/cli/src/lib/compose/select-services.ts @@ -21,10 +21,10 @@ interface DefaultServiceSelection { * touches, per the three-tier policy: * * - rotatable (`platform`) → always, blue-green. - * - always-roll (`convex`) → always, in-place via the stateful compose. The - * sandbox tier (`sandbox`, `sandbox-egress`) also rolls every deploy, but - * through its own zero-gap blue-green flip (`flipSandboxTier`), not here — - * so it is deliberately absent from the returned `stateful` list. + * - always-roll (`convex`, `llm-gateway`) → always, in-place via the stateful + * compose. The sandbox tier (`sandbox`, `sandbox-egress`) also rolls every + * deploy, but through its own zero-gap blue-green flip (`flipSandboxTier`), + * not here — so it is deliberately absent from the returned `stateful` list. * - stop-gated (`db`, `proxy`) → only when already stopped, on a first deploy, * or when the operator opts into the downtime with `--stop`; otherwise left * running and surfaced in `leftRunning` so the caller can warn. diff --git a/tools/cli/src/lib/compose/services/create-convex-service.ts b/tools/cli/src/lib/compose/services/create-convex-service.ts index 03e3cd325e..7eddc31c73 100644 --- a/tools/cli/src/lib/compose/services/create-convex-service.ts +++ b/tools/cli/src/lib/compose/services/create-convex-service.ts @@ -55,10 +55,17 @@ export function createConvexService(config: ServiceConfig): ComposeService { db: { condition: 'service_healthy' }, }, logging: DEFAULT_LOGGING, + // Joined to BOTH networks so the in-sandbox integration bridge can reach + // Convex at http://convex:3211 from the sandbox bridge, while the rest of + // the stack reaches it on `internal`. The `convex` alias is carried on + // each network so the same host name resolves from either side. networks: { internal: { aliases: ['convex'], }, + sandbox: { + aliases: ['convex'], + }, }, extra_hosts: EXTRA_HOSTS, }; diff --git a/tools/cli/src/lib/compose/services/create-llm-gateway-service.test.ts b/tools/cli/src/lib/compose/services/create-llm-gateway-service.test.ts new file mode 100644 index 0000000000..cba7262b7f --- /dev/null +++ b/tools/cli/src/lib/compose/services/create-llm-gateway-service.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test'; + +import { setProjectId } from '../../project/project-context'; +import type { ServiceConfig } from '../types'; +import { createLlmGatewayService } from './create-llm-gateway-service'; + +// getProjectId() (used for container_name) throws unless the project context +// has been initialised, so seed it once for these unit tests. +setProjectId('test-project'); + +const config = { + version: '0.2.17', + registry: 'ghcr.io/tale-project', +} satisfies ServiceConfig; + +describe('createLlmGatewayService', () => { + test('uses the tale-llm-gateway image at the configured registry + version', () => { + expect(createLlmGatewayService(config).image).toBe( + 'ghcr.io/tale-project/tale-llm-gateway:0.2.17', + ); + }); + + test('names the container -llm-gateway', () => { + expect(createLlmGatewayService(config).container_name).toBe( + 'test-project-llm-gateway', + ); + }); + + test('mounts the llm-gateway-data volume at /app/data', () => { + expect(createLlmGatewayService(config).volumes).toContain( + 'llm-gateway-data:/app/data', + ); + }); + + test('joins BOTH the internal and sandbox networks', () => { + const networks = createLlmGatewayService(config).networks; + if (Array.isArray(networks) || networks === undefined) { + throw new Error('llm-gateway networks should be the object form'); + } + expect(networks.internal).toBeDefined(); + expect(networks.sandbox).toBeDefined(); + }); + + test('healthchecks the gateway on :8080/health via wget', () => { + const command = createLlmGatewayService(config).healthcheck?.test; + if (!Array.isArray(command)) { + throw new Error( + 'llm-gateway healthcheck test should be a CMD-SHELL array', + ); + } + const shell = command.join(' '); + expect(shell).toContain('wget'); + expect(shell).toContain('http://127.0.0.1:8080/health'); + }); +}); diff --git a/tools/cli/src/lib/compose/services/create-llm-gateway-service.ts b/tools/cli/src/lib/compose/services/create-llm-gateway-service.ts new file mode 100644 index 0000000000..de1ed9e895 --- /dev/null +++ b/tools/cli/src/lib/compose/services/create-llm-gateway-service.ts @@ -0,0 +1,38 @@ +import { getProjectId } from '../../../utils/load-env'; +import type { ComposeService, ServiceConfig } from '../types'; +import { DEFAULT_LOGGING } from '../types'; + +/** + * LLM gateway service. Fronts every model provider with a single + * OpenAI-compatible endpoint, mints per-session virtual keys, and is the + * single source of truth for usage accounting. Built on the upstream + * maximhq/bifrost core. + * + * Joined to BOTH networks: + * - `internal` — so the platform / convex containers can reach it on + * http://llm-gateway:8080. + * - `sandbox` — so in-sandbox agents routed through EXTERNAL_AGENT_GATEWAY_URL + * can reach the gateway from the sandbox bridge. + */ +export function createLlmGatewayService(config: ServiceConfig): ComposeService { + return { + image: `${config.registry}/tale-llm-gateway:${config.version}`, + container_name: `${getProjectId()}-llm-gateway`, + env_file: ['.env'], + restart: 'unless-stopped', + mem_limit: '512m', + volumes: ['llm-gateway-data:/app/data'], + healthcheck: { + test: [ + 'CMD-SHELL', + 'wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1', + ], + interval: '10s', + timeout: '5s', + retries: 3, + start_period: '15s', + }, + logging: DEFAULT_LOGGING, + networks: { internal: {}, sandbox: {} }, + }; +} diff --git a/tools/cli/src/lib/compose/types.ts b/tools/cli/src/lib/compose/types.ts index 083a9b4c82..c8a3212f93 100644 --- a/tools/cli/src/lib/compose/types.ts +++ b/tools/cli/src/lib/compose/types.ts @@ -78,6 +78,7 @@ export const STATEFUL_SERVICES = [ 'db', 'proxy', 'convex', + 'llm-gateway', // Listed only for service-name recognition (isStatefulService / // isValidService); the default deploy rolls these via flipSandboxTier, // never through the stateful compose path. @@ -101,11 +102,13 @@ export const STOP_GATED_SERVICES = ['db', 'proxy'] as const; * Always-roll-in-place tier — deployed via the stateful compose on EVERY * default deploy. `convex` must never version-skew from platform but can't be * two-color (it owns the single `convex-data` volume), so it's recreated in - * place and only when its image actually changed. `sandbox` / `sandbox-egress` - * are NOT here: they roll through their own zero-gap blue-green flip - * (`flipSandboxTier`, alongside platform's colour), not the stateful path. + * place and only when its image actually changed. `llm-gateway` is the same + * shape — a singleton that owns the single `llm-gateway-data` volume, so it + * also rolls in place. `sandbox` / `sandbox-egress` are NOT here: they roll + * through their own zero-gap blue-green flip (`flipSandboxTier`, alongside + * platform's colour), not the stateful path. */ -export const ALWAYS_ROLL_SERVICES = ['convex'] as const; +export const ALWAYS_ROLL_SERVICES = ['convex', 'llm-gateway'] as const; export type RotatableService = (typeof ROTATABLE_SERVICES)[number]; export type StatefulService = (typeof STATEFUL_SERVICES)[number]; diff --git a/tools/cli/src/lib/config/ensure-env.ts b/tools/cli/src/lib/config/ensure-env.ts index e0e1d99e6c..1663805cac 100644 --- a/tools/cli/src/lib/config/ensure-env.ts +++ b/tools/cli/src/lib/config/ensure-env.ts @@ -275,6 +275,12 @@ export async function ensureEnv( // "Audit log integrity check failed" alert on an otherwise-clean stack. // See convex/audit_logs/{internal_mutations,verify_integrity}.ts. 'TALE_AUDIT_SIGNING_KEY', + // Admin password for the LLM gateway's management API (the platform + // pushes provider keys / mints virtual keys through it). Auto-generated + // so the gateway is locked by default and the credential is STABLE + // across deploys; the matching LLM_GATEWAY_ADMIN_USERNAME=admin is a + // static line written by generateEnvContent. + 'LLM_GATEWAY_ADMIN_PASSWORD', ]; const missingUser = requiredUserVars.filter((v) => !existing[v]); const missingAuto = requiredAutoVars.filter((v) => !existing[v]); @@ -337,6 +343,7 @@ async function runHeadlessAutoSecretFill( DB_PASSWORD: generatePassword, SANDBOX_TOKEN: generateHexSecret, TALE_AUDIT_SIGNING_KEY: generateHexSecret, + LLM_GATEWAY_ADMIN_PASSWORD: generatePassword, }; const updates: Record = {}; @@ -461,6 +468,7 @@ async function runPartialEnvSetup( DB_PASSWORD: generatePassword, SANDBOX_TOKEN: generateHexSecret, TALE_AUDIT_SIGNING_KEY: generateHexSecret, + LLM_GATEWAY_ADMIN_PASSWORD: generatePassword, }; let generatedCount = 0; @@ -530,6 +538,7 @@ async function runEnvSetup(envPath: string): Promise { sopsAgeKey: ageKeypair.secretKey, sandboxToken: generateHexSecret(), auditSigningKey: generateHexSecret(), + llmGatewayAdminPassword: generatePassword(), }); await writeFile(envPath, envContent, 'utf-8'); @@ -552,6 +561,7 @@ interface EnvConfig { sopsAgeKey: string; sandboxToken: string; auditSigningKey: string; + llmGatewayAdminPassword: string; } function generateEnvContent(config: EnvConfig): string { @@ -643,6 +653,16 @@ function generateEnvContent(config: EnvConfig): string { '# previous key on the next rotation.', `TALE_AUDIT_SIGNING_KEY=${config.auditSigningKey}`, '# TALE_AUDIT_SIGNING_KEY_PREVIOUS=', + '', + '# ============================================================================', + '# LLM Gateway (model-routing proxy)', + '# ============================================================================', + '# Admin credentials for the LLM gateway management API. The platform uses', + '# these to push provider keys and mint per-session virtual keys. The', + '# username is fixed; the password is auto-generated and must stay STABLE', + '# across deploys (a changed password locks the platform out of the gateway).', + 'LLM_GATEWAY_ADMIN_USERNAME=admin', + `LLM_GATEWAY_ADMIN_PASSWORD=${config.llmGatewayAdminPassword}`, '# Container runtime for spawned sandbox containers. `runc` (default) is', '# plain Docker; `runsc` is gVisor (requires `runsc` installed on the', '# host and registered with dockerd). gVisor provides', From a69620430aa6a684ae9d30245587e03ad64f0364 Mon Sep 17 00:00:00 2001 From: larryro <371767072@qq.com> Date: Tue, 23 Jun 2026 13:11:21 +0800 Subject: [PATCH 3/3] fix(platform): unblock CI for the llm-gateway workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fallout fixes from adding the llm-gateway service in the bifrost→llm-gateway rename: - bun.lock never registered the new @tale/llm-gateway workspace member (matched by the services/* glob), so `bun install --frozen-lockfile` aborted in the shared Setup-toolchain step of every CI job. Regenerate the lockfile so it carries the workspace entry. - the new services/llm-gateway/Dockerfile had no USER instruction, so Trivy raised AVD-DS-0002 (HIGH) — "image user should not be root". Re-assert the upstream image's existing non-root appuser (uid 1000, owns /app and the /app/data SQLite store); the runtime is unchanged. --- bun.lock | 6 ++++++ services/llm-gateway/Dockerfile | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/bun.lock b/bun.lock index 788e7bc9a5..f5223556e0 100644 --- a/bun.lock +++ b/bun.lock @@ -169,6 +169,10 @@ "@types/bun": "1.3.11", }, }, + "services/llm-gateway": { + "name": "@tale/llm-gateway", + "version": "0.1.0", + }, "services/platform": { "name": "@tale/platform", "version": "0.1.0", @@ -1920,6 +1924,8 @@ "@tale/e2e": ["@tale/e2e@workspace:packages/e2e"], + "@tale/llm-gateway": ["@tale/llm-gateway@workspace:services/llm-gateway"], + "@tale/opengrep": ["@tale/opengrep@workspace:tools/opengrep"], "@tale/platform": ["@tale/platform@workspace:services/platform"], diff --git a/services/llm-gateway/Dockerfile b/services/llm-gateway/Dockerfile index dc7bac1924..5611628087 100644 --- a/services/llm-gateway/Dockerfile +++ b/services/llm-gateway/Dockerfile @@ -28,3 +28,9 @@ LABEL org.opencontainers.image.version="${VERSION}" \ # The base image ships busybox wget but no curl. HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \ CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1 + +# Re-assert the upstream image's existing non-root user (uid 1000, owns /app and +# the /app/data SQLite store). The runtime is unchanged — bifrost already runs as +# appuser — but Trivy's static Dockerfile scan can't see the base image's USER, +# so an explicit instruction is needed to satisfy AVD-DS-0002. +USER appuser