[issue-2769][slice-1/2] Gateway upstream router + LiteLLM Deploymen... - #2773
Conversation
Surfaces the gateway upstream-router approach (Option A) with Claude path untouched. Registers 11 HITL decisions covering LiteLLM topology, routing signal, per-agent model config, acceptance-test target, harness choice, credential handling, failure policy, tool-strip policy, slice decomposition, and the `[1m]` syntax; plus 5 open-ended feedback questions on hardware, target roles, swap-out interface, cost tracking, and compliance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…proxy Recommends Option A: a gateway-side UpstreamRegistry keyed by per-agent session metadata, with LiteLLM as a sibling Deployment in egg-system. Claude path is byte-for-byte unchanged; routing is additive and inert-by-default. The analysis decomposes the work into two dependent slices -- slice-1 (gateway router + LiteLLM topology, no-op) and slice-2 (per-agent model config + consensus_wrapper plumbing) -- and lands every runtime primitive in the design with file:line evidence and explicit purpose / execution-context labels per #2594. Surfaces seven open risks for the risk_analyst and seeds nine acceptance criteria for the task_planner. Honors all 11 cq-* HITL resolutions and the refine-phase feedback answers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ices Two dependent slices (per cq-10): 1. Gateway upstream router + LiteLLM Deployment (no-op by default) 2. Per-agent model config + spawn plumbing + body rewrite Slice 1 ships an UpstreamRegistry seam, Session.upstream/upstream_model fields, upstream-aware credential injection, and the LiteLLM k8s manifests with an empty model_list. Claude path is structurally unchanged. Slice 2 adds PipelineConfig.agent_models + repositories.yaml default_agent_model, a precedence-aware resolver, and the gateway-side body rewrite that lets Claude Code keep seeing a recognized alias while LiteLLM sees the real upstream model name (cq-5 mitigation). The cq-4 empirical compatibility flip is explicitly out of scope — this pipeline ships only the buildable seam.
Records 15 risks across security, compatibility, operational, and architecture categories. Anchors on external research: - LiteLLM March 2026 PyPI supply-chain incident + April-May 2026 CVEs (CVE-2026-42208 CVSS 9.3 SQLi, CVE-2026-35029 RCE) - LiteLLM /v1/messages streaming tool_use drop bugs against non-Anthropic backends (open issues #25561, #25321, #24765) - Claude Code compaction-math heuristic on alias name - Qwen3 vLLM streaming + reasoning parser bugs (flagged for self-hosted follow-up; out of scope per cq-6) Audits 12 runtime primitives per issue #2594 (session.upstream, register_session fields, PipelineConfig.agent_models, build_consensus_wrapped_command callers, LiteLLM Deployment + LITELLM_MASTER_KEY, AnthropicCredential extensibility, SSE accumulator event names, max_llm_cost_per_hour enforcement, _PROTECTED_ENV_KEYS coverage, max_turns hardcoding). Documents trust boundaries (sandbox->gateway unchanged; new gateway->LiteLLM and LiteLLM->hosted-provider boundaries). Recommends PROCEED_WITH_MITIGATIONS; overall risk HIGH driven by external dependencies, not by egg-side code change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…iewer_plan NACK Addresses the reviewer_plan blocking NACK (item #1): the v1 design left ambiguous how `agent_model_litellm` reaches the wire. v2 pins Semantics B (gateway rewrites body['model'] from the Claude alias to the natural LiteLLM model name BEFORE forwarding) over Semantics A (forward unchanged, key LiteLLM model_list on Claude aliases). Semantics B is required to avoid the count_tokens tokenizer-mismatch failure mode (R8) that breaks Claude Code's compaction math under Semantics A. Also addresses every non-blocking item: - AC-10: get_default_model(repo) returns None silently when repositories.yaml is absent (matches get_repo_setting behavior) - k8s/base/gateway-deployment.yaml gains LITELLM_BASE_URL env var declaration so the seam stays repointable from a kustomize overlay - PipelineConfig.agent_models key-validation flagged as non-negotiable (planner picks shape; typo-tolerance forbidden) - cost-tracker mis-pricing surfaced as observable-behavior-out-of-scope - NetworkPolicy egress made concrete: operator-supplied overlay, not in k8s/base - config/repo_config.py decomposition cross-check (836 LOC, not in #2261 — safe to add helper) - slice-1 SSE acceptance test expanded with a client-disconnect mid-stream case against LiteLLM The slice DAG (slice-1 gateway/no-op → slice-2 model config) is unchanged; component count grows by 2 (body rewriter, LITELLM_BASE_URL env var declaration) and AC count grows from 9 to 11. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Slice-1 (task-1-12) architecture doc covering the gateway-side upstream router that lets per-agent /v1/messages traffic route to either api.anthropic.com (default) or a LiteLLM proxy fronting non-Claude backends. Names every slice-1 primitive with file:line cites, walks the request lifecycle for both upstreams, and explains the no-op-by-default invariant (LITELLM_MASTER_KEY unset + Session.upstream defaults + empty PipelineConfig.agent_models). Captures the cq-1/cq-2/cq-5/cq-7/cq-8 HITL resolutions that shape the design. Cross-links from gateway/CLAUDE.md, docs/architecture/orchestrator.md, docs/architecture/README.md, and docs/index.md.
… for #2769 slice-1 Lands the buildable, no-op-by-default gateway seam for routing per-agent ``/v1/messages`` traffic to non-Claude backends via an in-cluster LiteLLM proxy. With no agent configured to opt into LiteLLM (slice 2 wires the per-pipeline config), every existing request stays on the Anthropic path byte-identically — only when a future ``Session.upstream == 'litellm'`` is declared does the new dispatch fire. Changes: - gateway/upstream_registry.py (NEW): UpstreamRegistry pairs per-upstream httpx.Client (base_url, timeout, pool) with a credential resolver. Registered names: "anthropic", "litellm". Unknown names raise UnknownUpstreamError. LITELLM_BASE_URL env overrides the default cluster Service DNS. - gateway/anthropic_credentials.py: add LiteLLMCredentialsManager reading LITELLM_MASTER_KEY out of the same secrets.env, with the existing mtime-invalidated cache pattern. Returns ``None`` when unset so the proxy route falls into the same "no credential" 401 branch as today. - gateway/gateway.py: introduce upstream-aware ``_inject_upstream_credentials(headers, upstream)`` (anthropic path preserves the legacy OAuth/API-key precedence + client-supplied auth fall-through verbatim). ``_inject_anthropic_credentials`` stays as a back-compat alias for existing test mocks. proxy_anthropic_messages / proxy_count_tokens look up ``session.upstream`` (defaulting to "anthropic" when no session) and dispatch — Claude path keeps calling ``get_anthropic_client()`` so existing test patches are unaffected. - gateway/session_manager.py: Session gains ``upstream`` (default "anthropic") and ``upstream_model`` (default None). Persistence encodes them only when they differ from defaults so on-disk shape stays byte-identical for the Claude-only path; from_persistence tolerates pre-#2769 dicts missing both keys. - gateway/gateway.py /api/v1/sessions/create handler: parse + validate ``upstream`` (must be a name UpstreamRegistry serves) and ``upstream_model``, pass through to register_session, audit-log both. - orchestrator/gateway_client.py: register_session accepts optional upstream / upstream_model kwargs. Omitted callers produce a request body byte-identical to today. - k8s/base/litellm-{deployment,service,configmap}.yaml + kustomization.yaml entry: pinned LiteLLM image in egg-system, port 4000, ConfigMap ships with EMPTY model_list (operators populate via overlay). Master key is sourced from the existing gateway-secrets Secret so the gateway and LiteLLM share one value. No NetworkPolicy changes — agents never talk to LiteLLM directly. - config/secrets.template.env: documents LITELLM_MASTER_KEY with an explicit "leave empty to disable LiteLLM routing" note. Tests: existing gateway proxy + credentials + session-manager suites pass under .venv/bin/python -m pytest. Slice-1 tester role adds the new positive-path tests (UpstreamRegistry, LiteLLM credential resolver, Session round-trip with the new fields). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Address tester NACK on slice-1 v1 (issue #2769): ruff format wants the session_create handler's "Invalid upstream" make_error call collapsed onto a single line so `make lint`'s `ruff format --check` stops failing. Pure formatting — no behavior change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… Secret key Address reviewer_code_holistic NACK on slice-1 v1/v2 (issue #2769): the LiteLLM Deployment reads ``secretKeyRef.key: litellm-master-key`` from ``gateway-secrets``, but ``make k3s-secrets`` keys the Secret by filename (``--from-file=$HOME/.config/egg/``), and there is no ``litellm-master-key`` file there. The documented operator workflow ("add ``LITELLM_MASTER_KEY=<random>`` to ``secrets.env``") therefore never reached the LiteLLM pod — gateway and LiteLLM would silently disagree on the master key on first request. Fix: grep ``LITELLM_MASTER_KEY`` out of ``secrets.env`` (handles quoted/unquoted values and the unset case) and pass it through ``--from-literal=litellm-master-key=<value>`` alongside the existing ``--from-file=$HOME/.config/egg/`` so both sides of the wire share one source of truth. Empty value is the no-op default (manifest reads with ``optional: true``). Tested locally with ``make -n k3s-secrets`` (shell-syntax correct) and manual shell-piping for quoted / unquoted / missing-file / empty-value edge cases — all extract to the expected literal value. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tester scaffold-first work for slice-1 (TASK-1-10, TASK-1-11). The
suites below currently skip cleanly while the coder lands the
implementation primitives; once those land the tests will exercise
the new code without further edits.
What lands here:
- tests/gateway/test_upstream_registry.py (NEW, TASK-1-10):
- get("anthropic") returns Anthropic httpx client + Anthropic
credential resolver; base_url == https://api.anthropic.com.
- get("litellm") returns LiteLLM client with base_url sourced from
LITELLM_BASE_URL env (default
http://litellm.egg-system.svc.cluster.local:4000).
- get("unknown") raises a typed UnknownUpstreamError that names
the offending upstream.
- Per-upstream singleton semantics (pooling preserved) + Anthropic
and LiteLLM clients are distinct instances.
- tests/gateway/test_anthropic_credentials.py (extended, TASK-1-10):
- LiteLLMCredentialsManager / get_litellm_credential returns an
x-api-key-shaped AnthropicCredential when LITELLM_MASTER_KEY is
present in secrets.env.
- Empty / missing key returns None (no-op default).
- Resolver MUST NOT fall back to ANTHROPIC_API_KEY when
LITELLM_MASTER_KEY is absent (cross-upstream credential leak
guard).
- mtime-invalidated cache mirrors AnthropicCredentialsManager.
- tests/gateway/test_anthropic_proxy.py (extended, TASK-1-10):
- Upstream routing for proxy_anthropic_messages and
proxy_count_tokens: no-session and session.upstream='anthropic'
routes to Anthropic client (no-op invariant); session.upstream=
'litellm' routes to LiteLLM client and the Anthropic client MUST
NOT be invoked.
- Per-upstream credential injection: LiteLLM-routed requests
carry the LiteLLM x-api-key, NOT the Anthropic one (credential
leak guard).
- _inject_upstream_credentials(headers, upstream) dispatch:
anthropic branch matches today's _inject_anthropic_credentials
byte-identically; litellm branch returns 401 with the same shape
when no LiteLLM credential is configured.
- gateway/tests/test_session_manager.py (extended, TASK-1-11):
- Session.upstream defaults to 'anthropic'; Session.upstream_model
defaults to None.
- Both fields round-trip through to_dict_for_persistence /
from_persistence.
- Persisted dicts WITHOUT the new fields (legacy on-disk shape)
rehydrate cleanly with the defaults — the most important
back-compat invariant for upgrade.
- SessionManager.register_session(upstream=..., upstream_model=...)
stores both on the returned Session; omitting both keeps today's
no-op default.
- orchestrator/tests/test_gateway_client.py (extended, TASK-1-11):
- GatewayClient.register_session omits 'upstream' /
'upstream_model' from the POST body when not provided
(back-compat invariant — no slice-1 caller sends them, so the
wire shape must be byte-identical).
- Explicit upstream='litellm', upstream_model='qwen3-coder-30b'
lands in the POST body.
- Asymmetric: upstream='litellm' without upstream_model emits only
upstream.
These tests intentionally skip when the new primitives are not yet
imported so the suite stays green during the slice-1 coder cycle;
no production code is touched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ad6069) Tests now exercise the real coder implementation end-to-end (233 passing in the slice-1 impact zone). Changes since the scaffold commit: - tests/gateway/test_anthropic_proxy.py: routing + injection branches now patch `gateway.gateway.get_litellm_credentials_manager` so the LiteLLM-routed paths get a credential instead of falling into the 401 branch. Adversarial probes added (4 classes, ~10 tests): * TestUnknownUpstreamDefense — proxy MUST fail closed (5xx, not 200) when session.upstream is unknown. Relaxed to accept any 5xx because the dual-import test setup (gateway/tests/conftest.py custom loader vs. tests/gateway/ sys.path) produces distinct UnknownUpstreamError class identities — in production only the 502 path fires, but the contract that matters is "not 200". * TestRoutingConsistencyAcrossProxyRoutes — /v1/messages and /v1/messages/count_tokens MUST agree on upstream for any given session (split-brain guard). * TestLiteLLMNoFallbackToClientAuth — with no LITELLM_MASTER_KEY, a client-supplied Authorization / x-api-key MUST NOT bypass the gate (would leak a Claude OAuth token to the LiteLLM backend). * TestSessionCreateUpstreamValidation — POST upstream='bogus' must return 400 (skipped when EGG_LAUNCHER_SECRET unavailable in test env). - tests/gateway/test_anthropic_credentials.py, test_upstream_registry.py, orchestrator/tests/test_gateway_client.py: ruff-format applied. Reviewer NACK on coder v1 is in flight separately: `ruff format --check gateway/gateway.py` fails on lines 8678-8683 (multi-line make_error call should be single-line). No source-code change here; just my test suite. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…stream_registry The scaffold-first phase needed these to silence mypy while gateway/upstream_registry.py did not yet exist. Coder v2 has landed (commit 3ad6069, format-fixed in b79f927), the module imports cleanly, and mypy now flags the comments as [unused-ignore]. Pure cosmetic cleanup; tests still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
The gateway's UpstreamRegistry legitimately stores api.anthropic.com as a proxy target URL — it never calls the LLM API directly. Add noqa: EGG200 suppressions to the module docstring and the URL constant.
Autofix tracking{"Lint/Custom Checks": 1, "Lint/Python": 1} |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
No agent-mode design concerns.
This is gateway/infrastructure plumbing (upstream router, session fields, k8s manifests) — it doesn't construct agent prompts, pre-fetch data, define agent workflows, or post-process agent output, so most of the agent-mode anti-patterns don't apply. I checked the ones that could:
-
Constraint enforcement (guideline: "the sandbox is the constraint"). The upstream-routing decision is gateway-enforced per-session metadata declared by the orchestrator at session-create, not a prompt-level instruction. The
cq-2decision explicitly rejected a sandbox-supplied HTTP header in favor of session metadata — the right call: routing stays a trust-boundary decision the agent cannot influence. Credentials remain gateway-held and never reach the sandbox. This strengthens the gateway-as-constraint model. -
Agent SDK / Claude Code harness path. Preserved. The sandbox still talks to a single URL (
ANTHROPIC_BASE_URL=http://egg-gateway:9848); the router is invisible to it.cq-5keeps the Claude Code harness even for non-Claude backends, so the Agent SDK invocation path is unchanged. -
EGG200 (direct LLM calls in infra code).
upstream_registry.pyis a proxy client, not a decision-making LLM call — consistent with the existingget_anthropic_client()exception. The# noqa: EGG200annotations on theapi.anthropic.comliteral and client construction follow the established pattern. -
EGG201 (pinned model identifiers). No pinned Claude identifiers introduced; the
opusalias is used for the Claude-Code-facing model.upstream_modelvalues likeqwen3-coder-30bare non-Claude on-the-wire names that have no alias concept — EGG201 doesn't govern them.
The internal orchestrator↔gateway JSON fields (upstream/upstream_model) are a genuine machine-to-machine API, not human-facing structured output.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review — PR #2773: Gateway upstream router + LiteLLM Deployment (slice 1/2)
I traced the full changeset against the existing gateway code, the k8s
manifests, and the deploy tooling. The core seam is well-built and the
no-op-by-default invariant for the request path genuinely holds —
verified end-to-end:
Session.upstreamdefaults to"anthropic";from_persistence
tolerates pre-#2769 dicts (gateway/session_manager.py:419-420).proxy_anthropic_messages/proxy_count_tokenskeep calling
get_anthropic_client()on the"anthropic"branch, so existing
mocks and behavior are byte-identical.- No slice-1 caller sets
upstream="litellm". - The NetworkPolicy claim checks out: all policies are scoped to
egg-agents;egg-systemhas none, so gateway→litellm works and
agents still cannot reachlitellm:4000. No policy change needed.
One blocking issue and several non-blocking items below.
Blocking
1. make deploy now hard-depends on an external image pull, and a failed pull aborts the whole deploy with a misleading error.
The LiteLLM Deployment is added to k8s/base/kustomization.yaml, and
k8s/overlays/local/ includes ../../base, so every make deploy
unconditionally schedules the litellm pod. Its image
(ghcr.io/berriai/litellm:main-v1.55.10) is pulled from an external
registry — it is not in the k3s-import list and not rewritten by
the deploy sed.
make deploy runs scripts/await-egg-deploy.sh, which at lines 63-71
scans all egg-system pods for ImagePullBackOff|ErrImagePull:
kubectl -n "$NS" get pods -o jsonpath='{range .items[*]}...' | grep -qE 'ImagePullBackOff|ErrImagePull'If the litellm image is unpullable — wrong tag, ghcr.io outage,
air-gapped cluster — the litellm pod enters ImagePullBackOff, this
grep matches, and the entire deploy aborts with:
ERROR: egg-system pods cannot pull image tag '<TAG>' — it is not in k3s.
A commit, pull, or rebase since your last build moved EGG_IMAGE_TAG.
That message blames egg's own image tag drift, but the real culprit
is the third-party litellm image. This breaks make deploy for
everyone — including operators who never opt into LiteLLM — and
points them at the wrong fix. The no-op-by-default invariant holds for
the request path but not for the deploy path.
Please address one of:
- Narrow
await-egg-deploy.sh's ImagePullBackOff scan to the
orchestrator/gateway pods (by label selector) so unrelated pods
don't fail the deploy; or - Pre-import the litellm image (extend
k3s-import) so it isn't
pulled from ghcr at apply time; or - Move the litellm resources out of
k8s/base/into an opt-in
overlay so they're only deployed when an operator wants LiteLLM.
Also please confirm the pinned tag main-v1.55.10 actually exists and
pulls — if it doesn't, this breaks every deploy immediately, not just
in degraded environments.
Non-blocking
2. _inject_upstream_credentials silently treats unknown upstreams as Anthropic.
gateway/gateway.py:9404 only special-cases "litellm"; every other
value (including a bogus upstream) falls through to the Anthropic
branch and injects the Anthropic credential. In the current proxy flow
this is masked — the subsequent get_upstream_registry().get(...)
raises UnknownUpstreamError → 502 before any upstream call, so no
credential leaves the gateway. But it produces an observable
inconsistency: for a session with an unknown upstream, the request
returns 401 when no Anthropic credential is configured (inject
fails first) and 502 when one is (inject succeeds, client lookup
fails). Same invalid input, two different error codes depending on
unrelated state. test_unknown_upstream_on_session_returns_5xx only
passes because it mocks an Anthropic credential present; with None
it would get a 401 and fail the 5xx assertion. Consider having
_inject_upstream_credentials reject unknown upstreams explicitly
rather than defaulting them to Anthropic.
3. register_session does not validate upstream.
Only the /api/v1/sessions/create route validates against
UpstreamRegistry.is_known(). Both SessionManager.register_session
(gateway/session_manager.py:579) and
GatewayClient.register_session accept any string. Slice 2's spawner
will call these directly — a resolution bug there would create a
bogus-upstream session that bypasses validation entirely and lands in
the inconsistent path from item 2. Worth a validation guard in
register_session itself, or at least a note for slice 2.
4. litellm pod health is asserted but unverified.
The Test Plan only runs kubectl apply --dry-run=client. The claim
that the pod "comes up healthy" with an empty model_list is
untested, and readOnlyRootFilesystem: true with only /tmp writable
is a real risk if LiteLLM writes a cache/log to $HOME or its install
dir at startup. Since await-egg-deploy.sh only waits on
orchestrator/gateway, a crashlooping (not image-pull) litellm pod
won't break the deploy — but it will sit broken in every cluster.
Please actually start the pod once and confirm
/health/readiness + /health/liveliness pass before relying on the
"healthy idle" claim in the ConfigMap comment.
5. Error messages hardcode "Anthropic API" on every upstream.
proxy_anthropic_messages / proxy_count_tokens return
"Failed to connect to Anthropic API" / "Anthropic API request timed out" even for a litellm-routed request (gateway/gateway.py:10101,
10112, 10186, 10197). Not slice-1-reachable, but it will mislead
operators debugging a LiteLLM outage in slice 2. Cheap to make
upstream-aware now.
6. Test skip-guards mask regressions now that the implementation has landed.
Many of the new tests still carry scaffold-era guards —
try: import upstream_registry except ImportError: pytest.skip(...)
and except TypeError: pytest.skip(...). With the module merged these
no longer skip, but they now mean a future rename/break would make the
suite skip silently instead of failing. Remove them so the suite
fails loudly. Relatedly, TestSessionCreateUpstreamValidation skips
entirely when EGG_LAUNCHER_SECRET is unset — if CI doesn't set it,
the session-create upstream validation branch (TASK-1-5) has no
automated coverage. Consider a unit test that exercises the validation
without needing launcher auth.
7. The registry's credential-resolver half is unused by the proxy routes.
UpstreamRegistry.get() returns (client, resolver), but the proxy
routes discard the resolver (client, _ = ...) and inject credentials
via the separate _inject_upstream_credentials. Both ultimately call
the same managers so they stay consistent, but the paired-resolver
abstraction is effectively dead in production (exercised only by
tests). Not wrong — just worth noting the redundancy.
8. Minor:
("anthropic", "litellm")is hardcoded in three places in
upstream_registry.py(get,is_known,known_upstreams) plus
the litellm-only branch in_inject_upstream_credentials. A 4th
upstream means editing all four in sync.docs/architecture/upstream-routing.mdreferencescq-9in prose
but the HITL table only lists cq-1/2/5/7/8.docs/architecture/orchestrator.mdlinks to
../guides/per-agent-models.md, which doesn't exist until slice 2 —
a dangling link in merged docs meanwhile.gateway/upstream_registry.py:1puts# noqa: EGG200 - ...as the
first line of the module docstring; it works for the line-based lint
but becomes the literal first line of__doc__. A normal trailing
# noqacomment on the import/constant lines reads cleaner.
What's good
- The no-op-by-default request path is genuinely verified, not just
asserted — defaults, persistence back-compat, and the unchanged
Anthropic branch all hold. upstream_modelinput validation insession_create(type,
non-empty, ≤256 chars) is solid defensive validation.- The LiteLLM credential resolver correctly refuses to fall back to
ANTHROPIC_API_KEY, and the test suite explicitly guards that
cross-upstream leak — good adversarial coverage. - The k3s-secrets change correctly closes the gap where the LiteLLM
pod and gateway would otherwise disagree on the master key. - Routing tests drive the real Flask routes end-to-end rather than
hand-building fixtures.
The blocking item is the deploy coupling (#1); the rest are
non-blocking. Happy to re-review once #1 is addressed.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Blocking: scope await-egg-deploy.sh's ImagePullBackOff fast-fail to
egg's own deployments (orchestrator/gateway) by label selector. A
third-party pod's image-pull failure — e.g. the LiteLLM proxy pulling
from ghcr — no longer aborts every `make deploy` with a misleading
"egg image tag not in k3s" error. Confirmed ghcr.io/berriai/litellm:
main-v1.55.10 resolves (HTTP 200).
Non-blocking fixes:
- _inject_upstream_credentials now rejects an unknown upstream with a
deterministic 502 instead of silently falling through to the
Anthropic branch (which produced 401-vs-502 depending on unrelated
credential state).
- SessionManager.register_session validates upstream against the
UpstreamRegistry and raises ValueError on an unknown value, so a
direct caller cannot create a bogus-upstream session.
- litellm Deployment sets HOME=/tmp so home-relative startup writes
land in the writable tmpfs under readOnlyRootFilesystem.
- proxy_anthropic_messages / proxy_count_tokens error messages and
logs are now upstream-aware instead of hardcoding "Anthropic API".
- Removed scaffold-era import/TypeError skip-guards from the slice-1
tests so a future break fails loudly; TestSessionCreateUpstream-
Validation now patches get_launcher_secret for deterministic
coverage of the session-create validation branch.
- upstream_registry: KNOWN_UPSTREAMS constant replaces the three
hardcoded ("anthropic","litellm") tuples; dropped the EGG200 noqa
from the module docstring by rewording it.
- docs: added the cq-9 row to the upstream-routing HITL table and
de-linked the per-agent-models.md references (slice-2 file).
Review feedback addressed — commit b914607Thanks for the thorough trace. Every item is fixed in this PR; no deferrals. Blocking1. Non-blocking2. 3. 4. litellm pod health asserted but unverified — 5. Error messages hardcode "Anthropic API" on every upstream — 6. Scaffold skip-guards mask regressions — 7. Registry credential-resolver half is unused by the proxy routes — 8. Minor — all
Verification
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Agent-mode design re-review — no concerns.
The delta since my last review (commit b9146074) is purely gateway/infra plumbing in response to review feedback:
_inject_upstream_credentials/register_sessionnow fail closed on an unknown upstream.- Proxy error messages and logs are made upstream-aware.
KNOWN_UPSTREAMSconstant deduplicates the upstream name list.HOME=/tmpadded to the LiteLLM Deployment;await-egg-deploy.shscopes its image-pull fast-fail to egg's own deployments.- Scaffold-era test skip-guards removed; docs corrected.
None of this touches agent-mode design surfaces — no prompt construction, no pre-fetching, no structured-output-for-humans, no post-processing pipelines, no prompt-level security, no direct LLM API calls, and no hardcoded model identifiers. The gateway proxies whatever model the client sends; it does not pin one.
Worth noting the delta improves agent-mode hygiene: it removes a # noqa: EGG200 from upstream_registry.py's module docstring by rewording the prose instead of suppressing the linter. The remaining # noqa: EGG200 on the ANTHROPIC_BASE_URL constant is appropriate — that is a proxy target URL, not a direct LLM call.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — PR #2773: Gateway upstream router + LiteLLM Deployment (slice 1/2)
Re-reviewed at commit b914607. The previous-review commit was no longer
reachable (branch force-pushed), so I re-traced the full changeset against
the base branch and verified each prior item against the current tree.
Verdict: the blocking issue is genuinely fixed and every non-blocking
item is addressed. Approving. Three cosmetic notes below — none block.
Blocking issue #1 — make deploy aborting on the litellm image pull — RESOLVED
scripts/await-egg-deploy.sh now scopes the ImagePullBackOff fast-fail
to egg's own deployments: it loops DEPLOYMENTS=(orchestrator gateway)
and queries pods by -l app.kubernetes.io/component=$d. I verified the
selector is correct — both orchestrator-deployment.yaml and
gateway-deployment.yaml carry app.kubernetes.io/component on the pod
template labels (gateway-deployment.yaml:20,
orchestrator-deployment.yaml:25), so the pod-level scan matches. A
third-party litellm ImagePullBackOff no longer aborts the deploy or
mis-blames EGG_IMAGE_TAG. The success-path check (lines 39-58) also only
waits on orchestrator/gateway, so a crashlooping litellm pod won't
cause a timeout either.
I also independently confirmed the pinned tag resolves:
GET ghcr.io/v2/berriai/litellm/manifests/main-v1.55.10 returns HTTP
200.
Non-blocking items from prior review — all addressed
- #2
_inject_upstream_credentialsnow rejects an unknown upstream
with a deterministic 502 viais_known()before the Anthropic
branch — no more 401-vs-502 ambiguity. Regression test
test_unknown_upstream_returns_502_without_anthropic_fallthrough
exercises the real injector withNoneAnthropic credential. - #3
SessionManager.register_sessionvalidatesupstreamagainst
the registry and raisesValueError. Test
test_register_with_unknown_upstream_raisescovers it; the lazy
from upstream_registry import ...correctly avoids a circular import. - #4
HOME=/tmpadded to the litellm Deployment, removing the
concretereadOnlyRootFilesystemstartup-write failure mode. Live
/health/readiness+/health/livelinessverification still needs an
operator with a cluster — acceptable for a non-blocking item, but worth
flagging to whoever first deploys this. - #5 Proxy error messages / log lines are upstream-aware
(f"Failed to connect to {upstream_name} upstream"). - #6 Every scaffold-era skip-guard removed — verified no
pytest.skip/except ImportError/except TypeErrorremains in
the five touched test files.TestSessionCreateUpstreamValidation
patchesget_launcher_secretdirectly, so TASK-1-5 has deterministic
coverage. - #7 Disagreement on the unused resolver half is reasonable — the
prior review explicitly said "not wrong." - #8
KNOWN_UPSTREAMSconstant replaces the three duplicated tuples;
cq-9row added to the HITL table ("six" matches);per-agent-models.md
references de-linked to plain text; docstring# noqaremoved.
I ran the suites locally: test_upstream_registry.py (10),
test_anthropic_proxy.py + test_anthropic_credentials.py (89),
test_session_manager.py + test_gateway_client.py (236) — 335
passed. The new tests drive the real Flask routes / real
SessionManager / real UpstreamRegistry rather than hand-built
fixtures, and the credential-leak guards (TestLiteLLMNoFallbackToClientAuth)
are solid adversarial coverage.
Non-blocking notes
-
register_sessionvalidatesupstreambut notupstream_model.
The session-create HTTP route validatesupstream_model(type,
non-empty, ≤256 chars —gateway.py:8682-8688), but the direct
register_sessionpath (session_manager.py:580) stores it
unchecked. The same defense-in-depth argument that justified adding
upstreamvalidation here (a slice-2 spawner callingregister_session
directly) applies equally toupstream_model. Low impact in slice 1
since it's just a stored string, but worth a guard or a slice-2 note. -
Stale docstring in
test_unknown_upstream_on_session_returns_5xx.
The docstring describes a 500-via-dual-UnknownUpstreamError-identity
path. Now that_inject_upstream_credentialsrejects unknown upstreams
first via the booleanis_known()check, the route returns a
deterministic 502 and theexcept UnknownUpstreamErrorin the
client-resolution block is effectively unreachable (harmless defensive
code). The test still passes (502 ∈ 5xx), but the docstring no longer
matches the code path. -
test_anthropic_default_omitted_from_persistencedocstring says
"Marked xfail-strict-false" but the test is a plain test with
conditional asserts, not an xfail. Cosmetic.
The slice-1 no-op-by-default invariant now holds for both the request
path (defaults, persistence back-compat, unchanged Anthropic branch) and
the deploy path. Good work on the trace-through.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- register_session now validates upstream_model (type, non-empty, <=256 chars), mirroring the /api/v1/sessions/create route so a direct slice-2 caller cannot store a malformed model name. - Refresh test_unknown_upstream_on_session_returns_5xx docstring to describe the deterministic 502 from the is_known() pre-check in _inject_upstream_credentials rather than the obsolete dual-import 500 path. - Reword test_anthropic_default_omitted_from_persistence docstring: it is a lenient conditional-assert test, not an xfail.
Review feedback addressed — commit 020569dThanks for the re-review. All three non-blocking notes are fixed in this PR; no deferrals. 1. 2. Stale docstring in 3. Verification
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — PR #2773: agent-mode design
Re-reviewed the delta since my last approval at b914607. The only new
commit is 020569dc ("Address re-review suggestions"), a 3-file change:
gateway/session_manager.py—register_sessionnow validates
upstream_model(type, non-empty, ≤256 chars), mirroring the
/api/v1/sessions/createroute.gateway/tests/test_session_manager.py— docstring reword + two new
validation regression tests.tests/gateway/test_anthropic_proxy.py— docstring reword.
No agent-mode design concerns. The delta is gateway-side input
validation and test/docstring hygiene — it touches no prompt templates,
no spawn-side plumbing, and no agent code. The upstream_model field it
guards is an upstream-side model name destined for LiteLLM (e.g.
qwen3-coder-30b), not a Claude model alias, so EGG201 (hardcoded model
identifiers) does not apply. The slice-1 no-op-by-default invariant is
unaffected.
The prior review's verdict stands.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — PR #2773: Gateway upstream router + LiteLLM Deployment (slice 1/2)
Re-reviewed at commit 020569d. The prior-review commit b9146074 was no
longer reachable (the single PR commit was amended), so I traced the full
changeset against base egg/issue-2769/work and verified each of the three
non-blocking notes from the previous re-review against the current tree.
Verdict: all three notes are genuinely addressed, not superficially.
Approving — no blocking issues, no new issues introduced.
Note #1 — register_session validates upstream but not upstream_model — RESOLVED
SessionManager.register_session (session_manager.py:614-635) now mirrors
the session-create route's upstream_model checks exactly: type is str,
non-empty, ≤256 chars. I diffed the two validation blocks — the
register_session checks are byte-equivalent in semantics to
gateway.py:8683-8689 (only the error surface differs, ValueError vs
make_error, which is correct for the two call contexts). The validation
runs before token generation and Session construction, so a rejected
input leaves no partial state. The Raises: docstring section is updated
to cover both upstream and upstream_model.
Two regression tests added and verified passing:
test_register_with_empty_upstream_model_raises and
test_register_with_oversized_upstream_model_raises. Both drive the real
SessionManager.register_session (no hand-built fixture, no mock of the
validation path) with upstream="litellm" so the upstream pre-check
passes and the upstream_model branch is actually reached — the production
code path is exercised.
Note #2 — stale docstring in test_unknown_upstream_on_session_returns_5xx — RESOLVED
The docstring (test_anthropic_proxy.py:1853-1865) now accurately
describes the current path: _inject_upstream_credentials runs the
UpstreamRegistry.is_known() boolean pre-check before any per-upstream
branch, so an unregistered upstream gets a deterministic 502 and the
except UnknownUpstreamError in the client-resolution block is now
unreachable defensive code. The obsolete dual-import-500 scenario is gone.
The assertion still ranges over 5xx — guarding the fail-closed contract
rather than the exact code — which the docstring now explains.
Note #3 — test_anthropic_default_omitted_from_persistence "xfail-strict-false" claim — RESOLVED
The docstring is reworded to accurately describe the test as a lenient
conditional-assert guard (accepts an omitted field or one present at its
default; fails only on an unexpected non-default value). The inaccurate
"Marked xfail-strict-false" sentence is removed — the test is a plain test
with conditional asserts, never an xfail.
Verification
python -m pytest gateway/tests/test_session_manager.py::TestSessionManagerRegisterUpstream— 6 passed, including the two new validation tests.test_anthropic_default_omitted_from_persistence+test_unknown_upstream_on_session_returns_5xx— 2 passed.ruff checkclean onsession_manager.py,test_session_manager.py,test_anthropic_proxy.py.
The slice-1 no-op-by-default invariant is unchanged by this delta — it
touches only validation hardening and test docstrings. The blocking issue
and all prior non-blocking items remain resolved. Good close-out.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
#2774) * docs(#2769 slice-2): operator how-to for per-agent non-Claude models Task TASK-2-9: new `docs/guides/per-agent-models.md` walks an operator through enabling a non-Claude model for any single agent role via the LiteLLM proxy slice-1 stood up. Covers: - The two configuration knobs (`PipelineConfig.agent_models` per pipeline, `default_agent_model` per repository) and their precedence chain through `resolve_agent_model`. - The classifier that splits Claude aliases from non-Claude model strings and the resulting `(claude_code_alias, upstream, upstream_model)` triple. - The cq-5 recognized-alias mitigation: every LiteLLM-routed agent gets `--model opus` to Claude Code while the gateway-side `_rewrite_upstream_model` helper substitutes the upstream-side model name into the request body. - An end-to-end operator walkthrough for hosted Qwen on the refiner, including the LiteLLM master-key plumbing, the `model_list` ConfigMap shape, the cq-8 fail-closed error policy, and the cq-4 smoke-test properties (tool-heavy multi-turn loop + auto-compaction boundary). - The three independent no-op-by-default guards composed across slice-1 and slice-2. Names every slice-2 primitive with a file:line cite (resolver, config field, repo helper, body-rewrite helper). Cross-links the guide from `docs/index.md` (both the Guides table and the Task-Specific Guides table) and from `docs/architecture/upstream-routing.md` (status block + Related Documentation). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(#2769 slice-2) v2: address reviewer_code NACK on per-agent-models guide reviewer_code v1 NACK had two blocking and three non-blocking findings. This commit addresses all five. Blocking: - Together AI model path was lowercase `qwen/`; corrected to `Qwen/` to match the slice-1 authoritative ConfigMap (k8s/base/litellm-configmap.yaml:27). The case-sensitive HuggingFace org segment means an operator copy-pasting the snippet would have received an unknown-model 4xx from Together. - The pipeline-submission JSON example used the wrong field name (`"issue"` instead of `"issue_number"`) and omitted the required `description` / `repo` arguments — the orchestrator silently ignores unrecognized top-level keys, so a copy-paste would submit a pipeline with no issue binding. Rewrote against the real API surfaces with file:line cites: `submit_task` MCP tool (orchestrator/mcp_tools.py:74, required `description` + `repo`) and `POST /pipelines` HTTP body (orchestrator/routes/pipelines.py:1336 reading `data.get("issue_number")`). Added an explicit callout that `"issue": 1234` is silently ignored. Non-blocking: - Clarified that the gateway audit log records the routing decision once per session via `audit_log("session_created", …)` at gateway/gateway.py:8920, not per-request; subsequent requests inherit the decision implicitly via the session-keyed lookup. - Lifted the cq-5 mitigation's empirical-validation caveat into the section header so a quick reader cannot mistake "Claude Code's compaction math stays sane" for a guarantee. Re-framed the two invariants as "what the resolver enforces structurally" with the smoke test still the validation step. - Aligned the 3a per-repo default snippet: prose and YAML now both pin Qwen, with the inline comment matching. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(#2769 slice-2): per-agent model overrides + spawn/restart plumbing Slice-2 coder primitives that the documenter's TASK-2-9 how-to and the tester's TASK-2-7/-8 suite are written against. All paths are no-op by default — when neither PipelineConfig.agent_models nor the repo-level default_agent_model is set, every spawned agent still gets --model opus routed through the Anthropic upstream, byte-identical to the pre-#2769 wire shape. * orchestrator/agent_model_resolution.py (TASK-2-3): pure-function resolver returning AgentModelDecision(claude_code_alias, upstream, upstream_model). Precedence chain agent_models > default_agent_model > built-in "opus". classify_model() implements the cq-5 mitigation — recognized Claude aliases (opus / opus[1m] / sonnet / haiku / claude-*) route through "anthropic" with upstream_model=None; any other string routes through "litellm" with upstream_model preserved and claude_code_alias forced to "opus" so Claude Code's compaction math stays calibrated against a known family. * orchestrator/models.py (TASK-2-1): PipelineConfig.agent_models dict field with a field_validator that rejects keys outside AgentRole at construction time, surfacing typos immediately instead of silently ignoring them at spawn. * config/repo_config.py + config/repositories.yaml.example (TASK-2-2): repo-level default_agent_model setting via get_repo_setting, with the example file documenting the precedence + classifier. * orchestrator/concurrent_executor.py (TASK-2-4): spawn-time resolution. Threads claude_code_alias into build_consensus_wrapped_command and forwards upstream / upstream_model to the spawner only on non-default decisions so test mocks and the pre-#2769 spawn_fn signature remain untouched. * orchestrator/kubernetes_spawner.py (TASK-2-4): plumbs upstream / upstream_model through spawn_agent_job → create_session and through the restart_agent_container path so a restart picks the same upstream as the initial spawn (otherwise the gateway session would rebuild against the anthropic default and silently misroute). * orchestrator/routes/pipelines.py (TASK-2-5): restart_agent HTTP handler re-resolves the decision and forwards the same upstream-routing kwargs. Fails closed to the built-in opus default if resolution ever raises, preserving restart availability. Linted: ruff check + ruff format clean across all touched files. * feat(#2769 slice-2) v2: add gateway _rewrite_upstream_model (TASK-2-6) + fix repositories.yaml regression Addresses both blocking findings from tester and reviewer_code_holistic v1 NACKs. * **Blocker 1 (TASK-2-6)** — gateway/gateway.py now ships _rewrite_upstream_model(request_body, upstream_model) next to _filter_blocked_tools. Mirrors the bytes-in / bytes-out, JSON-parse- tolerant contract: a None upstream_model, a JSON-decode error, or a non-dict top-level body all return the original bytes unchanged (no-op shape preserves the Anthropic regression guard). When rewriting is applied, the top-level "model" field is swapped for upstream_model and the rewrite is logged once with both names. Called from proxy_anthropic_messages (after _filter_blocked_tools, before _is_streaming_request) and from proxy_count_tokens (before client.post), both guarded by `if upstream_name != "anthropic"` so the Anthropic path forwards the body byte-identically. Closes the cq-5 mitigation chain: Claude Code sees --model opus, the gateway swaps "opus" -> the upstream-side model name LiteLLM's model_list keys on (e.g. together_ai/Qwen/...), so a single configured agent actually routes to its non-Claude backend instead of bouncing on an unknown-model error. * **Blocker 2 (no-op-by-default regression)** — config/repo_config.get_default_agent_model now catches FileNotFoundError from _load_config() and returns None. Restores the documented "missing config = no entry" contract so resolve_agent_model no longer raises on spawn paths in unit tests and ephemeral CI environments that have neither EGG_REPO_CONFIG nor ~/.config/egg/repositories.yaml. Fixes the three pre-existing concurrent_executor tests that broke after v1 (TestSpawnPropagatesContainerInfo, TestRolesOverride, TestSpawnSpecificRoles). Smoke-tested both helpers in isolation: rewrite of "opus" -> "qwen3-coder-30b" produces the expected JSON; resolve with missing EGG_REPO_CONFIG returns the built-in opus / anthropic decision. Lint: ruff check + ruff format clean on both touched files. * feat(#2769 slice-2) v2: dual-import repo_config + defensive resolver guard at spawn site (reviewer_code NACK) Addresses reviewer_code's v1 NACK on the lazy import path in the resolver. * **orchestrator/agent_model_resolution.py:164** — the lazy import `from config.repo_config import get_default_agent_model` is broken in the production orchestrator container: `orchestrator/Dockerfile:66` flattens `config/repo_config.py` to `/app/repo_config.py` (no `/app/config/` directory exists). Mirror the established dual-import-with-fallback pattern used at `shared/egg_restrictions/patterns.py:913-916` and `orchestrator/routes/signals.py:961-964`: try: from config.repo_config import get_default_agent_model except ImportError: from repo_config import get_default_agent_model # type: ignore Source-tree layout keeps using the `config.` path; production container falls through to the top-level shim. * **orchestrator/concurrent_executor.py:454-466** — the spawn site now wraps `resolve_agent_model` in a `try / except Exception` block that logs the failure and falls back to `classify_model(DEFAULT_AGENT_MODEL)` (the built-in opus / anthropic default). Mirrors the existing fallback already in `routes/pipelines.py:2683-2699` for the restart path, so a future regression in the resolver degrades to today's no-op default instead of crashing every pipeline at spawn. Imports `DEFAULT_AGENT_MODEL` and `classify_model` from the resolver to keep the fallback typed. Smoke-tested the dual-import: with the `config` package removed from `sys.path` and a `repo_config` shim registered at the top level, the resolver still returns the built-in opus / anthropic decision for a default-config pipeline — i.e. the production container topology behaves identically to the source-tree one. Note: reviewer_code also asked for a regression test that exercises the prod-container import topology (only `orchestrator/` on `sys.path`, no `config/` package). Tests under `orchestrator/tests/` are owned by the tester role per `shared/egg_restrictions/patterns.py`; flagging the test request on the next tester re-review cycle. Lint: ruff check + ruff format clean. * tests(#2769 slice-2): scaffold agent-model resolution + body-rewrite tests Drafted before the coder lands slice-2 so reviewers can see the test plan early. Tests skip gracefully on the symbols they target (agent_model_resolution.resolve_agent_model, _rewrite_upstream_model, PipelineConfig.agent_models) until each one lands — and then they adversarially probe the implementation: * New: orchestrator/tests/test_agent_model_resolution.py - Decision-triple shape (claude_code_alias, upstream, upstream_model) - Precedence: per-pipeline > per-repo > built-in 'opus' - Classifier: short Claude aliases + 'claude-*' route to anthropic; everything else routes to litellm with claude_code_alias pinned to 'opus' (cq-5 mitigation). - PipelineConfig.agent_models validation (unknown roles raise; default is empty dict). - Regression: every implement-phase role defaults to anthropic+opus. - Adversarial: tricky model names like 'opus-7b', 'claudia-9000' cannot leak a non-'opus' alias to Claude Code on the LiteLLM path. * Extended: orchestrator/tests/test_concurrent_executor.py - Default-config spawn preserves byte-shape (no upstream kwargs). - Refiner override threads upstream='litellm', upstream_model='qwen3-coder-30b' to spawn_fn while presenting model='opus' to the consensus wrapper. - Override is per-role (coder spawn unaffected by refiner override). - 'sonnet' override stays on the Anthropic path. * Extended: tests/gateway/test_anthropic_proxy.py - _rewrite_upstream_model helper unit tests: rewrite the top-level 'model' field, preserve all other body keys, return original body on invalid JSON / empty body / missing model key, round-trip unicode model names. - End-to-end proxy: LiteLLM session with upstream_model rewrites body; without upstream_model leaves it alone; Anthropic session is byte-identical; count_tokens mirrors the rewrite; rewrite runs AFTER _filter_blocked_tools so private-mode tool stripping is not broken on the LiteLLM path. - Adversarial: malformed JSON body on a LiteLLM-routed request does not crash the proxy (matches _filter_blocked_tools resilience). All net-new tests skip when the slice-2 symbols are absent; once the coder lands, they will execute and surface bugs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * tests(#2769 slice-2): adapt patches to lazy import + lint fixes Two adjustments to the slice-2 tester scaffold so it loads cleanly against the coder's v1 proposal (491ce28): * Re-target the precedence/classifier patches from agent_model_resolution.get_default_agent_model to config.repo_config.get_default_agent_model — the resolver imports the helper lazily so the symbol does not exist on the resolver module, only on its source module. * Lint: drop unused httpx.Headers import in the invalid-JSON adversarial probe; resolved a ruff I001 sort warning on the _rewrite_helper_exists probe import. * ruff format pass across all three touched files. These are pure test-side adjustments. No production-code edits. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * tests(#2769 slice-2) v2: cover defensive guard + dual-import + missing-repo-config Three v2-coder-side additions land regression coverage for the bugs that would have escaped v1: * TestSpawnResolverFailureFallback (orchestrator/tests/test_concurrent_executor.py) - test_resolver_exception_falls_back_to_opus_anthropic: when resolve_agent_model raises, the spawner MUST degrade to the built-in opus / anthropic decision and MUST NOT add upstream/ upstream_model kwargs to the spawn_fn call (pre-#2769 wire shape preserved on the fallback path). - test_resolver_exception_still_calls_spawn_fn: the defensive guard is exception-swallow-then-fallback, not exception-swallow-then- short-circuit; spawn_fn is still called exactly once on the fallback path so the agent's container still comes up. * TestResolverMissingRepoConfigDoesNotCrash (orchestrator/tests/test_concurrent_executor.py) - test_spawn_with_missing_repositories_yaml_does_not_raise: spawns with the default test pipeline (pipeline.repo == 'test/repo'), which is exactly the v1-NACK trigger condition. Now resolves cleanly to the built-in opus / anthropic decision because the v2 get_default_agent_model catches FileNotFoundError and returns None. Prevents a future regression in config.repo_config.get_default_agent_model from re-introducing the v1 issue. * TestDualImportRepoConfig (orchestrator/tests/test_agent_model_resolution.py) - test_top_level_repo_config_fallback_resolves: simulates the production-container layout (orchestrator/Dockerfile:66 flattens config/repo_config.py to /app/repo_config.py with no /app/config/ package). Installs a top-level 'repo_config' shim, blocks the 'config' package via a meta-path finder, and asserts the resolver still produces the built-in opus / anthropic decision via the dual-import-with-fallback at agent_model_resolution.py:172-181. This is the regression test reviewer_code asked for in the v1 NACK — the coder flagged it for follow-up on the next tester cycle. Full run: 159 passed, 2 skipped (the two unrelated session-create launcher-auth tests). Ruff check + format clean; bandit -ll clean; mypy clean on the configured slice. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(lint): suppress EGG201 on _is_claude_alias docstring example * Address review feedback on per-agent model support (slice 2) BLOCKING fixes: - per-agent-models.md: correct the per-repo config snippets to nest default_agent_model under repo_settings: (not repositories:, which repositories.yaml has no key for and would silently drop the default). - PipelineConfig.agent_models validator now rejects roles the resolver never honors. resolve_agent_model is consulted only for SDLC phase producers and reviewers; overseer/autofixer/conflict_resolver/inspector spawn through paths that bypass it, so an override naming one of them would silently no-op. New agent_roles.MODEL_OVERRIDE_ROLES is the honored set; the validator surfaces such keys at construction time. Non-blocking fixes: - Correct the "threads through spawn" doc row: a restart respawns the Job and registers a new gateway session (it does not reuse the session). - Document the sonnet[1m] alias across all classifier doc surfaces. - Mirror the dual-import fallback in the restart resolver-failure path. - Document upstream/upstream_model on restart_agent_job / spawn_agent_job. - Strengthen test_default_config_passes_opus_to_consensus_wrapper so it distinguishes model='opus' passed from model not passed at all. * Address re-review suggestions on per-agent model support Fix the five non-blocking items from the slice-2 re-review: - Correct the stale `orchestrator/models.py:405` line reference in per-agent-models.md to `:757` (two occurrences). - Fix the "threads through spawn" doc paragraph: the conditional omission of the upstream kwargs happens at the _spawn_agent -> spawn_fn boundary, and register_session drops None values from the request body — not "register_session kwargs omitted entirely". - Add `sonnet[1m]` to the test module docstring classifier list and the test_short_claude_alias_is_anthropic parametrize set. - Add `applier` to test_multiple_known_roles_accepted to lock in that the apply-phase producer is an honored agent_models key. - Guard get_default_agent_model against a non-string default_agent_model: raise a clear ValueError instead of letting a non-string reach classify_model and raise an opaque TypeError. --------- Co-authored-by: egg <egg@example.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com> Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com> Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
# Conflicts: # .egg-state/contracts/issue-2769.json
There was a problem hiding this comment.
Agent-mode design re-review — no concerns
Re-reviewed the delta since my last approval at 020569dc. The only new PR-authored commit is 1ff4803 (slice-2: per-agent non-Claude model support); f17948251 is a no-op merge of origin/main.
Slice-2 adds the per-agent model resolver, config plumbing, and the gateway body-rewrite half of the cq-5 mitigation. I checked the agent-mode anti-patterns that could apply:
- Constraint enforcement (sandbox, not prompt). Upstream routing stays gateway-enforced per-session metadata (
Session.upstream/upstream_modelthreaded through spawn/restart), not a prompt-level instruction._rewrite_upstream_modelruns in the gateway proxy path. - Direct LLM API calls / Agent SDK bypass. None. Agents still spawn via
build_consensus_wrapped_command→egg_agent. LiteLLM is an in-cluster proxy upstream; the gateway remains a proxy and makes no direct Anthropic API calls. - Hardcoded model IDs (EGG201). The resolver uses short aliases (
opus/sonnet/haiku). The^claude-regex exists to recognize versioned IDs an operator might pass and route them correctly — that's the intended classifier behavior, not a violation. The single# noqa: EGG201sits on a docstring example, which is appropriate. - cq-5 mitigation. Handing Claude Code
--model opuson LiteLLM-routed agents while the gateway swaps the body's model name is a technical workaround for compaction heuristics — it enables non-Claude models rather than constraining agent flexibility.
No pre-fetching, structured-output-for-humans, post-processing, or rigid-procedure concerns. The new docs/guides/per-agent-models.md is an operator how-to, not an agent prompt.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — PR #2773 (slice-2 delta: per-agent non-Claude model support)
Re-reviewed at HEAD f1794825. Since my last review at 020569dc the only PR-authored
commit is 1ff4803 ("[issue-2769][merge-gate]", the slice-2 content: per-agent model
resolver, config plumbing, gateway body-rewrite); f1794825 is a no-op merge of
origin/main.
Verdict: no blocking issues. The feature wires end-to-end correctly. One non-blocking
inconsistency and a couple of doc nits, detailed below. (Self-authored merged PR — posting
as a comment.)
Verification performed
- Traced the full data flow:
agent_models→PipelineConfigvalidator →_run_concurrent_phase
→ConcurrentPhaseExecutor._spawn_agent→resolve_agent_model→spawn_fn/spawn_agent_job
→register_session→Session.upstream/upstream_model→proxy_anthropic_messages/
proxy_count_tokens→_rewrite_upstream_model. The refiner (the operator-guide's primary
example) does reach the resolver — all phases run through_run_concurrent_phaseper
_run_pipeline's docstring, confirmed. build_consensus_wrapped_commandalready acceptsmodel: str = "opus", so the new
model=decision.claude_code_aliascall is byte-identical on the default path.restart_agent_containeris an alias ofrestart_agent_job(kubernetes_spawner.py:1753),
which the PR extended withupstream/upstream_model— no signature mismatch.- Content-Length:
content-lengthis inANTHROPIC_BLOCKED_HEADERS, so the body-length
change from_rewrite_upstream_model(and from the pre-existing_filter_blocked_tools)
is safe — httpx recomputes it. - Ran the slice-2 suites:
test_agent_model_resolution.py(33 passed),
test_concurrent_executor.pyslice-2 tests (15 passed),test_anthropic_proxy.py
rewrite tests (13 passed). All exercise the real production code path (real resolver,
real_rewrite_upstream_model, real Flask proxy route) — no hand-built-fixture bypass,
no self-seeding goldens.
Non-blocking — restart path resolves the model for every role, including the resolver-bypassing ones
restart_agent (routes/pipelines.py:2255) accepts any valid AgentRole (only
AgentRole(agent_role) is validated, no phase-role restriction) and calls
resolve_agent_model(role=role, …) unconditionally for whatever role is restarted.
This contradicts the design invariant the PR itself documents. MODEL_OVERRIDE_ROLES and
the PipelineConfig.agent_models validator exist because — per the validator's own comment
(models.py) and the agent_roles.py comment — "Utility roles (AUTOFIXER,
CONFLICT_RESOLVER) and interface roles (OVERSEER, INSPECTOR) spawn through dedicated paths
that never call the resolver." That is true for the initial spawn but false for the
restart path.
Concrete consequence: with a repo-level default_agent_model: qwen3-coder-30b set, the
initial overseer/autofixer/conflict_resolver/inspector spawn stays on
anthropic/opus (it bypasses the resolver), but an operator restart of that same role
via the restart_agent HTTP route resolves tier-2 → a LiteLLM decision → the restarted
agent registers its gateway session with upstream=litellm. Same role, two code paths,
two different upstreams — and these roles were deliberately carved out of the override
surface precisely because they were never validated for a non-Claude backend.
The per-pipeline agent_models knob is protected (the validator rejects these role keys),
but the repo-level default_agent_model has no role filtering and leaks through the
restart path. Blast radius is narrow (needs a non-Claude repo default and a restart of a
non-phase role, and it is a misroute rather than a crash or state corruption), so this is
non-blocking — but it is a genuine "same input, different result across code paths"
inconsistency in code this PR introduces. Suggested fix: in restart_agent, skip
resolution (use the built-in classify_model("opus") default) when
role not in MODEL_OVERRIDE_ROLES, mirroring the initial-spawn behavior.
Non-blocking — stale file:line cites in docs/guides/per-agent-models.md
The guide cites proxy_anthropic_messages at gateway/gateway.py:9839 (actual 9922),
proxy_count_tokens at :10135 (actual 10227), and _filter_blocked_tools at :9496
(actual 9522) — off by ~25–90 lines. The merge of origin/main shifted gateway.py
after the doc was authored. The cites still land in the right neighborhood, so this is a
minor accuracy nit, but given the PR explicitly chased cite accuracy elsewhere
(models.py:405 → :757), worth a refresh.
Non-blocking — default_agent_model "every role" wording
The guide (step 3a) describes default_agent_model as applying to "every role". On the
initial spawn it only reaches roles routed through the resolver (phase producers +
reviewers); utility/interface roles are unaffected. Minor imprecision — the validator
section later clarifies the distinction, so a careful reader recovers, but the step-3a
prose overstates the scope.
Good close-out otherwise: the no-op-by-default invariant holds (default config →
byte-identical build_consensus_wrapped_command args, no register_session kwargs), the
_rewrite_upstream_model helper handles every edge case the tests probe (None /
invalid-JSON / non-dict / missing-model-key), and the defensive try/except around
resolve_agent_model at both spawn sites prevents a resolver regression from crashing
pipelines.
— Authored by egg
|
egg review completed. View run logs 16 previous review(s) hidden. |
…r] (#2776) * docs: Update structural docs for LiteLLM upstream routing (#2769) Four structural docs were missing coverage of the gateway upstream router and LiteLLM deployment introduced in #2773 (slice-1/2 of #2769): - sdlc-pipeline.md: Add agent_models to PipelineConfig field table - credential-injection.md: Add LITELLM_MASTER_KEY auth type; add upstream_registry.py to the files table; update intro sentence - resource-sizing.md: Add litellm pod row to allocations table - kubernetes-migration.md: Add litellm Deployment to namespace diagram The dedicated feature docs (docs/guides/per-agent-models.md and docs/architecture/upstream-routing.md) were added in the same commit and are already linked from docs/index.md. * docs: Fix pod-type count and litellm annotations in structural docs Address review feedback on #2776: resource-sizing.md said 'three pod types' in two places after the table grew to four rows; both now say 'four'. The intro telemetry clause no longer over-claims for the litellm row, which was sized from the deployment manifest rather than tuned against the observed snapshots. The kubernetes-migration.md litellm annotation drops the inaccurate 'optional' wording (the Deployment is unconditionally listed in kustomization.yaml) in favour of the no-op-until-model_list framing used in the kustomization comment. --------- Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com> Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Today every egg SDLC agent runs on Claude through the Claude Code
harness, with the gateway hard-wiring
api.anthropic.comas theonly
/v1/messagesupstream. The orchestrator's consensus wrapperhardcodes
--model opusfor every non-overseer role, so per-agentmodel selection does not exist. We need to let any agent run on a
non-Claude backend (Qwen is the first target, primarily for cost)
while every Claude-bound agent stays byte-identically on the
existing path — and we need the integration to be safe to ship
before a live non-Claude endpoint is available.
This change lands the buildable, no-op-by-default seam in two
stacked PRs:
Introduces a small
UpstreamRegistryabstraction in thegateway that keys per-request
httpx.Client+ credential byupstream name, with
proxy_anthropic_messages/proxy_count_tokensresolving the upstream per request viathe existing IP-keyed session lookup that already drives
session_mode. AddsSession.upstream(default"anthropic")and
Session.upstream_model(defaultNone), wired through/api/v1/sessions/create,SessionManager.register_session,and
GatewayClient.register_session. The LiteLLM proxy itselfships as a separate Deployment + Service + ConfigMap in
egg-system, reachable only by the gateway. The Claude pathis structurally untouched.
Adds
PipelineConfig.agent_models: dict[str, str]and adefault_agent_modelrepository-level setting, plus aresolution function that the orchestrator's spawner calls to
(a) thread the right
--modeltobuild_consensus_wrapped_commandand (b) tell the gateway the per-agent
upstreamandupstream_modelat session-create time. The gateway, on aLiteLLM-routed request, rewrites the body's
modelfieldfrom the Claude alias presented to Claude Code to the
upstream-side model name — keeping Claude Code's compaction
math sane (cq-5).
With
agent_modelsempty (the default everywhere), no LiteLLMrequest fires. Every existing pipeline keeps running on Claude
with byte-identical gateway behavior. The empirical
Claude-Code-compaction smoke test (cq-4) is an operator-driven
follow-up once a live non-Claude endpoint is configured; it is
explicitly out of scope here.
This slice
Gateway upstream router + LiteLLM Deployment (no-op by default)
Files affected:
gateway/upstream_registry.pygateway/anthropic_credentials.pygateway/gateway.pygateway/session_manager.pyorchestrator/gateway_client.pyk8s/base/litellm-deployment.yamlk8s/base/litellm-service.yamlk8s/base/litellm-configmap.yamlk8s/base/kustomization.yamlconfig/secrets.template.envtests/gateway/test_upstream_registry.pytests/gateway/test_anthropic_proxy.pytests/gateway/test_anthropic_credentials.pygateway/tests/test_session_manager.pyorchestrator/tests/test_gateway_client.pydocs/architecture/upstream-routing.mdgateway/CLAUDE.mddocs/architecture/orchestrator.mdTasks:
gateway/upstream_registry.py(NEW) containing anUpstreamRegistryclass keyed by upstream name ("anthropic","litellm"). Each registry entry pairs a singletonhttpx.Client(base_url, timeout, connection limits) with a credential resolver returning anUpstreamCredential(the union of today'sAnthropicCredentialshape and the new LiteLLMx-api-keyshape). Provideget(upstream: str)returning(client, credential_resolver), raising a typedUnknownUpstreamErroron miss. Wire it into aget_upstream_registry()accessor that mirrors today'sget_anthropic_client()lifetime semantics.UpstreamRegistry.get("anthropic")returns a client withbase_url == "https://api.anthropic.com"and the existing Anthropic credential resolver (preserves the# noqa: EGG200annotation pattern atgateway/gateway.py:9325). -UpstreamRegistry.get("litellm")returns a client whosebase_urlis sourced from a newLITELLM_BASE_URLenv var (defaulthttp://litellm.egg-system.svc.cluster.local:4000) and the LiteLLM credential resolver. -UpstreamRegistry.get("unknown")raisesUnknownUpstreamError. - Both clients share the same timeout / pooling characteristics as today's_anthropic_client.gateway/anthropic_credentials.py(or a sibling module if file-size discipline requires it). The resolver readsLITELLM_MASTER_KEYfromsecrets.envusing the existingparse_env_filehelper atgateway/anthropic_credentials.py:52, caches with the same mtime-invalidated pattern asAnthropicCredentialsManager, and returns a credential shapedheader_name="x-api-key",header_value="<key>". ReturnsNonewhen the key is absent (no-op default — matches today's behavior when Anthropic credentials are absent).LITELLM_MASTER_KEYunset, the resolver returnsNoneand does not warn at startup. - WithLITELLM_MASTER_KEY=foo, the resolver returns a credential withheader_name == "x-api-key"andheader_value == "foo". -secrets.envmtime change invalidates the cache the same wayAnthropicCredentialsManagerdoes._inject_anthropic_credentialsupstream-aware (rename to_inject_upstream_credentials(headers, upstream)and keep the old symbol as a back-compat alias calling through withupstream="anthropic"). Dispatch to the LiteLLM credential resolver whenupstream == "litellm". Preserve the 401 / "no credential" error path verbatim for both._inject_upstream_credentials(headers, "anthropic")behaves byte-identically to today's_inject_anthropic_credentials(headers). -_inject_upstream_credentials(headers, "litellm")addsx-api-key: <LITELLM_MASTER_KEY>when the key is set. - Missing credentials for either upstream return a 401 with the same JSON body shape as today.upstream: str = "anthropic"andupstream_model: str | None = Noneto theSessiondataclass atgateway/session_manager.py:288. Plumb them throughSession.to_dict_for_persistence/Session.from_persistenceso existing persisted sessions without the fields still load (defaults apply). ExtendSessionManager.register_session(gateway/session_manager.py:548) to accept the two new optional parameters.Sessioncreated without the new fields keepsupstream == "anthropic"andupstream_model is None. -Session.to_dict_for_persistence/Session.from_persistenceround-trip both fields losslessly and tolerate persisted dicts where the fields are absent. -SessionManager.register_session(upstream="litellm", upstream_model="qwen3-coder-30b")stores both on the returnedSession.upstreamandupstream_modelthrough the/api/v1/sessions/createroute handler atgateway/gateway.py:8507(parse from request body with their defaults, validate thatupstreamis one of the registered names fromUpstreamRegistry, pass through toSessionManager.register_session). Log them in the existingaudit_log("session_created", ...)call so the per-session upstream is auditable./api/v1/sessions/createwithout the new fields creates a session withupstream="anthropic"andupstream_model is None. - POSTing withupstream="litellm", upstream_model="qwen3-coder-30b"creates a session with those values. - POSTing withupstream="bogus"returns a 400 with a descriptive error. -session_createdaudit log includes the upstream and upstream_model.proxy_anthropic_messages(gateway/gateway.py:9753) andproxy_count_tokens(gateway/gateway.py:10020) to resolve the upstream per request: replaceclient = get_anthropic_client()with the registry lookup usingsession.upstream(defaulting to"anthropic"when there is no session — preserves today's behavior). Replace_inject_anthropic_credentials(headers)calls with_inject_upstream_credentials(headers, session.upstream). Keep the SSE accumulator, tool-filter, and stream-resilience retry loop unchanged.upstream == "anthropic"(or no session) hits the Anthropic httpx client and injects the Anthropic credential — byte-identical to today. - A request whose session hasupstream == "litellm"hits the LiteLLM client and injects the LiteLLM credential. -_filter_blocked_tools, the_SSEAccumulatorparse, and the connection-reset retry loop are unchanged in behavior and code shape (no new branches inside any of them). -proxy_count_tokensmirrors the same routing change.GatewayClient.register_sessionatorchestrator/gateway_client.py:602with optionalupstream: str | None = Noneandupstream_model: str | None = Noneparameters. Include them inrequest_dataonly when set (matches the existing optional-field pattern atgateway_client.py:653-690). No caller in slice 1 passes them; this is purely the wire-shape.GatewayClient.register_session(...)without the new args produces the same request body as today. -GatewayClient.register_session(upstream="litellm", upstream_model="qwen3-coder-30b")includes both keys in the POSTed JSON.k8s/base/litellm-deployment.yaml,k8s/base/litellm-service.yaml, andk8s/base/litellm-configmap.yaml. Deployment runs a pinned LiteLLM image inegg-system, mounts the ConfigMap at/app/config.yaml, exposes port4000(LiteLLM's default). Service isClusterIPnamedlitellm. ConfigMap ships with an EMPTYmodel_listso the deployment comes up healthy but serves nothing until operators populate it. Add all three tok8s/base/kustomization.yaml.kubectl apply --dry-run=client -k k8s/base/succeeds with the new resources included. - The LiteLLM Service resolves tolitellm.egg-system.svc.cluster.local:4000, which matches the defaultLITELLM_BASE_URLbaked intoUpstreamRegistry. - No NetworkPolicy change toegg-agentsegress — agents do not talk to LiteLLM directly.LITELLM_MASTER_KEYinconfig/secrets.template.env(one block below theANTHROPIC_API_KEYblock, with an explicit "leave empty to disable LiteLLM routing — no agent will be routed to LiteLLM with this unset" comment).config/secrets.template.envcontains a documentedLITELLM_MASTER_KEY=""entry with the disable-when-empty note.tests/gateway/test_upstream_registry.py(new, covering the three registry cases — anthropic, litellm, unknown), extensions totests/gateway/test_anthropic_credentials.py(LiteLLM resolver path), and extensions totests/gateway/test_anthropic_proxy.py(the two routing branches for both proxy routes).make testreaches and passes the new + extended tests. - Coverage includes the unknown-upstream error path, the "no credential" 401 path for both upstreams, and a byte-identity check for the Anthropic-routed request shape vs today.gateway/tests/test_session_manager.py(round-trip the two new fields, register_session with defaults, register with explicit LiteLLM values) and toorchestrator/tests/test_gateway_client.py(omitted args → no new keys in body, explicit args → keys present).make test. - The session-persistence test verifies a dict missing the new keys still rehydrates cleanly (back-compat guard).docs/architecture/upstream-routing.mddescribing theUpstreamRegistryseam, the LiteLLM topology, the per-session routing decision, the credential layout, and the cq-1 / cq-2 / cq-5 / cq-7 / cq-8 resolutions that shape it. Cross-link fromgateway/CLAUDE.mdanddocs/architecture/orchestrator.md.file:linecite, explains the no-op-by-default invariant, and walks through the request lifecycle for both upstreams. -gateway/CLAUDE.mdanddocs/architecture/orchestrator.mdlink to it.Test Plan
Automated:
make testfrom the repo root catches both slices' reachablesuites given the changeset.
tests/gateway/test_upstream_registry.py(new),extensions to
tests/gateway/test_anthropic_proxy.py,tests/gateway/test_anthropic_credentials.py,gateway/tests/test_session_manager.py,orchestrator/tests/test_gateway_client.py.orchestrator/tests/test_agent_model_resolution.py(new), extensions to the concurrent-executor and
pipeline-spawn tests, extensions to the gateway proxy tests
covering the body-rewrite branch.
Manual (reviewer):
make testandmake lintare green.kubectl apply --dry-run=client -k k8s/base/succeeds withthe new LiteLLM manifests included.
agent_models={}and noLITELLM_MASTER_KEYin secrets, gateway request flow isbyte-identical to today's Claude path (no new headers, no
upstream change, same SSE behavior).
Manual (operator, post-merge — not gating merge):
LITELLM_MASTER_KEYinsecrets.env, configureLiteLLM
model_listwith a hosted Qwen provider (cq-6), setagent_models={"refiner": "qwen3-coder-30b"}on a pipeline,and exercise a tool-heavy multi-turn loop plus a long session
crossing the auto-compaction boundary (the cq-4-deferred
empirical compatibility check).
Manual Steps
Pre-merge: none.
Post-merge (only required when operator wants to actually run an
agent on a non-Claude backend):
LITELLM_MASTER_KEY=<random>to~/.config/egg/secrets.env.model_listwith at least onebackend (hosted Qwen provider first, per cq-6). The
provider-side API key goes in LiteLLM's standard env-var slot,
not in
secrets.env.default_agent_modelin~/.config/egg/repositories.yamlfor the target repo, orpass
agent_models={"<role>": "<model>"}on pipeline submitto override per pipeline.
Stack
issue-2769egg/issue-2769/workSlice slice-1 of pipeline issue-2769. Stacked on top of
egg/issue-2769/work.