diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index b5aada3..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(gh pr *)" - ] - } -} diff --git a/.gitignore b/.gitignore index 501ab1e..97ffb13 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ venv/ .DS_Store Thumbs.db +# Claude Code local settings (per-developer permissions, never shared) +.claude/ + # Node node_modules/ npm-debug.log* diff --git a/.handoff.md b/.handoff.md deleted file mode 100644 index f963fce..0000000 --- a/.handoff.md +++ /dev/null @@ -1,153 +0,0 @@ -# Claude Handoff: Multi-Provider Gateway Expansion - -**Date:** 2026-05-11 -**Repo:** `phoenixvc/sluice` -**Current branch:** `dev` -**Current HEAD:** `83bbfba feat(routing): multi-provider routing with smart auto alias (ADR 11) (#121)` -**Workspace state:** dirty; workflow/docs handoff changes are local and uncommitted - -## Completed Before This Session - -- Merged `phoenixvc/tokenutil` PR #1. -- Tagged `phoenixvc/tokenutil` release `v0.1.0`. -- Merged `phoenixvc/sluice` PR #121 into `dev`. -- Synced local `dev` to `origin/dev`. -- Added optional multi-provider LiteLLM routing through `extra_providers`. -- Added policy aliases: `auto`, `cheap-fast`, `cheap-reasoning`, - `cheap-long-context`, `premium`, `openrouter-free`, `kimi-coding`. -- Added direct aliases for Claude Code / Kimi use: `kimi-coding`, `kimi-k2`, - `moonshot-kimi-k2`. -- Added `scripts/sluice_router.py`, a LiteLLM `CustomLogger` callback that - rewrites `model: "auto"` before the upstream call. -- Added `router_shim_tokenutil_package`, defaulting to: - -```text -phoenixvc-tokenutil @ https://github.com/phoenixvc/tokenutil/archive/refs/tags/v0.1.0.tar.gz -``` - -## Completed In This Session - -- Patched GitHub Actions so production deploys can actually activate the - optional providers: - - `.github/workflows/deploy.yaml` - - `.github/workflows/deploy-environment.yaml` -- The workflows now build `TF_VAR_extra_providers` from non-empty prod - environment secrets: - - `GROQ_API_KEY` - - `GEMINI_API_KEY` - - `TOGETHERAI_API_KEY` - - `OPENROUTER_API_KEY` - - `FIREWORKS_API_KEY` - - `MOONSHOT_API_KEY` -- The workflows now pass `TF_VAR_enable_router_shim` from `ENABLE_ROUTER_SHIM`. -- Aligned prod workflow `codex_api_version` with `infra/env/prod/terraform.tfvars`: - `2025-04-01-preview`. -- Updated `docs/SECRETS.md` with the optional provider secrets and - `ENABLE_ROUTER_SHIM=true`. -- Added the provider secrets to the GitHub `prod` environment. Do not record - the values in repo files or handoff docs. -- Set GitHub `prod` environment variable `ENABLE_ROUTER_SHIM=true`. - -## Current GitHub Prod Environment State - -Prod environment secrets now include: - -- `AZURE_OPENAI_API_KEY` -- `AZURE_OPENAI_EMBEDDING_API_KEY` -- `AZURE_OPENAI_EMBEDDING_ENDPOINT` -- `AZURE_OPENAI_ENDPOINT` -- `FIREWORKS_API_KEY` -- `GEMINI_API_KEY` -- `GROQ_API_KEY` -- `MOONSHOT_API_KEY` -- `MYSTIRA_AZURE_CREDENTIALS` -- `OPENROUTER_API_KEY` -- `STATE_SERVICE_SHARED_TOKEN` -- `TF_BACKEND_CONTAINER` -- `TF_BACKEND_RG` -- `TF_BACKEND_SA` -- `TOGETHERAI_API_KEY` - -Prod environment variables now include: - -- `DASHBOARD_AUTH_MODE=entra` -- `ENABLE_ROUTER_SHIM=true` -- `ENTRA_CLIENT_ID=ec2f13ed-1b47-4002-ada3-b092e933a237` -- `ENTRA_TENANT_ID=9530cd32-9e33-47f0-9247-ed964730b580` -- `STATE_SERVICE_CONTAINER_IMAGE=ghcr.io/phoenixvc/sluice-state-service:sha-3c3ac34@sha256:35459ad19835055f4c046de6374620316f0c30076c11918841e1d71f6c683c64` - -## Important Files - -- `.github/workflows/deploy.yaml` -- `.github/workflows/deploy-environment.yaml` -- `docs/SECRETS.md` -- `scripts/sluice_router.py` -- `scripts/keys.yaml` -- `infra/modules/sluice_aca/main.tf` -- `infra/modules/sluice_aca/variables.tf` -- `infra/env/prod/main.tf` -- `infra/env/prod/variables.tf` -- `infra/env/prod/terraform.tfvars` -- `docs/architecture/11-multi-provider-routing.md` -- `docs/architecture/12-tokenisation-conventions.md` - -## Validation Run In This Session - -```bash -pnpm exec prettier --check .github/workflows/deploy.yaml .github/workflows/deploy-environment.yaml docs/SECRETS.md -git diff --check -- .github/workflows/deploy.yaml .github/workflows/deploy-environment.yaml docs/SECRETS.md -terraform validate # from infra/env/prod; required escalation on Windows sandbox -python -m py_compile scripts\sluice_router.py -``` - -Validation passed for the changed files and prod Terraform config. - -Known caveat: - -- Full `pnpm format:check` is still expected to fail because the repo glob - includes generated `dashboard/.next` files and unrelated pre-existing docs / - workflows. Do not mass-format those unless explicitly requested. - -## Current Workspace State - -Local uncommitted files: - -- `.github/workflows/deploy.yaml` -- `.github/workflows/deploy-environment.yaml` -- `docs/SECRETS.md` -- `.handoff.md` - -Branch: - -```text -dev...origin/dev -``` - -## Next Work - -1. Review and commit the workflow/docs changes. -2. Push the branch / open PR, or merge to the deployment branch according to the - repo flow. -3. Trigger prod deploy once the workflow changes are on the branch used by - `.github/workflows/deploy.yaml`. -4. After deploy, run: - -```bash -python scripts/manage_keys.py apply -``` - -5. Smoke test: - - `GET /v1/models` - - `POST /v1/responses` - - `POST /v1/embeddings` -6. Exercise `model: "auto"` with: - - normal short prompt - - reasoning `metadata.stage` - - long-context prompt over `SLUICE_LONG_CONTEXT_TOKENS` - - `metadata.route_hint: "kimi-coding"` for Claude Code only - -## Security Note - -The provider API keys were pasted into chat during this session and then added -as GitHub environment secrets. Rotate them after the deployment is stable if -strict secret hygiene is required. diff --git a/.pr-body.md b/.pr-body.md deleted file mode 100644 index 21e9efd..0000000 --- a/.pr-body.md +++ /dev/null @@ -1,62 +0,0 @@ -## Summary - -Add optional multi-provider routing (Groq, Together, Fireworks, Gemini, OpenRouter) behind stable policy aliases, plus a smart router shim that rewrites `model: "auto"` based on request characteristics. - -### Phase 1 — Multi-provider model groups - -- Single `extra_providers` map variable with `for_each` Key Vault secrets, container secrets, and env blocks -- Conditional model lists with LiteLLM `order` param for intra-group priority -- Policy aliases: `auto`, `cheap-fast`, `cheap-reasoning`, `cheap-long-context`, `premium`, `openrouter-free` -- Fallback chains and `context_window_fallbacks` across groups -- All 7 virtual keys updated with new aliases - -### Phase 2 — Router shim (`async_pre_call_hook`) - -- New `scripts/sluice_router.py` — `CustomLogger` subclass that rewrites `model: "auto"` to a concrete alias based on: - - Token-counted input size (>8k tokens → `cheap-long-context`) - - `metadata.route_hint` (caller override) - - `metadata.stage` (reasoning stages → `cheap-reasoning`) - - Default → `cheap-fast` -- Injects `route_alias`, `selected_model`, `route_reason` into metadata for all downstream sinks -- Gated by `enable_router_shim` variable (default false) -- Written into container via env var at startup — no custom Docker image needed -- Installs `phoenixvc-tokenutil` from `v0.1.0` at startup when the shim is enabled, with char/4 fallback if install is unavailable - -### Phase 3 — Kimi K2 - -- Adds `kimi-coding` / `kimi-k2` aliases via Groq -- Adds optional direct Moonshot fallback through `extra_providers.moonshot` -- Keeps Kimi out of `auto` until validated in production - -### Phase 4 — Tokenutil - -- Merged `phoenixvc/tokenutil` PR #1 and tagged `v0.1.0` -- Router shim now uses `tokenutil.count_tokens()` when available -- ADR 12 documents Python tokenutil and TypeScript `js-tiktoken` conventions -- Fixed Terraform sensitive `extra_providers` `for_each` handling while keeping API keys sensitive - -### Safety - -- **Zero diff** when `extra_providers` is empty and `enable_router_shim` is false -- `terraform fmt -check -recursive` passes -- `terraform validate` passes in `infra/env/prod` -- ADR: `docs/architecture/11-multi-provider-routing.md` - -### Files changed - -- `scripts/sluice_router.py` — new router shim -- `infra/modules/sluice_aca/main.tf` — provider model lists, auto alias, router shim wiring, tokenutil startup install -- `infra/modules/sluice_aca/variables.tf` — `extra_providers`, `enable_router_shim`, `router_shim_tokenutil_package` -- `infra/env/prod/main.tf` — pass-through wiring -- `infra/env/prod/variables.tf` — pass-through variables -- `infra/env/prod/terraform.tfvars` — commented examples -- `scripts/keys.yaml` — updated virtual key model allowlists -- `docs/architecture/11-multi-provider-routing.md` — new ADR -- `docs/architecture/12-tokenisation-conventions.md` — tokenization ADR -- `.handoff.md` — handoff document - ---- - -[Conversation](https://app.warp.dev/conversation/179dffdf-ac5d-4557-8612-d9e914f6875c) | [Plan: Phase 2 — Router Shim](https://app.warp.dev/drive/notebook/uFlxqUt3xy7k8G1TGLmEzU) - -Co-Authored-By: Oz diff --git a/dashboard/components/admin/admin-view.tsx b/dashboard/components/admin/admin-view.tsx index 1397374..f6f9c62 100644 --- a/dashboard/components/admin/admin-view.tsx +++ b/dashboard/components/admin/admin-view.tsx @@ -49,7 +49,7 @@ export function AdminView({ autoRefresh={false} onAutoRefreshChange={() => {}} onRefresh={() => keysData.refetch()} - lastUpdated={new Date(keysData.dataUpdatedAt || Date.now())} + lastUpdated={keysData.dataUpdatedAt ? new Date(keysData.dataUpdatedAt) : null} showAutoRefresh={false} /> diff --git a/dashboard/components/analytics/analytics-view.tsx b/dashboard/components/analytics/analytics-view.tsx index 646803e..de1bb29 100644 --- a/dashboard/components/analytics/analytics-view.tsx +++ b/dashboard/components/analytics/analytics-view.tsx @@ -189,7 +189,7 @@ export function AnalyticsView({ autoRefresh={autoRefresh} onAutoRefreshChange={setAutoRefresh} onRefresh={() => metrics.refetch()} - lastUpdated={new Date(metrics.dataUpdatedAt)} + lastUpdated={metrics.dataUpdatedAt ? new Date(metrics.dataUpdatedAt) : null} /> diff --git a/dashboard/components/tables/recent-logs-table.tsx b/dashboard/components/tables/recent-logs-table.tsx index 1eb7b43..ec9fe7c 100644 --- a/dashboard/components/tables/recent-logs-table.tsx +++ b/dashboard/components/tables/recent-logs-table.tsx @@ -224,13 +224,9 @@ export function RecentLogsTable({ ) : ( <> - {/* TODO(virtualizer-table): column widths drift from sticky header — - refactor to CSS grid (role="table" / "row" with shared - gridTemplateColumns) so virtualized absolutely-positioned rows - align with . */}
@@ -263,32 +259,49 @@ export function RecentLogsTable({ User / Key - - {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const d = sorted[virtualRow.index]; - const tsFmt = - d.tsMs > 0 ? new Date(d.tsMs).toLocaleString() : d.ts || "—"; + {/* Padding-rows virtualization (per @tanstack/react-virtual + table guidance). Browsers ignore `position: absolute` on + , so virtualized rows must stay in normal flow with + spacer s above and below the visible window. As a + bonus, real s inherit the 's column widths, + so the sticky header now aligns with body cells. */} + + {(() => { + const virtualItems = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0; + const paddingBottom = + virtualItems.length > 0 + ? totalSize - virtualItems[virtualItems.length - 1].end + : 0; return ( - setSelectedRow(d.raw)} - style={{ - position: "absolute", - top: 0, - left: 0, - width: "100%", - transform: `translateY(${virtualRow.start}px)`, - }} - /> + <> + {paddingTop > 0 ? ( + + + ) : null} + {virtualItems.map((virtualRow) => { + const d = sorted[virtualRow.index]; + const tsFmt = + d.tsMs > 0 ? new Date(d.tsMs).toLocaleString() : d.ts || "—"; + return ( + setSelectedRow(d.raw)} + /> + ); + })} + {paddingBottom > 0 ? ( + + + ) : null} + ); - })} + })()}
@@ -343,12 +356,10 @@ function DataRow({ tsFmt, derived: d, onSelect, - style, }: { tsFmt: string; derived: Derived; onSelect: () => void; - style?: React.CSSProperties; }) { // Whole-row click opens the drawer for the visual hover affordance, but the // accessible affordance is the explicit button in the first cell — that's @@ -357,7 +368,6 @@ function DataRow({ return ( diff --git a/dashboard/hooks/use-keys.ts b/dashboard/hooks/use-keys.ts index 05ea232..9ccf2ce 100644 --- a/dashboard/hooks/use-keys.ts +++ b/dashboard/hooks/use-keys.ts @@ -36,7 +36,7 @@ export function useKeys(key: string, enabled: boolean) { if (demo) return [] as LiteLLMKey[]; // Prefer the admin path: /key/list returns every key the caller can - // see, so we don't need the per-caller /v1/key/info round-trip when + // see, so we don't need the per-caller /key/info round-trip when // the user has admin scope. On 401/403 we silently fall back — that // means the caller is non-admin, which is expected for end-users. // Other errors (5xx, 404, network) are surfaced rather than @@ -55,8 +55,11 @@ export function useKeys(key: string, enabled: boolean) { const isAuthError = err instanceof ApiError && (err.status === 401 || err.status === 403); if (!isAuthError) throw err; - // Non-admin path — fetch the caller's own key. - const infoResp = await gatewayFetch("/v1/key/info", { key }); + // Non-admin path — fetch the caller's own key. Use /key/info to + // match the rest of this codebase's key-management endpoints; the + // /v1/key/* prefix is OpenAI-compat surface and isn't wired up + // for key-info in our gateway. + const infoResp = await gatewayFetch("/key/info", { key }); const info = await infoResp.json(); return [info.info] as LiteLLMKey[]; } diff --git a/infra/env/prod/main.tf b/infra/env/prod/main.tf index 9b99ad5..5253a97 100644 --- a/infra/env/prod/main.tf +++ b/infra/env/prod/main.tf @@ -74,10 +74,10 @@ module "sluice" { custom_domains = var.custom_domains } -# Adopt the out-of-band custom-domain binding for sluice.phoenixvc.tech, -# created 2026-07-03 via `az containerapp hostname add` + `az containerapp +# Adopt the out-of-band custom-domain binding for litellm.sluice.phoenixvc.tech, +# created 2026-07-12 via `az containerapp hostname add` + `az containerapp # hostname bind --validation-method CNAME` (Azure-managed certificate -# mc-pvc-prod-sluic-sluice-phoenixvc-1673). The first apply after this lands +# mc-pvc-prod-sluic-litellm-sluice-p-5988). The first apply after this lands # should report "1 to import, 0 to add/change/destroy" for this resource; # once the binding is in state the block is a no-op and may be removed. # @@ -85,8 +85,17 @@ module "sluice" { # ID parser is case-sensitive and rejects the all-lowercase form that # `az containerapp show --query id` returns. import { - to = module.sluice.azurerm_container_app_custom_domain.custom["sluice.phoenixvc.tech"] - id = "/subscriptions/bb4e3882-2079-4bab-8974-611bc0b8bb58/resourceGroups/pvc-prod-sluice-rg/providers/Microsoft.App/containerApps/pvc-prod-sluice-ca/customDomainName/sluice.phoenixvc.tech" + to = module.sluice.azurerm_container_app_custom_domain.custom["litellm.sluice.phoenixvc.tech"] + id = "/subscriptions/bb4e3882-2079-4bab-8974-611bc0b8bb58/resourceGroups/pvc-prod-sluice-rg/providers/Microsoft.App/containerApps/pvc-prod-sluice-ca/customDomainName/litellm.sluice.phoenixvc.tech" +} + +# Adopt the out-of-band custom-domain binding for the dashboard entrypoint, +# created 2026-07-12 via `az containerapp hostname add` + `az containerapp +# hostname bind` using Azure-managed certificate +# mc-pvc-prod-sluic-sluice-phoenixvc-1673. +import { + to = module.dashboard.azurerm_container_app_custom_domain.custom["sluice.phoenixvc.tech"] + id = "/subscriptions/bb4e3882-2079-4bab-8974-611bc0b8bb58/resourceGroups/pvc-prod-sluice-rg/providers/Microsoft.App/containerApps/pvc-prod-sluice-dashboard/customDomainName/sluice.phoenixvc.tech" } module "state_service" { @@ -118,6 +127,9 @@ module "dashboard" { resource_group_name = module.sluice.resource_group_name container_image = var.dashboard_container_image gateway_url = module.sluice.gateway_url + gateway_public_url = var.gateway_public_url + dashboard_public_url = var.dashboard_public_url + custom_domains = var.dashboard_custom_domains grafana_url = var.grafana_url state_service_url = var.state_service_container_image == "" ? "" : module.state_service[0].state_service_url state_service_shared_token = var.state_service_shared_token diff --git a/infra/env/prod/terraform.tfvars b/infra/env/prod/terraform.tfvars index a215f84..6f16471 100644 --- a/infra/env/prod/terraform.tfvars +++ b/infra/env/prod/terraform.tfvars @@ -72,5 +72,11 @@ enable_litellm_db = true # Custom domain for the gateway. DNS (CNAME + asuid TXT) lives in the # org-owned phoenixvc.tech zone in `mys-global-shared-rg` (a different # subscription than sluice) and is intentionally not managed by this stack. -# The Azure-managed certificate was bound out-of-band on 2026-07-03. -custom_domains = ["sluice.phoenixvc.tech"] +# The Azure-managed certificate was bound out-of-band on 2026-07-12. +custom_domains = ["litellm.sluice.phoenixvc.tech"] +gateway_public_url = "https://litellm.sluice.phoenixvc.tech" + +# Public dashboard entrypoint. The dashboard links through to the LiteLLM +# admin UI at gateway_public_url /ui. +dashboard_custom_domains = ["sluice.phoenixvc.tech"] +dashboard_public_url = "https://sluice.phoenixvc.tech" diff --git a/infra/env/prod/variables.tf b/infra/env/prod/variables.tf index 9b63f39..9f78adb 100644 --- a/infra/env/prod/variables.tf +++ b/infra/env/prod/variables.tf @@ -335,3 +335,29 @@ variable "custom_domains" { description = "Pre-verified custom hostnames to bind to the gateway Container App. Each hostname must already have DNS in place (CNAME + asuid TXT in the owning zone) and an Azure-managed certificate provisioned out-of-band via `az containerapp hostname add` + `az containerapp hostname bind` before being listed here." default = [] } + +variable "gateway_public_url" { + type = string + description = "Public HTTPS URL for the LiteLLM gateway/admin UI that dashboard and consumers should use. Empty falls back to the raw ACA gateway URL." + default = "" + validation { + condition = var.gateway_public_url == "" || can(regex("^https://", var.gateway_public_url)) + error_message = "gateway_public_url must be empty or start with https://." + } +} + +variable "dashboard_public_url" { + type = string + description = "Public HTTPS URL for the Sluice dashboard entrypoint. Empty falls back to the raw dashboard ACA URL." + default = "" + validation { + condition = var.dashboard_public_url == "" || can(regex("^https://", var.dashboard_public_url)) + error_message = "dashboard_public_url must be empty or start with https://." + } +} + +variable "dashboard_custom_domains" { + type = list(string) + description = "Pre-verified custom hostnames to bind to the dashboard Container App." + default = [] +} diff --git a/infra/modules/dashboard_aca/main.tf b/infra/modules/dashboard_aca/main.tf index 30a89dd..6745d95 100644 --- a/infra/modules/dashboard_aca/main.tf +++ b/infra/modules/dashboard_aca/main.tf @@ -15,12 +15,15 @@ locals { use_shared_token = trimspace(var.state_service_shared_token) != "" use_entra = var.auth_mode == "entra" + gateway_url = trimspace(var.gateway_public_url) != "" ? trimspace(var.gateway_public_url) : var.gateway_url + # Derive the dashboard's public URL from the gateway URL by swapping the # subdomain. Gateway: https://pvc-{env}-{proj}-ca. → Dashboard: # https://pvc-{env}-{proj}-dashboard.. Used to set NEXTAUTH_URL so # next-auth knows the correct origin for redirects. - cae_suffix = trimprefix(var.gateway_url, "https://${local.prefix}-ca.") - dashboard_url = local.cae_suffix == var.gateway_url ? "" : "https://${local.ca_name}.${local.cae_suffix}" + cae_suffix = trimprefix(var.gateway_url, "https://${local.prefix}-ca.") + derived_dashboard_url = local.cae_suffix == var.gateway_url ? "" : "https://${local.ca_name}.${local.cae_suffix}" + dashboard_url = trimspace(var.dashboard_public_url) != "" ? trimspace(var.dashboard_public_url) : local.derived_dashboard_url tags = merge({ env = var.env @@ -156,7 +159,7 @@ resource "azurerm_container_app" "dashboard" { env { name = "GATEWAY_URL" - value = var.gateway_url + value = local.gateway_url } env { @@ -277,3 +280,16 @@ resource "azurerm_container_app" "dashboard" { } } } + +# Custom domain bindings for the dashboard entrypoint. DNS and managed +# certificate provisioning are out-of-band, matching the gateway module's +# ownership boundary for the phoenixvc.tech zone. +resource "azurerm_container_app_custom_domain" "custom" { + for_each = toset(var.custom_domains) + name = each.value + container_app_id = azurerm_container_app.dashboard.id + + lifecycle { + ignore_changes = [certificate_binding_type, container_app_environment_certificate_id] + } +} diff --git a/infra/modules/dashboard_aca/outputs.tf b/infra/modules/dashboard_aca/outputs.tf index 69a3ed2..6755242 100644 --- a/infra/modules/dashboard_aca/outputs.tf +++ b/infra/modules/dashboard_aca/outputs.tf @@ -5,5 +5,10 @@ output "dashboard_fqdn" { output "dashboard_url" { description = "Public HTTPS URL of the dashboard." - value = "https://${azurerm_container_app.dashboard.ingress[0].fqdn}" + value = local.dashboard_url != "" ? local.dashboard_url : "https://${azurerm_container_app.dashboard.ingress[0].fqdn}" +} + +output "custom_domain_fqdns" { + description = "Custom hostnames bound to the dashboard Container App." + value = [for d in azurerm_container_app_custom_domain.custom : d.name] } diff --git a/infra/modules/dashboard_aca/variables.tf b/infra/modules/dashboard_aca/variables.tf index 64992ab..1530cff 100644 --- a/infra/modules/dashboard_aca/variables.tf +++ b/infra/modules/dashboard_aca/variables.tf @@ -44,6 +44,32 @@ variable "gateway_url" { } } +variable "gateway_public_url" { + type = string + description = "Optional public HTTPS URL of the LiteLLM gateway/admin UI. When set, the dashboard uses this for its gateway link and proxy target instead of the raw ACA FQDN." + default = "" + validation { + condition = var.gateway_public_url == "" || can(regex("^https://", var.gateway_public_url)) + error_message = "gateway_public_url must be empty or start with https://." + } +} + +variable "dashboard_public_url" { + type = string + description = "Optional public HTTPS URL of the dashboard, used for auth callback origins such as NEXTAUTH_URL." + default = "" + validation { + condition = var.dashboard_public_url == "" || can(regex("^https://", var.dashboard_public_url)) + error_message = "dashboard_public_url must be empty or start with https://." + } +} + +variable "custom_domains" { + type = list(string) + description = "Pre-verified custom hostnames to bind to the dashboard Container App. DNS and Azure-managed certs are provisioned out-of-band before listing here." + default = [] +} + variable "grafana_url" { type = string description = "Grafana Cloud stack URL for the dashboard link button (leave empty to hide the button)" diff --git a/infra/modules/sluice_aca/main.tf b/infra/modules/sluice_aca/main.tf index 4519b95..f446646 100644 --- a/infra/modules/sluice_aca/main.tf +++ b/infra/modules/sluice_aca/main.tf @@ -418,9 +418,12 @@ locals { }, ] : [] - # ── "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. + # ── "auto" alias — always defined, even without extra providers ──────── + # `auto` is allowlisted by every vkey in keys.yaml, so the alias must exist + # in model_list regardless of which providers are configured. When none of + # groq/together/fireworks is present, auto degrades gracefully to Azure + # (same target as `premium`) so callers asking for model="auto" still get + # a response instead of a "model not found" error. auto_model_provider = ( local.has_groq ? "groq/openai/gpt-oss-20b" : local.has_together ? "together_ai/openai/gpt-oss-20b" : @@ -433,8 +436,37 @@ locals { local.has_fireworks ? "os.environ/FIREWORKS_API_KEY" : "os.environ/LITELLM_AZURE_OPENAI_API_KEY" ) + auto_uses_azure = !(local.has_groq || local.has_together || local.has_fireworks) + # Spend tracking uses these; mismatched costs would corrupt /spend/logs. + auto_model_cost_in = ( + local.has_groq ? 0.000000075 : + local.has_together ? 0.00000005 : + local.has_fireworks ? 0.0000001 : + var.codex_input_cost_per_token + ) + auto_model_cost_out = ( + local.has_groq ? 0.0000003 : + local.has_together ? 0.0000002 : + local.has_fireworks ? 0.0000004 : + var.codex_output_cost_per_token + ) has_any_extra = local.has_groq || local.has_together || local.has_fireworks || local.has_gemini - auto_models = local.has_any_extra ? [ + # Which alias groups actually exist in model_list. cheap-fast/cheap-reasoning + # come from any of groq/together/fireworks; cheap-long-context comes from + # gemini only. Without these guards, has_any_extra=true with only gemini + # configured would emit fallbacks pointing at non-existent aliases. + has_cheap_fast = local.has_groq || local.has_together || local.has_fireworks + has_cheap_reasoning = local.has_groq || local.has_together || local.has_fireworks + has_cheap_long_context = local.has_gemini + # Chain for `auto` overflow: skip any alias group that isn't actually + # defined, always end at `premium` (Azure, always present). + auto_fallback_chain = compact([ + local.has_cheap_fast ? "cheap-fast" : "", + local.has_cheap_reasoning ? "cheap-reasoning" : "", + local.has_cheap_long_context ? "cheap-long-context" : "", + "premium", + ]) + auto_models = [ { model_name = "auto" litellm_params = merge( @@ -443,17 +475,17 @@ locals { api_key = local.auto_model_env }, # Azure needs api_base + api_version; external providers don't. - local.has_groq || local.has_together || local.has_fireworks ? {} : { + local.auto_uses_azure ? { api_base = var.azure_openai_endpoint api_version = var.codex_api_version - } + } : {} ) model_info = { - input_cost_per_token = 0.000000075 - output_cost_per_token = 0.0000003 + input_cost_per_token = local.auto_model_cost_in + output_cost_per_token = local.auto_model_cost_out } }, - ] : [] + ] # ── Router shim (Phase 2, ADR 11) ─────────────────────────────────── # When enabled, the Python callback file is written into the container @@ -483,12 +515,12 @@ locals { # 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"] }), - ] : [] + extra_fallbacks = local.has_any_extra ? concat( + [tomap({ auto = local.auto_fallback_chain })], + local.has_cheap_fast ? [tomap({ "cheap-fast" = ["cheap-reasoning", "premium"] })] : [], + local.has_cheap_reasoning ? [tomap({ "cheap-reasoning" = ["premium"] })] : [], + local.has_cheap_long_context ? [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"] }), @@ -528,15 +560,18 @@ locals { 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 + ADR 09). The auto/cheap-* - # chains are added when an extra provider is active; the agent-default + # chains are added when an extra provider is active (and gated on whether + # the source alias actually exists in model_list); 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"] }, - { auto = ["cheap-long-context"] }, - ] : null + # Context-window fallbacks only make sense if cheap-long-context (Gemini) + # exists; without it there's nowhere to overflow to. + context_window_fallbacks = local.has_cheap_long_context ? concat( + local.has_cheap_fast ? [{ "cheap-fast" = ["cheap-long-context"] }] : [], + local.has_cheap_reasoning ? [{ "cheap-reasoning" = ["cheap-long-context"] }] : [], + [{ auto = ["cheap-long-context"] }], + ) : null } litellm_settings = { for k, v in local.litellm_settings_raw : k => v if v != null }