fovea LLM role routing via agentgateway to ZDR open models
Design bead: hl-3w5i
Related: hl-uh07 (fovea deploy epic), hl-uh07.9 (verify + go-live), hl-z5fc (mesh egress)
Status: Design
Date: 2026-06-25
Summary
Section titled “Summary”fovea (0.4.x) reviews PRs in a scout → gate → deepdive pipeline. It currently
runs Anthropic-direct (claude-sonnet-4-6 for every role). This spec routes
fovea’s two LLM roles through the cluster’s agentgateway (llm-gw.fzymgc.house)
to zero-data-retention (ZDR) open models on OpenRouter, and uses fovea’s
0.4.x multi-aspect scout to tier models per aspect:
scoutrole → DeepSeek V4-Flash (cheap, high-volume scan). Reuses the existingopenrouter-zdrlane.deepdiverole → GLM-5.2 (agentic verifier emitting a strict-JSON verdict). New dedicated ZDR backend.- Aspect tiering:
correctness+securityscout aspects route to thedeepdiverole (GLM-5.2);types+testsstay on thescoutrole (Flash). claude-sonnet-4-6is retained as a documented break-glass fallback (not built).
This reverses the prior “fovea is intentionally Anthropic-direct” decision.
The driver is cost (open models are 5–80× cheaper per output token than sonnet)
and consolidation onto the cluster’s existing gateway, while keeping private PR
diffs on ZDR endpoints. fovea stays in shadow mode (publish_enabled: false)
throughout; go-live remains the separate hl-uh07.9 gate.
- Route both fovea LLM roles through agentgateway (single OpenAI-compatible dialect).
- Keep all code-bearing traffic on ZDR endpoints (matches the octopus/engram precedent).
- Use per-aspect tiering to spend GLM-5.2 only where it matters and Flash everywhere else.
- Preserve the
response_format: json_schema+ tool-calling guarantees deepdive depends on. - No behaviour change for users: ship in shadow mode behind the existing go-live gate.
Non-goals
Section titled “Non-goals”- Flipping
publish_enabled: true(separate, gated byhl-uh07.9). - Istio/mesh egress for fovea (
hl-z5fc). - Building the sonnet ZDR fallback backend (documented, deferred — YAGNI).
- Retrieval/embedding routing (
retrieval.enabled: falsetoday; out of scope). - Any upstream fovea change (e.g. N-tier roles); we design within current capabilities.
Grounded facts & constraints
Section titled “Grounded facts & constraints”These shape the design; full grounding traces are on bead hl-3w5i.
-
fovea role schema (
internal/config/config.go).gateway.rolesis amap[string]RoleModel; eachRoleModel={provider (anthropic|openai, default openai), model, base_url (optional per-role openai endpoint override), api_key}. Required roles arescoutanddeepdive; each must setmodel. Per-role provider drives which credential is required; aprovider: openairole with nobase_url(role orgateway.openai.base_url) fails config validation at boot (this caused the 0.4.3 CrashLoopBackOff, fixed in #1380). -
Closed role set → two-tier only (
internal/core/aspect.go,internal/eyes/scout/registry.go,internal/llm/provider.go). Built-in scout aspects arecorrectness,security,types,tests. The per-aspectscout.aspects[<aspect>].modeloverride is anllm.Role, not a model id, andvalidRole()accepts onlyscoutordeepdive(unknown → warn + fall back toscout). So per-aspect tiering is bounded to the two role models; a third distinct scout model is impossible without an upstream change. The “strong scout tier” is therefore necessarily thedeepdiverole model. -
Structured output (ADR
fovea-fl2). deepdive’s verdict usesresponse_format: {type: json_schema, strict}on Chat Completions (schema from a Go struct viainvopop/jsonschema). The gateway translates it to each backend’s native mechanism. The model’s OpenRouter endpoint must supportstructured_outputs+tools, or the request fails. -
Single dialect (ADR
fovea-m7c). All LLM calls go through one OpenAI-compatible gateway dialect — so the sonnet fallback also routes through the gateway (provider: openai), not a second Anthropic-native path. -
OpenRouter mechanics.
require_parameters: trueforces routing only to provider endpoints that support the requested params (response_format+tools) and fails loud otherwise — the structural safety net against a silent re-run of the #17 verdict-parse failure. All chosen models advertisetools+structured_outputs+ ZDR endpoints (verified live againstGET /api/v1/modelsand?zdr=true). Fusion and Auto Router are rejected: Fusion reportstools:false, structured_outputs:false, makes external web calls (violates ZDR), and is dynamically priced (~4–5× a completion). -
agentgateway ZDR is backend-global (
llm-policies.yaml,llm-backends.yaml).backend.ai.overrides(zdr,require_parameters) has no per-model selector, so each ZDR model needs its own dedicatedAgentgatewayBackend(pinningprovider.openai.modelcollapses aliases to it). Today the only ZDR chat lane isopenrouter-zdr→deepseek/deepseek-v4-flash, reached via anx-agentgateway-backendheader. -
fovea is the cluster’s first ZDR chat client. Proven ZDR lanes today are all embeddings (octopus bge, engram qwen3). The existing ZDR chat route is header-gated and was left as a “reachability via real clients” live-verify gate. fovea’s
RoleModelexposes per-rolebase_urlbut no custom-header field, so it cannot use the header route — driving the header-free, path-based routes below. The header-free path mechanism itself is proven:llm-embeddings-engramalready serves a custom prefix/engram-embed/v1/embeddings→ a dedicated ZDR backend (no rewrite filter, longest-prefix-matched). fovea mirrors that pattern for chat.
Model selection & rationale
Section titled “Model selection & rationale”| Role / use | Model | OpenRouter (in/out per 1M) | Why |
|---|---|---|---|
scout (types, tests aspects) | DeepSeek V4-Flash | $0.09 / $0.18 | Cheapest; reliable structured output + tool use (TAU-bench 95); 3× faster than M3; reuses existing ZDR lane. Scout is recall-oriented with downstream verification. |
deepdive + strong scout (correctness, security) | GLM-5.2 | $0.95 / $3.00 | Best open agentic/structured reliability (Terminal-Bench 81, MCP-Atlas 77, FrontierSWE 74); ~5× cheaper than sonnet. |
| break-glass fallback | claude-sonnet-4-6 | $3.00 / $15.00 | Known-good; the config #17 was verified against. Documented flip, not built. |
MiniMax M3 was evaluated and rejected for the cheap tier: its ~6.7× output premium buys capability concentrated in domains scout does not exercise (browsing, long-horizon terminal agentics), while Flash matches or beats it on the scout-relevant axes and reuses the existing lane.
Architecture
Section titled “Architecture”1. agentgateway (argocd/app-configs/agentgateway/)
Section titled “1. agentgateway (argocd/app-configs/agentgateway/)”- New backend
openrouter-zdr-glminllm-policies.yaml(besideopenrouter-zdr, where ZDR backends live): pinsprovider.openai.model: z-ai/glm-5.2, aliasor-glm-5-2-zdr,overrides: {zdr: true, require_parameters: true}, authagentgateway-openrouter. - Reuse
openrouter-zdr(deepseek-v4-flash) for the cheap tier — no new backend. - Two header-free
HTTPRoutes inllm-routes.yaml(where routes live), directly mirroring the provenllm-embeddings-engramroute, which already serves a custom prefix/engram-embed/v1/embeddings→ a dedicated ZDR backend with no URL-rewrite filter (the backend’spathPrefix: /api/v1does the upstream rewrite; longest-prefix-matched away from the canonical route):llm-fovea-scout—/fovea-scout/v1/chat/completions→openrouter-zdr(Flash)llm-fovea-deepdive—/fovea-deepdive/v1/chat/completions→openrouter-zdr-glm(GLM-5.2) These prefixes do not overlap/v1/chat/completions, so there is no match ambiguity. Modify (not create) the existingllm-apikeyAgentgatewayPolicy inllm-policies.yamlto addllm-fovea-scoutandllm-fovea-deepdiveto itstargetRefs, so the vkey gate covers them.
- New virtual key
vk_fovea— see §3 (stored in both the agentgateway accept-list path and fovea’s own client path, per the karakeep/mealie pattern).
2. fovea config (argocd/cluster-app/templates/fovea.yaml valuesObject)
Section titled “2. fovea config (argocd/cluster-app/templates/fovea.yaml valuesObject)”config: gateway: # global default endpoint; per-role base_url overrides below pin the routes openai: { base_url: "https://llm-gw.fzymgc.house/fovea-scout/v1" } roles: scout: provider: openai model: or-deepseek-v4-flash-zdr # backend pins the model; string nominal base_url: "https://llm-gw.fzymgc.house/fovea-scout/v1" deepdive: provider: openai model: or-glm-5-2-zdr base_url: "https://llm-gw.fzymgc.house/fovea-deepdive/v1" scout: aspects: correctness: { model: deepdive } # → GLM-5.2 (strong tier) security: { model: deepdive } # → GLM-5.2 (strong tier) # types, tests inherit the scout role (Flash) — no override needed- The model strings are nominal: each ZDR backend pins its model, so the
alias collapses regardless. They are kept meaningful for readability and to
satisfy fovea’s “
modelis required” validation. - Add
openaiKey: { name: fovea-app, key: openai_api_key }→FOVEA_GATEWAY__OPENAI__API_KEY= thevk_foveavalue. RetainanthropicKey(present but unused while both roles areprovider: openai, so fovea requires no anthropic credential) — this keeps the break-glass flip to Anthropic-direct a one-line config change with no secret churn. - Chart grounding for
openaiKey: verified against the 0.4.3 chart —helm show valuesdeclaresopenaiKey(→FOVEA_GATEWAY__OPENAI__API_KEY, “optional; gateway may be keyless”), andhelm template oci://ghcr.io/seanb4t/charts/fovea --version 0.4.3renders it to asecretKeyRefenv (name: fovea-app, key: openai_api_key). The 2026-06-09 deploy spec’s three-key list (privateKeySecret/webhookSecret/anthropicKey) predates 0.4.0, which introduced thegateway/openaiconfig surface (#23). gateway.openai.base_urlis the global default endpoint; both roles set their ownbase_url, so the global is only a fallback (pointed at the scout lane).deepdiveoverrides it with its own lane. Intended semantics: default = scout lane; deepdive explicitly overrides.
3. Secrets / ESO
Section titled “3. Secrets / ESO”The vkey value is stored in two Vault locations, mirroring karakeep/mealie
(client own-path openai_api_key value = the vkey; matching accept-list entry on
the gateway path):
- Client credential — store the
vk_foveavalue asopenai_api_keyunder Vaultfzymgc-house/cluster/fovea. fovea’sExternalSecret(argocd/app-configs/fovea/external-secret.yaml) reads it intofovea-app.openai_api_key, surfaced via the chart’sopenaiKey→FOVEA_GATEWAY__OPENAI__API_KEY. - Accept-list — store the SAME value as property
vk_foveaunder Vaultfzymgc-house/cluster/agentgateway(where every vkey lives), and add avk_foveaentry to theagentgateway-vkeysExternalSecret (argocd/app-configs/agentgateway/secrets.yaml): atemplate.dataline —fovea: '{"key":"{{ .vk_fovea }}","metadata":{"group":"service"}}'— plus adataentry (secretKey: vk_fovea,remoteRef→fzymgc-house/cluster/agentgatewaypropertyvk_fovea). - The ESO reader policy already wildcards
secret/data/*— no Vault policy change.
4. Network
Section titled “4. Network”- Confirm/permit fovea ns →
llm-gwVIP (192.168.20.155) on 443, the same reachability every gateway client uses. NotoFQDNs(broken on this cluster,hl-bdr); the VIP is an in-cluster MetalLB address.
5. Fallback (documented, deferred)
Section titled “5. Fallback (documented, deferred)”Break-glass for a GLM-5.2 deepdive regression: flip gateway.roles.deepdive
back to provider: anthropic with the retained Anthropic key (one-line revert),
or later add an openrouter-zdr-sonnet backend + /fovea-deepdive repoint.
Not built now (YAGNI until verification fails).
Cost picture (order-of-magnitude)
Section titled “Cost picture (order-of-magnitude)”Per review, roughly: scout = 4 aspect calls (2 on GLM-5.2, 2 on Flash) + ≤5 deepdives on GLM-5.2. Versus the all-sonnet baseline this cuts the high-volume scout output cost ~80× (types/tests on Flash) and ~5× on the GLM-routed work, while the absolute per-review delta between Flash and M3 for the cheap aspects is sub-cent — confirming model choice is driven by fit, not headline price.
Rollout & verification
Section titled “Rollout & verification”- Ship all changes with
publish_enabled: false(shadow). - After sync: confirm fovea pod
1/1, ExternalSecrets synced, and a test PR drivesscoutfan-out across both tiers (Flash + GLM-5.2 visible in ClickStack telemetry) anddeepdivereaches theverifiedfunnel stage on GLM-5.2 with norequire_parameters/structured-output failures. - Only then the separate
hl-uh07.9go-live flip.
Acceptance: Application Synced/Healthy; pod 1/1; vk_fovea authenticates
(no 401 at the gateway); a real PR shows tiered scout models + a GLM-5.2 verified
verdict in telemetry; ZDR confirmed — agentgateway telemetry/access logs show
fovea’s requests routed to the openrouter-zdr / openrouter-zdr-glm backends
(which carry the zdr: true + require_parameters: true override), cross-checked
against the OpenRouter activity dashboard (ZDR endpoint, params honored).
- First ZDR chat client (R-1). The header-free path-prefix mechanism is
itself proven (
llm-embeddings-engramserves/engram-embed/v1/embeddings→ a dedicated ZDR backend, no rewrite filter), so fovea mirrors a working pattern; the residual unknown is chat (vs embeddings) over a ZDR lane, covered by the live-verify step. Fallback: the header-basedllm-chat-zdrroute exists. - Open-model verdict reliability (R-2). GLM-5.2 deepdive structured output is
unproven in-cluster;
require_parameters: truefails loud rather than silent, and the sonnet break-glass is one flip away. - Model/alias drift (R-3). OpenRouter model ids or pricing can change; the backend pins explicit ids and the design is revertible per-role.
- Reasoning-mode cost (R-4). fovea has no per-role thinking-mode knob; GLM-5.2 /
DeepSeek default reasoning effort applies. Watch deepdive
max_tokens(8000) andwall_clock(90s) under live load; tune in a follow-up if budgets are exceeded.
Change surfaces
Section titled “Change surfaces”| File | Change |
|---|---|
argocd/app-configs/agentgateway/llm-policies.yaml | new openrouter-zdr-glm backend; modify llm-apikey policy targetRefs to add the two fovea routes |
argocd/app-configs/agentgateway/llm-routes.yaml | two header-free fovea HTTPRoutes — llm-fovea-scout + llm-fovea-deepdive (mirror llm-embeddings-engram) |
argocd/app-configs/agentgateway/secrets.yaml | add vk_fovea to the agentgateway-vkeys ExternalSecret (template.data + data) |
argocd/cluster-app/templates/fovea.yaml | gateway roles + per-role base_url + aspect tiering; add openaiKey (retain anthropicKey unused) |
argocd/app-configs/fovea/external-secret.yaml | add openai_api_key (the vk_fovea value) |
| Vault | …/cluster/fovea#openai_api_key (client) and …/cluster/agentgateway#vk_fovea (accept-list) — same value |
docs/reference/services.md, secrets.md | document fovea→agentgateway routing + new secret |
Out of scope
Section titled “Out of scope”Go-live flip (hl-uh07.9); mesh egress (hl-z5fc); retrieval/embedding routing;
upstream fovea N-tier roles; the sonnet ZDR backend.