Skip to content

[issue-2769][slice-1/2] Gateway upstream router + LiteLLM Deploymen... - #2773

Merged
jwbron merged 23 commits into
mainfrom
egg/issue-2769/slice-1
May 22, 2026
Merged

[issue-2769][slice-1/2] Gateway upstream router + LiteLLM Deploymen...#2773
jwbron merged 23 commits into
mainfrom
egg/issue-2769/slice-1

Conversation

@james-in-a-box

Copy link
Copy Markdown
Contributor

Today every egg SDLC agent runs on Claude through the Claude Code
harness, with the gateway hard-wiring api.anthropic.com as the
only /v1/messages upstream. The orchestrator's consensus wrapper
hardcodes --model opus for every non-overseer role, so per-agent
model 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:

  1. Gateway upstream router + LiteLLM Deployment (slice 1).
    Introduces a small UpstreamRegistry abstraction in the
    gateway that keys per-request httpx.Client + credential by
    upstream name, with proxy_anthropic_messages /
    proxy_count_tokens resolving the upstream per request via
    the existing IP-keyed session lookup that already drives
    session_mode. Adds Session.upstream (default "anthropic")
    and Session.upstream_model (default None), wired through
    /api/v1/sessions/create, SessionManager.register_session,
    and GatewayClient.register_session. The LiteLLM proxy itself
    ships as a separate Deployment + Service + ConfigMap in
    egg-system, reachable only by the gateway. The Claude path
    is structurally untouched.
  2. Per-agent model config + spawn-side plumbing (slice 2).
    Adds PipelineConfig.agent_models: dict[str, str] and a
    default_agent_model repository-level setting, plus a
    resolution function that the orchestrator's spawner calls to
    (a) thread the right --model to build_consensus_wrapped_command
    and (b) tell the gateway the per-agent upstream and
    upstream_model at session-create time. The gateway, on a
    LiteLLM-routed request, rewrites the body's model field
    from the Claude alias presented to Claude Code to the
    upstream-side model name — keeping Claude Code's compaction
    math sane (cq-5).

With agent_models empty (the default everywhere), no LiteLLM
request 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.py
  • gateway/anthropic_credentials.py
  • gateway/gateway.py
  • gateway/session_manager.py
  • orchestrator/gateway_client.py
  • k8s/base/litellm-deployment.yaml
  • k8s/base/litellm-service.yaml
  • k8s/base/litellm-configmap.yaml
  • k8s/base/kustomization.yaml
  • config/secrets.template.env
  • tests/gateway/test_upstream_registry.py
  • tests/gateway/test_anthropic_proxy.py
  • tests/gateway/test_anthropic_credentials.py
  • gateway/tests/test_session_manager.py
  • orchestrator/tests/test_gateway_client.py
  • docs/architecture/upstream-routing.md
  • gateway/CLAUDE.md
  • docs/architecture/orchestrator.md

Tasks:

  • task-1-1: Introduce gateway/upstream_registry.py (NEW) containing an UpstreamRegistry class keyed by upstream name ("anthropic", "litellm"). Each registry entry pairs a singleton httpx.Client (base_url, timeout, connection limits) with a credential resolver returning an UpstreamCredential (the union of today's AnthropicCredential shape and the new LiteLLM x-api-key shape). Provide get(upstream: str) returning (client, credential_resolver), raising a typed UnknownUpstreamError on miss. Wire it into a get_upstream_registry() accessor that mirrors today's get_anthropic_client() lifetime semantics.
    • Acceptance criteria: - UpstreamRegistry.get("anthropic") returns a client with base_url == "https://api.anthropic.com" and the existing Anthropic credential resolver (preserves the # noqa: EGG200 annotation pattern at gateway/gateway.py:9325). - UpstreamRegistry.get("litellm") returns a client whose base_url is sourced from a new LITELLM_BASE_URL env var (default http://litellm.egg-system.svc.cluster.local:4000) and the LiteLLM credential resolver. - UpstreamRegistry.get("unknown") raises UnknownUpstreamError. - Both clients share the same timeout / pooling characteristics as today's _anthropic_client.
  • task-1-2: Add a LiteLLM credential resolver to gateway/anthropic_credentials.py (or a sibling module if file-size discipline requires it). The resolver reads LITELLM_MASTER_KEY from secrets.env using the existing parse_env_file helper at gateway/anthropic_credentials.py:52, caches with the same mtime-invalidated pattern as AnthropicCredentialsManager, and returns a credential shaped header_name="x-api-key", header_value="<key>". Returns None when the key is absent (no-op default — matches today's behavior when Anthropic credentials are absent).
    • Acceptance criteria: - With LITELLM_MASTER_KEY unset, the resolver returns None and does not warn at startup. - With LITELLM_MASTER_KEY=foo, the resolver returns a credential with header_name == "x-api-key" and header_value == "foo". - secrets.env mtime change invalidates the cache the same way AnthropicCredentialsManager does.
  • task-1-3: Make _inject_anthropic_credentials upstream-aware (rename to _inject_upstream_credentials(headers, upstream) and keep the old symbol as a back-compat alias calling through with upstream="anthropic"). Dispatch to the LiteLLM credential resolver when upstream == "litellm". Preserve the 401 / "no credential" error path verbatim for both.
    • Acceptance criteria: - _inject_upstream_credentials(headers, "anthropic") behaves byte-identically to today's _inject_anthropic_credentials(headers). - _inject_upstream_credentials(headers, "litellm") adds x-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.
  • task-1-4: Add upstream: str = "anthropic" and upstream_model: str | None = None to the Session dataclass at gateway/session_manager.py:288. Plumb them through Session.to_dict_for_persistence / Session.from_persistence so existing persisted sessions without the fields still load (defaults apply). Extend SessionManager.register_session (gateway/session_manager.py:548) to accept the two new optional parameters.
    • Acceptance criteria: - A Session created without the new fields keeps upstream == "anthropic" and upstream_model is None. - Session.to_dict_for_persistence / Session.from_persistence round-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 returned Session.
  • task-1-5: Wire upstream and upstream_model through the /api/v1/sessions/create route handler at gateway/gateway.py:8507 (parse from request body with their defaults, validate that upstream is one of the registered names from UpstreamRegistry, pass through to SessionManager.register_session). Log them in the existing audit_log("session_created", ...) call so the per-session upstream is auditable.
    • Acceptance criteria: - POSTing to /api/v1/sessions/create without the new fields creates a session with upstream="anthropic" and upstream_model is None. - POSTing with upstream="litellm", upstream_model="qwen3-coder-30b" creates a session with those values. - POSTing with upstream="bogus" returns a 400 with a descriptive error. - session_created audit log includes the upstream and upstream_model.
  • task-1-6: Refactor proxy_anthropic_messages (gateway/gateway.py:9753) and proxy_count_tokens (gateway/gateway.py:10020) to resolve the upstream per request: replace client = get_anthropic_client() with the registry lookup using session.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.
    • Acceptance criteria: - A request whose session has upstream == "anthropic" (or no session) hits the Anthropic httpx client and injects the Anthropic credential — byte-identical to today. - A request whose session has upstream == "litellm" hits the LiteLLM client and injects the LiteLLM credential. - _filter_blocked_tools, the _SSEAccumulator parse, and the connection-reset retry loop are unchanged in behavior and code shape (no new branches inside any of them). - proxy_count_tokens mirrors the same routing change.
  • task-1-7: Extend GatewayClient.register_session at orchestrator/gateway_client.py:602 with optional upstream: str | None = None and upstream_model: str | None = None parameters. Include them in request_data only when set (matches the existing optional-field pattern at gateway_client.py:653-690). No caller in slice 1 passes them; this is purely the wire-shape.
    • Acceptance criteria: - 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.
  • task-1-8: Add k8s manifests for the LiteLLM proxy: k8s/base/litellm-deployment.yaml, k8s/base/litellm-service.yaml, and k8s/base/litellm-configmap.yaml. Deployment runs a pinned LiteLLM image in egg-system, mounts the ConfigMap at /app/config.yaml, exposes port 4000 (LiteLLM's default). Service is ClusterIP named litellm. ConfigMap ships with an EMPTY model_list so the deployment comes up healthy but serves nothing until operators populate it. Add all three to k8s/base/kustomization.yaml.
    • Acceptance criteria: - kubectl apply --dry-run=client -k k8s/base/ succeeds with the new resources included. - The LiteLLM Service resolves to litellm.egg-system.svc.cluster.local:4000, which matches the default LITELLM_BASE_URL baked into UpstreamRegistry. - No NetworkPolicy change to egg-agents egress — agents do not talk to LiteLLM directly.
  • task-1-9: Document LITELLM_MASTER_KEY in config/secrets.template.env (one block below the ANTHROPIC_API_KEY block, with an explicit "leave empty to disable LiteLLM routing — no agent will be routed to LiteLLM with this unset" comment).
    • Acceptance criteria: - config/secrets.template.env contains a documented LITELLM_MASTER_KEY="" entry with the disable-when-empty note.
  • task-1-10: Write unit tests covering the slice 1 gateway-side changes: tests/gateway/test_upstream_registry.py (new, covering the three registry cases — anthropic, litellm, unknown), extensions to tests/gateway/test_anthropic_credentials.py (LiteLLM resolver path), and extensions to tests/gateway/test_anthropic_proxy.py (the two routing branches for both proxy routes).
    • Acceptance criteria: - make test reaches 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.
  • task-1-11: Write unit tests covering the slice 1 session-manager and orchestrator-client changes: extensions to gateway/tests/test_session_manager.py (round-trip the two new fields, register_session with defaults, register with explicit LiteLLM values) and to orchestrator/tests/test_gateway_client.py (omitted args → no new keys in body, explicit args → keys present).
    • Acceptance criteria: - All tests pass under make test. - The session-persistence test verifies a dict missing the new keys still rehydrates cleanly (back-compat guard).
  • task-1-12: Author a new architecture doc docs/architecture/upstream-routing.md describing the UpstreamRegistry seam, 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 from gateway/CLAUDE.md and docs/architecture/orchestrator.md.
    • Acceptance criteria: - The doc names every primitive added in slice 1 with a file:line cite, explains the no-op-by-default invariant, and walks through the request lifecycle for both upstreams. - gateway/CLAUDE.md and docs/architecture/orchestrator.md link to it.

Test Plan

Automated:

  • make test from the repo root catches both slices' reachable
    suites given the changeset.
  • Slice 1: 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.
  • Slice 2: 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):

  • Confirm make test and make lint are green.
  • kubectl apply --dry-run=client -k k8s/base/ succeeds with
    the new LiteLLM manifests included.
  • Spot-check that with agent_models={} and no
    LITELLM_MASTER_KEY in secrets, gateway request flow is
    byte-identical to today's Claude path (no new headers, no
    upstream change, same SSE behavior).

Manual (operator, post-merge — not gating merge):

  • Populate LITELLM_MASTER_KEY in secrets.env, configure
    LiteLLM model_list with a hosted Qwen provider (cq-6), set
    agent_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):

  1. Add LITELLM_MASTER_KEY=<random> to ~/.config/egg/secrets.env.
  2. Populate the LiteLLM ConfigMap model_list with at least one
    backend (hosted Qwen provider first, per cq-6). The
    provider-side API key goes in LiteLLM's standard env-var slot,
    not in secrets.env.
  3. Either set default_agent_model in
    ~/.config/egg/repositories.yaml for the target repo, or
    pass agent_models={"<role>": "<model>"} on pipeline submit
    to override per pipeline.

Stack

  • Position: slice 1 of 2 in pipeline issue-2769
  • Stacked on top of egg/issue-2769/work

Slice slice-1 of pipeline issue-2769. Stacked on top of egg/issue-2769/work.

egg-orchestrator and others added 17 commits May 22, 2026 01:20
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>
@james-in-a-box

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.
@james-in-a-box

james-in-a-box Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor Author
Autofix tracking
{"Lint/Custom Checks": 1, "Lint/Python": 1}

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-2 decision 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-5 keeps 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.py is a proxy client, not a decision-making LLM call — consistent with the existing get_anthropic_client() exception. The # noqa: EGG200 annotations on the api.anthropic.com literal and client construction follow the established pattern.

  • EGG201 (pinned model identifiers). No pinned Claude identifiers introduced; the opus alias is used for the Claude-Code-facing model. upstream_model values like qwen3-coder-30b are 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

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.upstream defaults to "anthropic"; from_persistence
    tolerates pre-#2769 dicts (gateway/session_manager.py:419-420).
  • proxy_anthropic_messages / proxy_count_tokens keep 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-system has none, so gateway→litellm works and
    agents still cannot reach litellm: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.md references cq-9 in prose
    but the HITL table only lists cq-1/2/5/7/8.
  • docs/architecture/orchestrator.md links to
    ../guides/per-agent-models.md, which doesn't exist until slice 2 —
    a dangling link in merged docs meanwhile.
  • gateway/upstream_registry.py:1 puts # 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
    # noqa comment 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_model input validation in session_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

@james-in-a-box

This comment has been minimized.

@james-in-a-box

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).
@james-in-a-box

Copy link
Copy Markdown
Contributor Author

Review feedback addressed — commit b914607

Thanks for the thorough trace. Every item is fixed in this PR; no deferrals.

Blocking

1. make deploy aborts on the litellm image pull / misleading errorfixed-in-PR (commit b914607)
scripts/await-egg-deploy.sh's ImagePullBackOff fast-fail now scans only egg's own deployments (orchestrator/gateway) via the app.kubernetes.io/component label selector, looping the existing DEPLOYMENTS array. A third-party pod (the LiteLLM proxy, image pulled from an external registry) failing to pull no longer aborts make deploy or mis-blames EGG_IMAGE_TAG — the tag-drift fast-fail only ever applies to images egg actually builds and tag-rewrites. I picked this option (vs. pre-importing the image or moving the manifests to an overlay) because it is the root-cause fix: the script's premise is egg tag drift, so it should only ever blame egg's own pods. Confirmed ghcr.io/berriai/litellm:main-v1.55.10 resolves — ghcr manifest request returns HTTP 200.

Non-blocking

2. _inject_upstream_credentials silently treats unknown upstreams as Anthropicfixed-in-PR (commit b914607)
Added an explicit guard at the top: an upstream not in UpstreamRegistry fails closed with a deterministic 502 before the Anthropic branch, so the same invalid input no longer yields 401-vs-502 depending on unrelated Anthropic-credential state. New regression test test_unknown_upstream_returns_502_without_anthropic_fallthrough exercises exactly the None-credential case you flagged.

3. register_session does not validate upstreamfixed-in-PR (commit b914607)
SessionManager.register_session now validates upstream against UpstreamRegistry and raises ValueError on an unknown value — a direct caller (the slice-2 spawner, tests) can no longer create a bogus-upstream session. New test test_register_with_unknown_upstream_raises. GatewayClient.register_session (orchestrator side) gets a docstring note that the gateway is authoritative and 400s an unknown value — I kept the orchestrator a thin client rather than duplicating the known-upstreams list across the trust boundary.

4. litellm pod health asserted but unverifiedfixed-in-PR (commit b914607) (hardening) + verification note
Addressed the concrete readOnlyRootFilesystem risk: the Deployment now sets HOME=/tmp so any home-relative startup write LiteLLM performs (cache/config dirs, and the XDG_* defaults that derive from $HOME) lands in the writable tmpfs instead of failing against the read-only root. I cannot start a live pod in this sandbox (no cluster for an arbitrary external image), so a real /health/readiness + /health/liveliness run still needs an operator with a cluster — but the HOME=/tmp change removes the specific failure mode that made the "healthy idle" claim risky.

5. Error messages hardcode "Anthropic API" on every upstreamfixed-in-PR (commit b914607)
proxy_anthropic_messages / proxy_count_tokens ConnectError / TimeoutException / Exception handlers (and their log lines) are now upstream-aware — f"Failed to connect to {upstream_name} upstream" etc., with upstream=upstream_name structured-log context.

6. Scaffold skip-guards mask regressionsfixed-in-PR (commit b914607)
Removed every scaffold-era guard: the try: import upstream_registry except ImportError: skip, except TypeError: skip, and except AttributeError: skip blocks across test_anthropic_proxy.py, test_anthropic_credentials.py, test_upstream_registry.py, test_session_manager.py, and test_gateway_client.py, plus the two-shape _import_litellm_resolver indirection. A future rename now fails the suite loudly. TestSessionCreateUpstreamValidation no longer depends on EGG_LAUNCHER_SECRET — it patches get_launcher_secret and sends the matching bearer, so the TASK-1-5 validation branch has deterministic coverage (test_bogus_upstream_returns_400 + test_known_upstreams_pass_validation).

7. Registry credential-resolver half is unused by the proxy routesdisagree (deliberate design, not redundancy)
The paired (client, resolver) is intentional: the registry is the single source of truth for the upstream→credential mapping and stays self-describing and test-covered. The proxy routes use the dedicated _inject_upstream_credentials because the Anthropic branch carries the OAuth-passthrough / client-supplied-auth fall-through, which is upstream-specific behavior that does not belong inside a generic registry resolver. Routing injection through registry.get(...)[1] would also break the test isolation that patches gateway.gateway.get_litellm_credentials_manager directly. Since you noted this is "not wrong," I've kept the pairing as-is.

8. Minor — all fixed-in-PR (commit b914607)

  • KNOWN_UPSTREAMS module constant in upstream_registry.py replaces the three hardcoded ("anthropic", "litellm") tuples in get / is_known / known_upstreams.
  • Added the cq-9 row to the upstream-routing HITL table (and updated "five" → "six") so the table matches the prose.
  • De-linked the per-agent-models.md references in orchestrator.md and upstream-routing.md — they are now plain text noting the file ships in slice 2, so merged docs carry no dangling link.
  • Dropped the # noqa: EGG200 from the module docstring's first line by rewording the docstring to not name api.anthropic.com (the ANTHROPIC_BASE_URL constant keeps its trailing noqa, which reads cleanly).

Verification

ruff check + ruff format --check clean; check-llm-api-calls.py (EGG200) clean; shellcheck clean on the deploy script; mypy clean for the changed files. Test suites green: test_upstream_registry.py (10), test_anthropic_credentials.py (23), test_anthropic_proxy.py (66), test_session_manager.py + test_gateway_client.py (236), and the session-create/proxy slice of test_gateway.py (25).

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_session now fail closed on an unknown upstream.
  • Proxy error messages and logs are made upstream-aware.
  • KNOWN_UPSTREAMS constant deduplicates the upstream name list.
  • HOME=/tmp added to the LiteLLM Deployment; await-egg-deploy.sh scopes 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

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 #1make 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_credentials now rejects an unknown upstream
    with a deterministic 502 via is_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 with None Anthropic credential.
  • #3 SessionManager.register_session validates upstream against
    the registry and raises ValueError. Test
    test_register_with_unknown_upstream_raises covers it; the lazy
    from upstream_registry import ... correctly avoids a circular import.
  • #4 HOME=/tmp added to the litellm Deployment, removing the
    concrete readOnlyRootFilesystem startup-write failure mode. Live
    /health/readiness + /health/liveliness verification 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 TypeError remains in
    the five touched test files. TestSessionCreateUpstreamValidation
    patches get_launcher_secret directly, 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_UPSTREAMS constant replaces the three duplicated tuples;
    cq-9 row added to the HITL table ("six" matches); per-agent-models.md
    references de-linked to plain text; docstring # noqa removed.

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

  1. register_session validates upstream but not upstream_model.
    The session-create HTTP route validates upstream_model (type,
    non-empty, ≤256 chars — gateway.py:8682-8688), but the direct
    register_session path (session_manager.py:580) stores it
    unchecked. The same defense-in-depth argument that justified adding
    upstream validation here (a slice-2 spawner calling register_session
    directly) applies equally to upstream_model. Low impact in slice 1
    since it's just a stored string, but worth a guard or a slice-2 note.

  2. Stale docstring in test_unknown_upstream_on_session_returns_5xx.
    The docstring describes a 500-via-dual-UnknownUpstreamError-identity
    path. Now that _inject_upstream_credentials rejects unknown upstreams
    first via the boolean is_known() check, the route returns a
    deterministic 502 and the except UnknownUpstreamError in 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.

  3. test_anthropic_default_omitted_from_persistence docstring 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

@james-in-a-box

This comment has been minimized.

@james-in-a-box

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.
@james-in-a-box

Copy link
Copy Markdown
Contributor Author

Review feedback addressed — commit 020569d

Thanks for the re-review. All three non-blocking notes are fixed in this PR; no deferrals.

1. register_session validates upstream but not upstream_modelfixed-in-PR (commit 020569d)
SessionManager.register_session now applies the same upstream_model checks the /api/v1/sessions/create route does (type is str, non-empty, ≤256 chars), raising ValueError on a violation. This closes the defense-in-depth gap you flagged: a slice-2 spawner calling register_session directly can no longer store a malformed model name that would only surface later in the body-rewrite path. The docstring Raises: section is updated to cover it, and two regression tests were added — test_register_with_empty_upstream_model_raises and test_register_with_oversized_upstream_model_raises.

2. Stale docstring in test_unknown_upstream_on_session_returns_5xxfixed-in-PR (commit 020569d)
Rewrote the docstring to describe the current code path: _inject_upstream_credentials runs the UpstreamRegistry.is_known() 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 docstring no longer references the obsolete dual-import 500 scenario. The assertion still ranges over 5xx — the fail-closed contract, not the exact code, is what the test guards.

3. test_anthropic_default_omitted_from_persistence docstring claims "xfail-strict-false"fixed-in-PR (commit 020569d)
Reworded the docstring — the test is a lenient conditional-assert test (accepts an omitted field or one present at its default; fails only on an unexpected non-default value), not an xfail. The inaccurate "Marked xfail-strict-false" sentence is removed.

Verification

ruff check + ruff format --check clean on all three changed files. Test suites green: test_session_manager.py (134) and test_anthropic_proxy.py (66) — 200 passed, including the two new register_session validation tests.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.pyregister_session now validates
    upstream_model (type, non-empty, ≤256 chars), mirroring the
    /api/v1/sessions/create route.
  • 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

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 #1register_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 #3test_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::TestSessionManagerRegisterUpstream6 passed, including the two new validation tests.
  • test_anthropic_default_omitted_from_persistence + test_unknown_upstream_on_session_returns_5xx2 passed.
  • ruff check clean on session_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

@james-in-a-box

This comment has been minimized.

@jwbron
jwbron changed the base branch from egg/issue-2769/work to main May 22, 2026 16:18
@james-in-a-box

This comment has been minimized.

james-in-a-box Bot and others added 2 commits May 22, 2026 09:20
#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
@jwbron
jwbron merged commit 8f0c980 into main May 22, 2026
19 checks passed

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_model threaded through spawn/restart), not a prompt-level instruction. _rewrite_upstream_model runs in the gateway proxy path.
  • Direct LLM API calls / Agent SDK bypass. None. Agents still spawn via build_consensus_wrapped_commandegg_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: EGG201 sits on a docstring example, which is appropriate.
  • cq-5 mitigation. Handing Claude Code --model opus on 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

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_modelsPipelineConfig validator → _run_concurrent_phase
    ConcurrentPhaseExecutor._spawn_agentresolve_agent_modelspawn_fn/spawn_agent_job
    register_sessionSession.upstream/upstream_modelproxy_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_phase per
    _run_pipeline's docstring, confirmed.
  • build_consensus_wrapped_command already accepts model: str = "opus", so the new
    model=decision.claude_code_alias call is byte-identical on the default path.
  • restart_agent_container is an alias of restart_agent_job (kubernetes_spawner.py:1753),
    which the PR extended with upstream/upstream_model — no signature mismatch.
  • Content-Length: content-length is in ANTHROPIC_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.py slice-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

@james-in-a-box

Copy link
Copy Markdown
Contributor Author

egg review completed. View run logs

16 previous review(s) hidden.

jwbron added a commit that referenced this pull request May 22, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant