Skip to content

[issue-2769][merge-gate] Add per-agent non-Claude model support via... - #2774

Merged
jwbron merged 12 commits into
egg/issue-2769/slice-1from
egg/issue-2769/slice-2
May 22, 2026
Merged

[issue-2769][merge-gate] Add per-agent non-Claude model support via...#2774
jwbron merged 12 commits into
egg/issue-2769/slice-1from
egg/issue-2769/slice-2

Conversation

@james-in-a-box

Copy link
Copy Markdown
Contributor

Program-level umbrella PR — terminal slice of pipeline issue-2769.
Roll-up of the slice-PR chain; this PR is the merge gate for the program. Pre-merge obligations (when present) live here.

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

Per-agent model config + spawn-side plumbing + body rewrite

Files affected:

  • orchestrator/models.py
  • config/repo_config.py
  • config/repositories.yaml.example
  • orchestrator/agent_model_resolution.py
  • orchestrator/concurrent_executor.py
  • orchestrator/kubernetes_spawner.py
  • orchestrator/routes/pipelines.py
  • gateway/gateway.py
  • orchestrator/tests/test_agent_model_resolution.py
  • orchestrator/tests/test_concurrent_executor.py
  • tests/gateway/test_anthropic_proxy.py
  • docs/guides/per-agent-models.md
  • docs/index.md
  • docs/architecture/upstream-routing.md

Tasks:

  • task-2-1: Add agent_models: dict[str, str] = Field(default_factory=dict, ...) to PipelineConfig (orchestrator/models.py:405). Validate keys against the AgentRole enum at shared/egg_contracts/agent_roles.py:46 via a Pydantic validator: unknown roles raise a typed config error at construction time. Values are free-form strings (validated downstream by the resolver in TASK-2-3).
    • Acceptance criteria: - PipelineConfig(agent_models={"refiner": "qwen3-coder-30b"}) constructs successfully. - PipelineConfig(agent_models={"bogus_role": "x"}) raises a Pydantic validation error citing the unknown role. - Default-constructed PipelineConfig.agent_models is an empty dict (no behavioral change for existing pipelines).
  • task-2-2: Add a default_agent_model: str | None field to the repositories.yaml schema (documented in config/repositories.yaml.example) and expose it via a new get_default_agent_model(repo) helper in config/repo_config.py (mirroring the get_repo_setting(repo, key, default) pattern at config/repo_config.py:248).
    • Acceptance criteria: - get_default_agent_model("owner/repo") returns the configured value when set in repositories.yaml, or None when absent. - config/repositories.yaml.example shows the new field in context with an inline comment naming the precedence rule (per-pipeline agent_models > this default > built-in "opus").
  • task-2-3: New module orchestrator/agent_model_resolution.py exporting resolve_agent_model(role: AgentRole, pipeline_config: PipelineConfig, repo: str | None) -> AgentModelDecision, where AgentModelDecision is a small dataclass with fields (claude_code_alias: str, upstream: str, upstream_model: str | None). Precedence: pipeline_config.agent_models.get(role.value)get_default_agent_model(repo) → built-in "opus". Classifier: model strings matching opus, opus[1m], sonnet, haiku, or claude-* map to upstream="anthropic", claude_code_alias=<model>, upstream_model=None. Every other string maps to upstream="litellm", claude_code_alias="opus" (cq-5 mitigation), upstream_model=<model>.
    • Acceptance criteria: - resolve_agent_model(AgentRole.CODER, default_config, None) returns (claude_code_alias="opus", upstream="anthropic", upstream_model=None). - resolve_agent_model(AgentRole.REFINER, PipelineConfig(agent_models={"refiner": "qwen3-coder-30b"}), None) returns (claude_code_alias="opus", upstream="litellm", upstream_model="qwen3-coder-30b"). - resolve_agent_model(...) with only default_agent_model="sonnet" set on the repo returns (claude_code_alias="sonnet", upstream="anthropic", upstream_model=None). - Per-pipeline agent_models entry overrides repo-level default_agent_model.
  • task-2-4: Thread the resolved decision through the initial spawn path: orchestrator/concurrent_executor.py:454 calls resolve_agent_model(role, ...) and passes model=decision.claude_code_alias to build_consensus_wrapped_command. The same site passes upstream=decision.upstream and upstream_model=decision.upstream_model to the downstream spawn helper that ultimately reaches GatewayClient.register_session (the existing call at orchestrator/kubernetes_spawner.py:735). When the decision is the default-Anthropic case, the new register_session kwargs are omitted (no wire change vs today).
    • Acceptance criteria: - With PipelineConfig.agent_models == {}, the spawn path produces the same build_consensus_wrapped_command args and the same register_session payload as before this slice (regression guard). - With agent_models={"refiner": "qwen3-coder-30b"}, the refiner spawn passes --model opus to the wrapper and upstream="litellm", upstream_model="qwen3-coder-30b" to the gateway.
  • task-2-5: Thread the resolved decision through the restart path at orchestrator/routes/pipelines.py:2704. Same shape as TASK-2-4 — resolve, pass model= to build_consensus_wrapped_command, ensure the surrounding restart code reuses the existing session (already registered with the right upstream).
    • Acceptance criteria: - Restarting an agent whose pipeline has a non-default agent_models entry uses the resolved Claude alias for the --model flag. - Restarting an agent on the default Claude path is byte-identical to today.
  • task-2-6: Add _rewrite_upstream_model(request_body, upstream_model) next to _filter_blocked_tools in gateway/gateway.py. On LiteLLM-routed requests with session.upstream_model set, the helper parses the JSON body, replaces the top-level "model" field with session.upstream_model, and returns the re-serialized body. On parse error or when upstream_model is None, the body is returned unchanged. Call it in proxy_anthropic_messages and proxy_count_tokens AFTER _filter_blocked_tools and BEFORE building the upstream request.
    • Acceptance criteria: - With upstream == "litellm" and upstream_model == "qwen3-coder-30b", the body forwarded upstream has "model": "qwen3-coder-30b" regardless of the incoming "model" value. - With upstream == "anthropic", the body is byte-identical to the incoming body (regression guard). - Invalid JSON returns the original body unchanged (does not crash the proxy).
  • task-2-7: Unit tests for the resolver and the spawn-side wiring: orchestrator/tests/test_agent_model_resolution.py (new) covering precedence + classifier; extensions to the existing concurrent-executor and restart-path test modules (orchestrator/tests/test_concurrent_executor.py or its current equivalent, plus a new or extended pipeline-restart test) that mock the spawner and assert the resolved --model and the register_session kwargs.
    • Acceptance criteria: - All new and extended tests pass under make test. - Tests assert the cq-5 mitigation explicitly: the Claude-Code-facing alias for a LiteLLM-routed agent is always "opus", never the upstream model name. - Default-agent_models path is exercised as the regression guard (no register_session kwargs added; no --model change).
  • task-2-8: Extend tests/gateway/test_anthropic_proxy.py with the body-rewrite branch: with a LiteLLM session whose upstream_model is set, the request body forwarded upstream has the rewritten model field; with an Anthropic session, the body is byte-identical. Also test the invalid-JSON path through _rewrite_upstream_model.
    • Acceptance criteria: - Tests pass under make test. - The byte-identical-Claude-path assertion uses a non-default incoming model value (e.g. "opus") and confirms it survives unchanged when upstream == "anthropic".
  • task-2-9: Write a how-to doc docs/guides/per-agent-models.md covering: setting agent_models per pipeline; setting default_agent_model per repository in repositories.yaml; the precedence rule; the cq-5 recognised-alias presented-to-Claude-Code mitigation; the operator smoke test (live LiteLLM endpoint, the cq-4-deferred validation). Cross-link from docs/index.md and the new docs/architecture/upstream-routing.md.
    • Acceptance criteria: - The guide names every primitive added in slice 2 with a file:line cite (resolver, config field, repo helper, body-rewrite helper). - It walks an operator through enabling Qwen for the refiner role end-to-end without modifying source code. - docs/index.md and docs/architecture/upstream-routing.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: merge-gate (slice 2 of 2) in pipeline issue-2769
  • Stacked on top of egg/issue-2769/slice-1

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

egg and others added 8 commits May 22, 2026 06:49
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>
…s 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>
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.
…) + 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.
…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

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

This comment has been minimized.

@james-in-a-box

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

@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 PR is upstream-routing infrastructure: a pure model-resolution function, a gateway request-body rewrite, a PipelineConfig field, and spawn/restart plumbing. It touches no agent prompts, so the five agent-mode guidelines (pre-fetching, human-facing output formats, post-processing pipelines, rigid procedures, prompt-level security) don't apply here.

Spot-checks against the enforced architectural conventions, all clean:

  • EGG200 (no direct LLM API calls outside the sandbox): The LiteLLM path keeps the gateway-as-proxy boundary intact — the agent's /v1/messages traffic is still proxied by the gateway sidecar, just to a different in-cluster upstream. agent_model_resolution.py is pure data resolution with no API calls.
  • EGG201 (model aliases over pinned IDs): The built-in default is the "opus" alias, and agent_models/default_agent_model are operator config rather than hardcoded identifiers. The classifier recognizes versioned claude-* IDs as valid operator input — appropriate flexibility, not a hardcoded use — and the lone docstring example carries a correct # noqa: EGG201.
  • cq-5 mitigation: Presenting the "opus" alias to Claude Code for LiteLLM-routed agents preserves the harness's compaction calibration. This supports the agent rather than constraining it — not an anti-pattern.

Design is aligned.

— 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.

Code Review — PR #2774: Per-agent non-Claude model support (slice 2)

Thorough review of the slice-2 changeset. I traced the full data flow (resolver → spawn/restart → register_session → gateway session → proxy_anthropic_messages/proxy_count_tokens body rewrite), read the slice-1 primitives this builds on, and ran the three affected suites locally: 159 passed, 2 skipped (test_agent_model_resolution.py 30, test_concurrent_executor.py 53, test_anthropic_proxy.py 76).

The production wiring is sound — the no-op-by-default invariant holds, the gateway body rewrite is correctly resilient, and the tests genuinely exercise the production code path (the gateway tests drive the real Flask route). But I found two blocking issues, the first of which breaks the guide's primary recommended workflow.


BLOCKING 1 — per-agent-models.md uses the wrong YAML top-level key; the "recommended" repo-default workflow is broken

docs/guides/per-agent-models.md tells operators to put default_agent_model under a repositories: top-level key, in two snippets:

  • Line 74 ("Repository-level default" section)
  • Line 273 — "3a. Per-repository default (recommended for stable rollouts)"
# docs/guides/per-agent-models.md:273  — WRONG
repositories:
  acme-corp/widgets:
    default_agent_model: qwen3-coder-30b

But repositories.yaml has no repositories: key. Per-repo settings live under repo_settings: — see config/repositories.yaml.example:123 and get_repo_setting itself:

# config/repo_config.py:260-261
config = _load_config()
repo_settings = config.get("repo_settings", {})

An operator who copy-pastes the guide's 3a block writes config under repositories:. _load_config() parses it, config.get("repo_settings", {}) returns {}, get_repo_setting returns the default (None), get_default_agent_model returns None, and the repo-level default is silently dropped — every agent stays on Claude with no error and no log line. This is a silent no-op on deliberately-set operator config, and it sinks the workflow the guide explicitly labels "recommended for stable rollouts."

Fix: change repositories:repo_settings: in both snippets (lines 74 and 273). Note config/repositories.yaml.example itself is correct, so the example file and the guide currently disagree.


BLOCKING 2 — agent_models validator accepts role keys the feature never honors

PipelineConfig._validate_agent_models_roles (orchestrator/models.py:772-792) validates keys against the entire AgentRole enum:

valid = {role.value for role in AgentRole}

That enum (shared/egg_contracts/agent_roles.py:46-94) includes overseer, autofixer, conflict_resolver, and inspector. But resolve_agent_model is threaded through only two spawn sites — concurrent_executor._spawn_agent and routes/pipelines.py:restart_agent — which cover the phase + reviewer roles. The overseer spawns via the separate spawn_overseer_job; autofixer/conflict_resolver/inspector spawn on-demand. None of those consult the resolver.

So PipelineConfig(agent_models={"overseer": "qwen3-coder-30b"}) constructs successfully, and the override is then silently dropped — no error, no warning, no effect. This directly defeats the validator's own stated purpose:

"Catches typos at PipelineConfig construction time so misconfigured overrides surface immediately instead of silently being ignored at spawn time."

The guide reinforces the false expectation: it says the feature flips "any single SDLC agent role (refiner, coder, tester, …)" and "Each agent role independently resolves."

Fix (pick one): restrict the validator's accepted set to the roles actually threaded through the resolver (phase + reviewer roles); or keep the full-enum validation but emit a loud warning at spawn when an agent_models entry names a role that never reaches resolve_agent_model, and document the honored-role set in the guide. A validated-but-ignored key is exactly the silent-ignore trap the validator was added to prevent.


Non-blocking

N1 — Doc contradicts code: restart does re-register the session. The "How the resolved decision threads through spawn" table (per-agent-models.md:125) says the restart path uses the "Same session … no second register_session call". That is false: restart_agentrestart_agent_container/restart_agent_jobspawn_agent_jobregister_session registers a new session. The PR's own kubernetes_spawner.py comment confirms it ("the gateway session is otherwise rebuilt against the anthropic default"). The code is correct; the doc row is wrong — update it.

N2 — sonnet[1m] is undocumented. _CLAUDE_EXACT_ALIASES (agent_model_resolution.py) contains "sonnet[1m]", but every doc surface lists only opus, opus[1m], sonnet, haiku, claude-* — the module docstring, classify_model's context, PipelineConfig.agent_models' field description, get_default_agent_model's docstring, repositories.yaml.example, and per-agent-models.md's classifier table. Even _is_claude_alias's own docstring lists it while the module docstring doesn't. Code accepts one more alias than it documents — add sonnet[1m] to the doc surfaces (or drop it from the set).

N3 — Restart fallback import skips the dual-import pattern. The resolver-failure fallback at routes/pipelines.py:2697 does a bare from agent_model_resolution import UPSTREAM_ANTHROPIC, classify_model, without the except ImportError → from ..agent_model_resolution fallback used by the primary import 20 lines above (2671-2680). In the (narrow) topology where agent_model_resolution is importable only via the relative path, a resolver failure would convert into an uncaught ImportError → HTTP 500 on restart. Mirror the dual-import for consistency, or reuse the already-bound symbols.

N4 — restart_agent_job docstring is stale. kubernetes_spawner.py:1243 adds upstream/upstream_model parameters but the Args section stops at wait_for_gateway. Add the two new entries.

N5 — Weak assertion. test_default_config_passes_opus_to_consensus_wrapper captures kwargs.get("model", "opus") with a default of "opus", so it cannot distinguish "model='opus' was passed" from "model was not passed at all." Use kwargs.get("model") (as the sibling override tests do) so the regression guard is real.


What's good

  • The no-op-by-default path is genuinely preserved: default agent_models={} adds no register_session kwargs and no --model change, and the gateway forwards the Anthropic body byte-identically (verified by the regression tests).
  • _rewrite_upstream_model correctly mirrors _filter_blocked_tools' bytes-in/bytes-out, parse-tolerant contract, runs after tool filtering on both proxy routes, and applies to streaming and non-streaming paths.
  • Tests exercise the real production code path rather than hand-built fixtures — the gateway tests drive the actual Flask route with a faked upstream registry.

Please address the two blocking items (the YAML key fix is a one-word change in two places; the validator gap needs a small decision on restrict-vs-warn). Happy to re-review.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

…lve conflicts in upstream-routing.md and test_anthropic_proxy.py

- docs/architecture/upstream-routing.md: kept slice-2 text — this PR
  ships docs/guides/per-agent-models.md, so the working link is correct.
- tests/gateway/test_anthropic_proxy.py: took slice-1's tightened
  test_known_upstreams_pass_validation assertion (== 200, matching the
  docstring); kept all of slice-2's new body-rewrite test section.
@james-in-a-box

Copy link
Copy Markdown
Contributor Author

Conflict Resolution Summary

Resolved merge conflicts with egg/issue-2769/slice-1 (merge commit, no rebase):

File Category Resolution
docs/architecture/upstream-routing.md Semantic (doc) Kept slice-2's text on both conflicting blocks
tests/gateway/test_anthropic_proxy.py Semantic + additive Took slice-1's tightened assertion; kept all of slice-2's new test section

Details

docs/architecture/upstream-routing.md (2 conflict blocks) — Both sides describe what slice 2 adds. Slice-1's text said the operator guide "does not exist until slice 2 lands" and referenced it as a plain path. This PR is slice 2 and ships docs/guides/per-agent-models.md, so slice-2's text (with the working markdown link) is correct post-merge. Kept HEAD on both blocks.

tests/gateway/test_anthropic_proxy.py (1 conflict block) — Two independent changes overlapped:

  • Both sides modified the final assertion of test_known_upstreams_pass_validation. Slice-1's review feedback tightened it from assert status != 400 or ... to assert status == 200, which matches that test's docstring ("reach a clean 200"). Slice-2's branch carried the older, weaker form only because it branched off an earlier slice-1. Took slice-1's == 200 assertion — it is the deliberately-tightened, newer version, and slice-2 has no stake in this particular check.
  • Slice-2 additionally appended a large new body-rewrite test section (TestRewriteUpstreamModelHelper, TestRewriteUpstreamModelOnProxyRoute, TestRewriteUpstreamModelMalformedBodyResilience) after that assertion. Kept all of it verbatim. The trailing ) shared by both sides now closes slice-2's malformed-JSON != 500 assertion.

Verification

  • tests/gateway/test_anthropic_proxy.py — 79 passed
  • Full gateway + slice-2 suites (tests/gateway/, test_agent_model_resolution.py, test_gateway_client.py, test_session_manager.py) — 442 passed
  • orchestrator/tests/test_concurrent_executor.py — 53 passed
  • ruff check + ruff format --check on both resolved files — clean

Please review

  • The test_known_upstreams_pass_validation assertion: I chose slice-1's stricter == 200 over slice-2's weaker != 400 or .... This is the only behavioral pick in the resolution — confirm the strict form is intended (it matches the test docstring).

— 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.

No agent-mode design concerns (re-review).

Delta since my last review (66723b73): a single merge commit (4b77867) integrating egg/issue-2769/slice-1. The only PR-authored content in that merge is conflict resolution in two files:

  • docs/architecture/upstream-routing.md — markdown prose; kept slice-2's text (working link to the operator guide this PR ships). No agent-design surface.
  • tests/gateway/test_anthropic_proxy.py — test code; took slice-1's tightened == 200 assertion and kept slice-2's body-rewrite test section. Test fixtures reference model names (opus, qwen3-coder-30b, claude-3-5-sonnet-…) as assertion data, not production model selection — not an EGG201 concern.

The slice-2 source code I assessed previously (the pure resolve_agent_model resolver, the gateway _rewrite_upstream_model body rewrite, the PipelineConfig.agent_models field, and the spawn/restart plumbing) is unchanged by this delta. The gateway-as-proxy boundary still holds and the no-op-by-default path is preserved. My prior approval stands.

The two request-changes blockers on this PR are from the general code-review bot (YAML key, validator scope) — those are correctness/doc-accuracy issues outside the agent-design scope and not re-litigated here.

— Authored by egg

@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 #2774 (slice 2): Per-agent non-Claude model support

Re-reviewed at 4b77867c. The only PR-authored change since my last review (66723b73) is the merge commit 4b77867c, which merged egg/issue-2769/slice-1 into the branch and resolved conflicts in two files.

Conflict resolution — correct

I verified both resolutions against each parent and ran the affected suites:

  • docs/architecture/upstream-routing.md — kept slice-2's text on both conflicting blocks (working markdown link to the guide this PR ships) and preserved slice-1's new cq-9 table row. Correct.
  • tests/gateway/test_anthropic_proxy.py — took slice-1's tightened assert status == 200 in test_known_upstreams_pass_validation, kept slice-2's TestRewriteUpstreamModelHelper / TestRewriteUpstreamModelOnProxyRoute / TestRewriteUpstreamModelMalformedBodyResilience sections. Correct.
  • No leftover conflict markers. test_anthropic_proxy.py 79 passed; test_agent_model_resolution.py + test_concurrent_executor.py + test_session_manager.py 217 passed.

Both blocking issues from the last review remain unaddressed

The merge commit does not touch docs/guides/per-agent-models.md or orchestrator/models.py, so neither blocking item was fixed. I re-verified both against the current tree (4b77867c):


BLOCKING 1 — per-agent-models.md still uses the wrong YAML top-level key

docs/guides/per-agent-models.md still tells operators to nest default_agent_model under a repositories: key, in both snippets:

  • "Repository-level default" section (~line 74)
  • "3a. Per-repository default (recommended for stable rollouts)" (~line 272)
# per-agent-models.md ~line 272 — WRONG
repositories:
  acme-corp/widgets:
    default_agent_model: qwen3-coder-30b

repositories.yaml has no repositories: key — per-repo settings live under repo_settings:. Confirmed against the current tree:

  • config/repo_config.py:261repo_settings = config.get("repo_settings", {})
  • config/repositories.yaml.example:123 — top-level key is repo_settings:

An operator who copy-pastes the guide's 3a block writes config under repositories:, get_repo_setting returns the default (None), get_default_agent_model returns None, and the repo-level default is silently dropped — every agent stays on Claude with no error. This is a silent no-op on deliberately-set operator config, and it sinks the workflow the guide explicitly labels "recommended for stable rollouts." The example file and the guide currently contradict each other.

Fix: repositories:repo_settings: in both snippets.


BLOCKING 2 — agent_models validator still accepts role keys the feature never honors

PipelineConfig._validate_agent_models_roles (orchestrator/models.py:772-792) still validates keys against the entire AgentRole enum:

valid = {role.value for role in AgentRole}

That enum (shared/egg_contracts/agent_roles.py) includes overseer, autofixer, conflict_resolver, inspector, and applier. resolve_agent_model is threaded only through concurrent_executor._spawn_agent and routes/pipelines.py:restart_agent (phase + reviewer roles). The overseer/autofixer/conflict_resolver/inspector/applier spawn paths never consult the resolver.

So PipelineConfig(agent_models={"overseer": "qwen3-coder-30b"}) constructs successfully and the override is then silently dropped at spawn — defeating the validator's own stated purpose ("misconfigured overrides surface immediately instead of silently being ignored at spawn time"). A validated-but-ignored key is exactly the silent-ignore trap the validator was added to prevent.

Fix (pick one): restrict the validator's accepted set to the roles actually threaded through the resolver; or keep full-enum validation but emit a loud warning at spawn when an agent_models entry names a role the resolver never reaches, and document the honored-role set in the guide.


Non-blocking items from the last review — also untouched

N1 (restart-path doc row says no second register_session — it does re-register), N2 (sonnet[1m] undocumented), N3 (restart fallback import skips the dual-import pattern), N4 (stale restart_agent_job docstring), N5 (weak kwargs.get("model", "opus") assertion) all remain. None block merge; fold them in with the blocking fixes.


The conflict resolution in this delta is sound. But the two blocking issues stand — the YAML key is a one-word change in two places; the validator gap needs the restrict-vs-warn decision. Happy to re-review once both land.

— Authored by egg

@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.

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

Copy link
Copy Markdown
Contributor Author

Review feedback addressed — all items fixed in commit 3b1f845

Thanks for the thorough re-review. Every item is fixed in-PR; per-item disposition below.

Blocking

BLOCKING 1 — wrong YAML top-level keyfixed-in-PR (commit 3b1f845)
Both snippets in per-agent-models.md (the "Repository-level default" section and "3a. Per-repository default") now nest default_agent_model under repo_settings:, matching config/repositories.yaml.example and get_repo_setting's config.get("repo_settings", {}).

BLOCKING 2 — validator accepts unhonored role keysfixed-in-PR (commit 3b1f845)
Took the restrict-the-validator option. Added agent_roles.MODEL_OVERRIDE_ROLES — a frozenset derived from _PHASE_ROLES + _PHASE_REVIEWERS (the roles resolve_agent_model is actually threaded through). PipelineConfig._validate_agent_models_roles now validates keys against that set, so agent_models={"overseer": ...} raises at construction time instead of silently no-op-ing at spawn. The honored set resolves to the 16 phase producer/reviewer roles; the rejected set is exactly autofixer, conflict_resolver, inspector, overseer. New regression test test_unhonored_real_role_raises_validation_error covers all four. Guide and field-description text updated to describe the honored set.

One small correction: applier is honored — it's the producer for the apply phase (_PHASE_ROLES["apply"]), so it reaches the resolver via get_roles_for_phase("apply", …). The re-review listed it among the unhonored roles; the validator correctly keeps it valid.

Non-blocking

N1 — restart-path doc row contradicts codefixed-in-PR (commit 3b1f845)
The "threads through spawn" table row now states the restart respawns the Job and registers a new gateway session (restart_agent_jobspawn_agent_jobregister_session), rather than reusing the session.

N2 — sonnet[1m] undocumentedfixed-in-PR (commit 3b1f845)
Added sonnet[1m] to every classifier doc surface: the agent_model_resolution.py module docstring, PipelineConfig.agent_models field description, get_default_agent_model's docstring, repositories.yaml.example, and the guide's classifier table.

N3 — restart fallback import skips the dual-import patternfixed-in-PR (commit 3b1f845)
The resolver-failure fallback in routes/pipelines.py now uses the same try: from agent_model_resolution … except ImportError: from ..agent_model_resolution … dual-import as the primary import.

N4 — stale restart_agent_job docstringfixed-in-PR (commit 3b1f845)
Added upstream / upstream_model Args entries to restart_agent_job, and to spawn_agent_job as well (the PR added the same params there and left them undocumented too).

N5 — weak assertionfixed-in-PR (commit 3b1f845)
test_default_config_passes_opus_to_consensus_wrapper now captures kwargs.get("model") with no default, so it distinguishes "model='opus' was passed" from "model was not passed at all".

Verification

make lint clean; tests/test_agent_model_resolution.py + tests/test_concurrent_executor.py — 84 passed.

— 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.

No agent-mode design concerns (re-review).

Re-reviewed the delta since my last approval (4b77867c3b1f8452, one commit: "Address review feedback on per-agent model support"). The delta is 10 files, +123/-44 — entirely feedback fixes from the general code-review bot's two blocking items plus the five non-blocking notes. None of it touches agent prompts, the sandbox, or model-selection logic.

Per-change assessment against the agent-mode guidelines:

  • Doc/docstring fixes (per-agent-models.md, repo_config.py, repositories.yaml.example, agent_model_resolution.py module docstring, kubernetes_spawner.py param docstrings) — the repositories:repo_settings: YAML-key correction, the sonnet[1m] alias documentation, and the restart-path table row are all prose accuracy fixes. No agent-design surface.
  • Validator tightening (models.py + new agent_roles.MODEL_OVERRIDE_ROLES) — PipelineConfig.agent_models now validates keys against the phase producer/reviewer set the resolver actually threads through, rejecting overseer/autofixer/conflict_resolver/inspector at construction time. This is code-enforced config validation surfacing a misconfiguration at the boundary — the correct place for a constraint, not a prompt-level one. It restricts which operator-config keys are honored, not any agent's runtime exploration.
  • Import resilience + test improvements (routes/pipelines.py dual-import fallback, test_agent_model_resolution.py new regression test, test_concurrent_executor.py strengthened assertion) — no agent-design surface.

No EGG201 concern in the delta: the alias strings (opus, sonnet, sonnet[1m], haiku, claude-*, qwen3-coder-30b) appear only as docstring/comment documentation of the classifier and as test-fixture data — no hardcoded full Claude model ID is used for selection. The cq-5 mitigation (presenting the "opus" alias to Claude Code for LiteLLM-routed agents to preserve compaction calibration) is unchanged from what I assessed previously and still supports the agent rather than constraining it.

My prior approval 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 — both blocking items fixed; approving with non-blocking nits

Re-reviewed the delta since commit 4b77867. (Note: the PR branch was squashed to a single commit 3b1f8452 and 4b77867 is no longer fetchable through the gateway, so this pass re-verifies the full PR diff against the base with focus on the previously-flagged items.)

Blocking items — both verified fixed

BLOCKING 1 — wrong YAML top-level key — FIXED. Both snippets in per-agent-models.md now nest default_agent_model under repo_settings: — the "Repository-level default" section (line 80) and "3a. Per-repository default" (line 279). Matches config/repositories.yaml.example:68 and get_repo_setting's config.get("repo_settings", {}).

BLOCKING 2 — validator accepts unhonored role keys — FIXED. agent_roles.MODEL_OVERRIDE_ROLES is derived from _PHASE_ROLES.values() + _PHASE_REVIEWERS.values(), and _validate_agent_models_roles now validates against it. I traced the spawn path to confirm the derivation is correct: _run_pipeline routes every phase (including apply) through _run_concurrent_phaseConcurrentPhaseExecutor._spawn_agentresolve_agent_model (routes/pipelines.py:19600 docstring + :21188 dispatch). So every role in those two maps genuinely reaches the resolver, and the four rejected roles (overseer, autofixer, conflict_resolver, inspector) genuinely don't. The author's correction is right — applier is honored (_PHASE_ROLES["apply"]). test_unhonored_real_role_raises_validation_error covers all four rejected roles.

N1–N5 are all addressed (restart-path doc row, sonnet[1m] doc surfaces, restart dual-import, spawn_agent_job/restart_agent_job Args entries, the kwargs.get("model") assertion).

New-code scrutiny

Traced the primary use case (agent_models={"refiner": "qwen3-coder-30b"}) end-to-end: validator accepts → _spawn_agent resolves to (opus, litellm, qwen3-coder-30b)build_consensus_wrapped_command(model="opus") + spawn_kwargs carries upstream/upstream_modelspawn_agent_jobregister_session → gateway proxy_anthropic_messages rewrites the body's model to qwen3-coder-30b. The gateway helper and both call sites are correct (upstream_name/session in scope in both proxy_anthropic_messages and proxy_count_tokens; rewrite guarded by upstream_name != "anthropic"; byte-identical on the Anthropic path). Resolution tests (31) and concurrent-executor tests (53) pass locally; tests exercise the production functions directly (no self-seeding goldens, no path-bypassing fixtures).

Non-blocking suggestions

  1. per-agent-models.md line reference is stale. The guide says agent_models "lives next to the existing overseer-tier model fields around orchestrator/models.py:405" (and the primitives table repeats :405). The field is actually at orchestrator/models.py:757. Two occurrences — worth correcting since the guide is otherwise precise about line numbers.

  2. "register_session kwargs omitted entirely" is slightly inaccurate. The guide (the "threads through spawn" section) says on the default Claude path "the new register_session kwargs are omitted entirely." kubernetes_spawner.py:770-771 always passes upstream=upstream, upstream_model=upstream_model to register_session (as None on the default path). The conditional omission happens one layer up, at the _spawn_agentspawn_fn boundary (concurrent_executor.py builds spawn_kwargs conditionally). The regression guard is real; the guide just attributes it to the wrong layer.

  3. sonnet[1m] still missing from the test file. The N2 fix added sonnet[1m] to the doc surfaces, but test_agent_model_resolution.py was not updated: the module docstring's classifier list and test_short_claude_alias_is_anthropic's parametrize list (["opus", "opus[1m]", "sonnet", "haiku"]) both omit it. _CLAUDE_EXACT_ALIASES includes sonnet[1m], so it's handled — just untested.

  4. No positive test for applier as an accepted key. test_unhonored_real_role_raises_validation_error proves the four rejected roles raise, but nothing asserts applier — the role specifically corrected as honored — is accepted. test_multiple_known_roles_accepted covers only refiner/coder/tester. A one-line addition would lock in the correction.

  5. Non-string default_agent_model degrades silently. get_default_agent_model returns the raw YAML value (cast(str, value) does not convert). A non-string value (default_agent_model: 4) reaches classify_model, where _CLAUDE_VERSIONED_RE.match(non_str) raises TypeError, which the spawn-path except Exception swallows into the opus fallback with only a log line. The realistic misconfigs are handled loudly (bad role key → ValidationError; bad model name → LiteLLM 502 fail-closed; agent_models values are Pydantic-checked via dict[str, str]), and YAML scalars are naturally strings, so this is a thin edge case — but an isinstance(value, str) guard in get_default_agent_model would close it.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

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

Copy link
Copy Markdown
Contributor Author

Re-review suggestions addressed — all five fixed in commit c22dc8e

Thanks for the thorough re-review and for verifying both blocking items. All five non-blocking suggestions are fixed in-PR; per-item disposition below.

1 — stale orchestrator/models.py:405 line referencefixed-in-PR (commit c22dc8e)
per-agent-models.md now references orchestrator/models.py:757 in both places (the prose under "Per-pipeline override" and the primitives table row). The field is at line 757.

2 — "register_session kwargs omitted entirely" is inaccuratefixed-in-PR (commit c22dc8e)
The "threads through spawn" paragraph now attributes the omission to the correct layer: ConcurrentPhaseExecutor._spawn_agent omits the upstream / upstream_model kwargs from its spawn_fn call (concurrent_executor.py:501-503), while spawn_agent_job still passes both to register_session as None on the default path (kubernetes_spawner.py:770-771) and register_session drops None values from the request body (gateway_client.py:707-712). The byte-identical wire shape claim stands; the doc now names the right layer.

3 — sonnet[1m] missing from the test filefixed-in-PR (commit c22dc8e)
Added sonnet[1m] to the test_agent_model_resolution.py module docstring classifier list and to test_short_claude_alias_is_anthropic's parametrize set, so the alias _CLAUDE_EXACT_ALIASES already accepts is now exercised.

4 — no positive test for applier as an accepted keyfixed-in-PR (commit c22dc8e)
test_multiple_known_roles_accepted now includes "applier": "haiku" with an assertion, plus a docstring note explaining applier is the honored apply-phase producer. This locks in the correction that applier is a valid agent_models key.

5 — non-string default_agent_model degrades silentlyfixed-in-PR (commit c22dc8e)
get_default_agent_model now has an isinstance(value, str) guard: a non-string value (e.g. default_agent_model: 4) raises a clear ValueError naming the bad value, instead of reaching classify_model and raising an opaque TypeError from the regex internals that the spawn-path except Exception swallows into the opus fallback. New regression test test_non_string_repo_default_raises_value_error covers it; the docstring gained a Raises: section.

Verification

make lint clean; test_agent_model_resolution.py (33) + test_concurrent_executor.py (53) + tests/config/test_repo_config.py (51) — 137 passed.

— 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.

No agent-mode design concerns (re-review).

Re-reviewed the delta since my last approval (3b1f8452c22dc8e, one commit: "Address re-review suggestions on per-agent model support"). The delta is 3 files, +59/-11 — entirely the five non-blocking nits from the general code-review bot's re-review. None of it touches agent prompts, the sandbox, or model-selection logic.

Per-change assessment against the agent-mode guidelines:

  • Doc accuracy fixes (per-agent-models.md) — the stale orchestrator/models.py:405:757 line reference (two occurrences) and the corrected "threads through spawn" paragraph (attributing the conditional kwarg omission to the _spawn_agentspawn_fn boundary rather than register_session) are prose accuracy fixes. No agent-design surface.
  • Test coverage additions (test_agent_model_resolution.py) — adding sonnet[1m] to the classifier docstring + test_short_claude_alias_is_anthropic parametrize set, and applier to test_multiple_known_roles_accepted, both lock in already-implemented behavior. No agent-design surface. No EGG201 concern: the alias strings appear only as test-fixture data and docstring documentation of the classifier, not as hardcoded selection identifiers.
  • Config-boundary validation (get_default_agent_model ValueError guard) — a non-string default_agent_model in repositories.yaml now raises an explicit, operator-readable ValueError instead of reaching classify_model and producing an opaque TypeError the spawn-path except Exception swallows into the opus fallback. This is code-enforced validation of operator config surfaced loudly at the boundary — the correct place for the constraint. It does not constrain any agent's runtime exploration.

My prior approval 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 — all five non-blocking suggestions correctly addressed; approving

Re-reviewed the delta since my last approval (3b1f8452c22dc8ed, one commit: "Address re-review suggestions on per-agent model support"). The delta is 3 files (+59/-11) and contains only doc/test/guard fixes — no change to the feature's logic. Verified each item against the actual code, not just the diff.

Per-suggestion verification

1 — stale orchestrator/models.py:405:757. Confirmed: agent_models: dict[str, str] = Field(...) is at orchestrator/models.py:757. Both occurrences in per-agent-models.md (the prose and the primitives table) now point to :757. Correct.

2 — "register_session kwargs omitted entirely" rewrite. Every line reference in the rewritten paragraph checks out:

  • concurrent_executor.py:501-503if decision.upstream != UPSTREAM_ANTHROPIC or decision.upstream_model is not None: then sets spawn_kwargs["upstream"] / ["upstream_model"]. The conditional omission is indeed at the _spawn_agentspawn_fn boundary.
  • kubernetes_spawner.py:770-771register_session(..., upstream=upstream, upstream_model=upstream_model) unconditionally (as None on the default path).
  • gateway_client.py:707-712register_session drops None: if upstream is not None: request_data["upstream"] = upstream and the same for upstream_model.
    The doc now attributes the byte-identical wire shape to the correct layer.

3 — sonnet[1m] added to the test module. Confirmed _CLAUDE_EXACT_ALIASES includes "sonnet[1m]" (agent_model_resolution.py:61). It is now in the module-docstring classifier list and test_short_claude_alias_is_anthropic's parametrize set — 33 passed.

4 — positive applier test. Confirmed MODEL_OVERRIDE_ROLES is derived from _PHASE_ROLES.values(), and _PHASE_ROLES["apply"] = [AgentRole.APPLIER], so "applier" is an honored key. test_multiple_known_roles_accepted now asserts {"applier": "haiku"} is accepted, with a docstring explaining why.

5 — non-string default_agent_model guard. get_default_agent_model now raises a clear ValueError naming the bad value before a non-string can reach classify_model. test_non_string_repo_default_raises_value_error exercises the production function directly (patches get_repo_setting to return 4, asserts ValueError match). Tests pass.

Note (not blocking, no action needed): the ValueError is still caught by the spawn-path except Exception at concurrent_executor.py:472-485 (and the analogous restart path), so a misconfigured repo still degrades to the built-in opus decision rather than hard-failing. That degrade-with-log behavior is the deliberate design (the defensive wrap comment notes a resolver crash would "bring down agent spawn for every pipeline"). The fix's real benefit is that the swallowed exception now carries an actionable message in the logger.warning (error=str(resolve_err)) instead of an opaque TypeError from the regex internals — which is exactly the right scope for what suggestion 5 asked for.

Verification

test_agent_model_resolution.py — 33 passed. tests/config/test_repo_config.py — 51 passed. No new issues; the delta does not touch the feature logic, only docs/tests/guard.

My prior approval stands.

— Authored by egg

@james-in-a-box

Copy link
Copy Markdown
Contributor Author

egg review completed. View run logs

15 previous review(s) hidden.

@jwbron
jwbron merged commit 1ff4803 into egg/issue-2769/slice-1 May 22, 2026
31 checks passed
jwbron added a commit that referenced this pull request May 22, 2026
#2773)

* Initialize SDLC contract for issue #2769

* refine(#2769): analysis for non-Claude models via LiteLLM proxy

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>

* Persist agent statefile writes before refine sync

* Persist statefiles after refine phase

* Persist HITL resolution after refine phase gate

* plan(#2769): architecture analysis for non-Claude models via LiteLLM 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>

* plan: decompose #2769 into gateway router + per-agent model config slices

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.

* plan(#2769): risk assessment for LiteLLM proxy integration

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>

* plan(#2769) v2: pin Semantics B for LiteLLM body rewrite, address reviewer_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>

* Persist statefiles after plan phase

* docs(#2769): document UpstreamRegistry seam and LiteLLM topology

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.

* gateway: add UpstreamRegistry seam + LiteLLM topology (no-op default) 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>

* gateway: format Invalid-upstream error message per ruff format

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>

* build: extend k3s-secrets to surface LITELLM_MASTER_KEY as a discrete 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>

* tests(#2769 slice-1): scaffold UpstreamRegistry + session-upstream tests

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>

* tests(#2769 slice-1): finalize tester suite against coder v1 (commit 3ad6069)

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>

* tests(#2769 slice-1): drop unused type:ignore[import-not-found] on upstream_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>

* Fix llm-api-calls lint: suppress EGG200 in upstream_registry

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.

* Fix checks: apply automated formatting fixes

* Address review feedback on #2769 slice-1 gateway upstream router

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).

* Address re-review suggestions on #2769 slice-1 upstream router

- 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.

* [issue-2769][merge-gate] Add per-agent non-Claude model support via... (#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>

---------

Co-authored-by: egg-orchestrator <egg@localhost>
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: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
Co-authored-by: James Wiesebron <jameswiesebron@khanacademy.org>
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