From 8e98d04bb264912a4de86c3e7e36b672eb2841f9 Mon Sep 17 00:00:00 2001 From: Jurie Smit Date: Fri, 3 Jul 2026 14:27:08 +0200 Subject: [PATCH] feat(infra): route CI Claude Code via Sluice with agent-default alias (ADR 09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable Anthropic/Claude in the LiteLLM gateway so non-interactive CI agents can route Claude Code through Sluice on API-key billing, per ADR 09 ("API-key billing = through Sluice"). - model_list: add anthropic_models (claude-haiku-4-5 cheap tier, claude-sonnet-4-6 mid tier) plus an `agent-default` alias group mapping to the cheap tier as primary (order 1), degrading to the mid tier then Azure `premium` via a new fallback chain. Gated on has_anthropic; concatenated into litellm_model_list. - Secret wiring reuses the existing extra_providers path: an `anthropic` entry creates the provider-anthropic-key KV secret and injects the LITELLM_ANTHROPIC_API_KEY env var on the container — no bespoke wiring. - Provider activation: deploy.yaml + deploy-environment.yaml read an ANTHROPIC_API_KEY GitHub secret and fold it into TF_VAR_extra_providers as env_name LITELLM_ANTHROPIC_API_KEY; absent key => provider stays inactive. - keys.yaml: add a dedicated mystira-ci virtual key allow-listing agent-default + the Claude models (interactive claude-code-cli stays direct-Anthropic on the Max subscription). - ADR 09 flipped to Accepted (2026-07-03); operator go-live steps updated to the real extra_providers flow. terraform fmt: clean. terraform validate: passes. Requires `python scripts/manage_keys.py apply` after deploy. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/deploy-environment.yaml | 6 ++ .github/workflows/deploy.yaml | 5 + .../architecture/09-claude-code-via-sluice.md | 84 +++++++++++----- infra/env/prod/terraform.tfvars | 3 + infra/modules/sluice_aca/main.tf | 98 +++++++++++++++++-- scripts/keys.yaml | 17 ++++ 6 files changed, 179 insertions(+), 34 deletions(-) diff --git a/.github/workflows/deploy-environment.yaml b/.github/workflows/deploy-environment.yaml index 828653b..6b18fb5 100644 --- a/.github/workflows/deploy-environment.yaml +++ b/.github/workflows/deploy-environment.yaml @@ -111,6 +111,8 @@ on: required: false MOONSHOT_API_KEY: required: false + ANTHROPIC_API_KEY: + required: false env: TF_BACKEND_RG: ${{ secrets.TF_BACKEND_RG }} @@ -208,6 +210,7 @@ jobs: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || '' }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY || '' }} MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY || '' }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY || '' }} run: | set -euo pipefail @@ -233,6 +236,9 @@ jobs: add_provider "openrouter" "OPENROUTER_API_KEY" "${OPENROUTER_API_KEY}" add_provider "fireworks" "FIREWORKS_API_KEY" "${FIREWORKS_API_KEY}" add_provider "moonshot" "MOONSHOT_API_KEY" "${MOONSHOT_API_KEY}" + # ADR 09: env_name is LITELLM_ANTHROPIC_API_KEY (the var the LiteLLM + # model_list reads), sourced from the ANTHROPIC_API_KEY secret. + add_provider "anthropic" "LITELLM_ANTHROPIC_API_KEY" "${ANTHROPIC_API_KEY}" if [ "${providers}" != "{}" ]; then echo "TF_VAR_extra_providers=${providers}" >> "$GITHUB_ENV" diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 3cfd26a..5952dd7 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -168,6 +168,7 @@ jobs: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY || '' }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY || '' }} MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY || '' }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY || '' }} run: | set -euo pipefail @@ -193,6 +194,9 @@ jobs: add_provider "openrouter" "OPENROUTER_API_KEY" "${OPENROUTER_API_KEY}" add_provider "fireworks" "FIREWORKS_API_KEY" "${FIREWORKS_API_KEY}" add_provider "moonshot" "MOONSHOT_API_KEY" "${MOONSHOT_API_KEY}" + # ADR 09: env_name is LITELLM_ANTHROPIC_API_KEY (the var the LiteLLM + # model_list reads), sourced from the ANTHROPIC_API_KEY secret. + add_provider "anthropic" "LITELLM_ANTHROPIC_API_KEY" "${ANTHROPIC_API_KEY}" if [ "${providers}" != "{}" ]; then echo "TF_VAR_extra_providers=${providers}" >> "$GITHUB_ENV" @@ -363,3 +367,4 @@ jobs: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/docs/architecture/09-claude-code-via-sluice.md b/docs/architecture/09-claude-code-via-sluice.md index 5a84c9f..2341ca8 100644 --- a/docs/architecture/09-claude-code-via-sluice.md +++ b/docs/architecture/09-claude-code-via-sluice.md @@ -1,7 +1,26 @@ # Routing Claude Code CLI Through Sluice -Status: Open — awaiting decision -Date: 2026-04-30 +Status: Accepted — 2026-07-03 +Date: 2026-04-30 (analysis) · 2026-07-03 (decision) + +## Decision + +CI and other **non-interactive** agents route Claude Code (and any Anthropic-SDK +caller) through Sluice on API-key billing, requesting the **`agent-default`** +alias. Sluice resolves `agent-default` to a concrete Claude model (currently +`claude-haiku-4-5`, degrading to `claude-sonnet-4-6` then Azure `premium`), so +the model can be re-routed centrally without touching any CI workflow. +**Interactive** Claude Code use backed by a Claude Max subscription stays on +**direct-Anthropic** auth — the flat-fee Max economics dominate per-token API +rates for human-driven sessions (see the analysis and recommendation below, +which this decision adopts unchanged). + +Implemented via: an `anthropic` entry in the LiteLLM `model_list` +(`infra/modules/sluice_aca/main.tf`), the `LITELLM_ANTHROPIC_API_KEY` secret +wired through the standard `extra_providers` path, provider activation in the +deploy workflows (`ANTHROPIC_API_KEY` GitHub secret → `TF_VAR_extra_providers`), +and a dedicated `mystira-ci` virtual key allow-listing `agent-default` +(`scripts/keys.yaml`). ## Question @@ -109,30 +128,43 @@ spending subscription quota through a self-hosted gateway. on subscription-first (Claude Code in particular). Routing through API could miss features for weeks. -## If we proceed — what setup looks like - -### Operator (one-time) - -1. Generate an Anthropic Console API key (pay-per-token billing on the - org's Console account) -2. Store it in KV: `az keyvault secret set --vault-name pvc-prod-sluice-kv - --name anthropic-api-key --value ` -3. Add a `model_list` entry to the LiteLLM YAML in - `infra/modules/sluice_aca/main.tf` — e.g. - ```yaml - - model_name: claude-sonnet-4-6 - litellm_params: - model: anthropic/claude-sonnet-4-6 - api_key: os.environ/LITELLM_ANTHROPIC_API_KEY - model_info: - input_cost_per_token: 0.000003 - output_cost_per_token: 0.000015 - ``` -4. Mount `LITELLM_ANTHROPIC_API_KEY` from the `anthropic-api-key` KV - secret on the LiteLLM container -5. Update each consumer's `keys.yaml` entry to include the new Claude - model in `models: [...]` -6. `python scripts/manage_keys.py apply` — re-applies model allowlists +## Setup (implemented) + +Steps 2–5 of the original analysis are now **in code** — the `model_list` +entries, the `LITELLM_ANTHROPIC_API_KEY` env wiring (via the standard +`extra_providers` path), provider activation in the deploy workflows, and the +`mystira-ci` allow-list all ship in this repo. The remaining operator work is +placing the key and running one deploy. + +### Operator (one-time — the single manual step to go live) + +1. Generate an Anthropic Console API key (pay-per-token billing on the org's + Console account). +2. Make the key available to Terraform. Two equivalent options: + - **Preferred — GitHub environment secret** (matches how every other + provider key is supplied): set `ANTHROPIC_API_KEY` as a `prod` environment + secret. The `Configure optional model providers` step folds it into + `TF_VAR_extra_providers` as + `{ anthropic = { api_key = , env_name = "LITELLM_ANTHROPIC_API_KEY" } }`, + and Terraform then **creates** the KV secret `provider-anthropic-key` and + mounts it as the `LITELLM_ANTHROPIC_API_KEY` env var on the LiteLLM + container. + - **Direct KV** (matches the runbook convention below): + ``` + az keyvault secret set --vault-name pvc-prod-sluice-kv \ + --name anthropic-api-key --value + ``` + Use this only if wiring `TF_VAR_extra_providers` from that KV secret rather + than a GitHub secret; the `extra_providers`-managed secret is + `provider-anthropic-key`, so keep the two out of sync deliberately. +3. Deploy: run the `deploy` workflow (or merge to trigger it) so Terraform + provisions `provider-anthropic-key`, injects the env var, and renders the + Claude models + `agent-default` alias into the LiteLLM config. +4. `python scripts/manage_keys.py apply` — applies the `mystira-ci` allow-list + (and any other `keys.yaml` changes) on the running proxy. + +CI consumers then set the two env vars from the per-consumer snippet below, +using `vkey-mystira-ci`, and request `model="agent-default"`. ### Per-consumer (each service / user) diff --git a/infra/env/prod/terraform.tfvars b/infra/env/prod/terraform.tfvars index 3d9922c..a215f84 100644 --- a/infra/env/prod/terraform.tfvars +++ b/infra/env/prod/terraform.tfvars @@ -64,6 +64,9 @@ enable_litellm_db = true # fireworks = { api_key = "...", env_name = "FIREWORKS_API_KEY" } # # Phase 3 — Kimi K2 direct Moonshot path (optional; Groq path uses GROQ_API_KEY above) # moonshot = { api_key = "sk-...", env_name = "MOONSHOT_API_KEY" } +# # ADR 09 — Claude Code CLI / CI agents via Sluice (env_name is the var the +# # LiteLLM model_list reads, NOT the GitHub secret name). +# anthropic = { api_key = "sk-ant-...", env_name = "LITELLM_ANTHROPIC_API_KEY" } # } # Custom domain for the gateway. DNS (CNAME + asuid TXT) lives in the diff --git a/infra/modules/sluice_aca/main.tf b/infra/modules/sluice_aca/main.tf index 57fe5d8..4519b95 100644 --- a/infra/modules/sluice_aca/main.tf +++ b/infra/modules/sluice_aca/main.tf @@ -352,6 +352,72 @@ locals { }, ] : [] + # ── Anthropic / Claude models (ADR 09) ───────────────────────────────── + # Enables Claude Code CLI and other Anthropic-SDK callers to route through + # Sluice on API-key billing (ADR 09: "API-key billing = through Sluice"). + # The `agent-default` alias is what non-interactive CI agents request; it + # maps to the cheap Claude tier as primary (order 1) and degrades to the + # mid tier via the fallback chain, so Sluice picks the concrete model and + # can re-route later without touching CI workflows. Activated by supplying + # an "anthropic" entry in extra_providers (env_name = LITELLM_ANTHROPIC_API_KEY). + has_anthropic = contains(local.active_provider_names, "anthropic") + anthropic_models = local.has_anthropic ? [ + # Concrete Claude models (current Anthropic model ids). + # Cheap tier — Claude Haiku 4.5 ($1 / $5 per 1M tokens). + { + model_name = "claude-haiku-4-5" + litellm_params = { + model = "anthropic/claude-haiku-4-5" + api_key = "os.environ/LITELLM_ANTHROPIC_API_KEY" + } + model_info = { + input_cost_per_token = 0.000001 + output_cost_per_token = 0.000005 + } + }, + # Mid tier — Claude Sonnet 4.6 ($3 / $15 per 1M tokens). + { + model_name = "claude-sonnet-4-6" + litellm_params = { + model = "anthropic/claude-sonnet-4-6" + api_key = "os.environ/LITELLM_ANTHROPIC_API_KEY" + } + model_info = { + input_cost_per_token = 0.000003 + output_cost_per_token = 0.000015 + } + }, + # agent-default group — CI agents request model="agent-default". + # Cheap Claude tier is primary (order 1) so Sluice picks the concrete + # model; degrades to the mid tier (order 2) then to `premium` (Azure, + # always present) via the fallback chain below. + { + model_name = "agent-default" + litellm_params = { + model = "anthropic/claude-haiku-4-5" + api_key = "os.environ/LITELLM_ANTHROPIC_API_KEY" + order = 1 + } + model_info = { + input_cost_per_token = 0.000001 + output_cost_per_token = 0.000005 + } + }, + # agent-default fallback member — mid Claude tier (order 2). + { + model_name = "agent-default" + litellm_params = { + model = "anthropic/claude-sonnet-4-6" + api_key = "os.environ/LITELLM_ANTHROPIC_API_KEY" + order = 2 + } + model_info = { + input_cost_per_token = 0.000003 + output_cost_per_token = 0.000015 + } + }, + ] : [] + # ── "auto" alias — points to cheapest entry with fallback chain ──────── # auto is a model group with a single entry (Groq gpt-oss-20b if available, # otherwise Together, otherwise Fireworks). Fallback chains handle the rest. @@ -407,9 +473,28 @@ locals { local.gemini_models, local.openrouter_models, local.moonshot_models, + local.anthropic_models, local.auto_models, ) + # ── Fallback chains ──────────────────────────────────────────────────── + # auto/cheap-* chains exist only with an extra provider (ADR 11); the + # agent-default chain exists only with Anthropic (ADR 09). Built + # separately and concatenated so either can appear on its own. + # Each entry is a single-key map(list(string)); tomap() keeps the element + # type uniform so concat() doesn't choke on differing object attribute sets. + extra_fallbacks = local.has_any_extra ? [ + tomap({ auto = ["cheap-fast", "cheap-reasoning", "cheap-long-context", "premium"] }), + tomap({ cheap-fast = ["cheap-reasoning", "premium"] }), + tomap({ cheap-reasoning = ["premium"] }), + tomap({ cheap-long-context = ["premium"] }), + ] : [] + # agent-default degrades cheap Claude → mid Claude → premium (Azure). + anthropic_fallbacks = local.has_anthropic ? [ + tomap({ "agent-default" = ["claude-sonnet-4-6", "premium"] }), + ] : [] + combined_fallbacks = concat(local.extra_fallbacks, local.anthropic_fallbacks) + # Build with nullable optional keys, then filter out the nulls. Terraform's # ternary requires both branches to share a type, so the cleaner-looking # `condition ? {extras} : {}` doesn't compile against typed objects. @@ -442,14 +527,11 @@ locals { budget_duration = var.budget_duration != "" ? var.budget_duration : null rpm_limit = var.rpm_limit > 0 ? var.rpm_limit : null tpm_limit = var.tpm_limit > 0 ? var.tpm_limit : null - # Multi-provider fallback chains (ADR 11). Only added when at least one - # extra provider is active; otherwise LiteLLM runs with Azure-only. - fallbacks = local.has_any_extra ? [ - { auto = ["cheap-fast", "cheap-reasoning", "cheap-long-context", "premium"] }, - { cheap-fast = ["cheap-reasoning", "premium"] }, - { cheap-reasoning = ["premium"] }, - { cheap-long-context = ["premium"] }, - ] : null + # Multi-provider fallback chains (ADR 11 + ADR 09). The auto/cheap-* + # chains are added when an extra provider is active; the agent-default + # chain is added when Anthropic is active. Concatenated so either set can + # appear independently; null only when neither is present (Azure-only). + fallbacks = length(local.combined_fallbacks) > 0 ? local.combined_fallbacks : null context_window_fallbacks = local.has_any_extra ? [ { cheap-fast = ["cheap-long-context"] }, { cheap-reasoning = ["cheap-long-context"] }, diff --git a/scripts/keys.yaml b/scripts/keys.yaml index 9905ab0..33ba536 100644 --- a/scripts/keys.yaml +++ b/scripts/keys.yaml @@ -67,6 +67,23 @@ keys: operator: jurie@phoenixvc.tech use: interactive + # ── Mystira CI (non-interactive agents via Sluice — ADR 09) ────────────── + # Claude Code CLI in CI / automation routes through Sluice on API-key + # billing. Requests model="agent-default"; Sluice maps it to the cheap + # Claude tier and can re-route later without a workflow change. Separate + # from claude-code-cli (interactive, Max-subscription — stays direct). + - alias: mystira-ci + max_budget: 10 + budget_duration: 7d + rpm_limit: 120 + tpm_limit: 200000 + models: [agent-default, claude-haiku-4-5, claude-sonnet-4-6, gpt-4o, text-embedding-3-large, auto, cheap-fast, cheap-reasoning, premium] + metadata: + project: mystira-workspace + service: ci + use: non-interactive + contact: jurie@phoenixvc.tech + # ── Dashboard Test Playground (small, public-key style) ────────────────── - alias: dashboard-playground max_budget: 1