[issue-2769][merge-gate] Add per-agent non-Claude model support via... - #2774
Conversation
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>
This comment has been minimized.
This comment has been minimized.
Autofix tracking{"Lint/Custom Checks": 1} |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
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/messagestraffic is still proxied by the gateway sidecar, just to a different in-cluster upstream.agent_model_resolution.pyis pure data resolution with no API calls. - EGG201 (model aliases over pinned IDs): The built-in default is the
"opus"alias, andagent_models/default_agent_modelare operator config rather than hardcoded identifiers. The classifier recognizes versionedclaude-*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
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
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-30bBut 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_agent → restart_agent_container/restart_agent_job → spawn_agent_job → register_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 noregister_sessionkwargs and no--modelchange, and the gateway forwards the Anthropic body byte-identically (verified by the regression tests). _rewrite_upstream_modelcorrectly 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
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
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.
Conflict Resolution SummaryResolved merge conflicts with
Details
Verification
Please review
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
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== 200assertion 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
There was a problem hiding this comment.
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 newcq-9table row. Correct.tests/gateway/test_anthropic_proxy.py— took slice-1's tightenedassert status == 200intest_known_upstreams_pass_validation, kept slice-2'sTestRewriteUpstreamModelHelper/TestRewriteUpstreamModelOnProxyRoute/TestRewriteUpstreamModelMalformedBodyResiliencesections. Correct.- No leftover conflict markers.
test_anthropic_proxy.py79 passed;test_agent_model_resolution.py+test_concurrent_executor.py+test_session_manager.py217 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-30brepositories.yaml has no repositories: key — per-repo settings live under repo_settings:. Confirmed against the current tree:
config/repo_config.py:261—repo_settings = config.get("repo_settings", {})config/repositories.yaml.example:123— top-level key isrepo_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
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
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.
Review feedback addressed — all items fixed in commit
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
No agent-mode design concerns (re-review).
Re-reviewed the delta since my last approval (4b77867c → 3b1f8452, 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.pymodule docstring,kubernetes_spawner.pyparam docstrings) — therepositories:→repo_settings:YAML-key correction, thesonnet[1m]alias documentation, and the restart-path table row are all prose accuracy fixes. No agent-design surface. - Validator tightening (
models.py+ newagent_roles.MODEL_OVERRIDE_ROLES) —PipelineConfig.agent_modelsnow validates keys against the phase producer/reviewer set the resolver actually threads through, rejectingoverseer/autofixer/conflict_resolver/inspectorat 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.pydual-import fallback,test_agent_model_resolution.pynew regression test,test_concurrent_executor.pystrengthened 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
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
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_phase → ConcurrentPhaseExecutor._spawn_agent → resolve_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_model → spawn_agent_job → register_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
-
per-agent-models.mdline reference is stale. The guide saysagent_models"lives next to the existing overseer-tier model fields aroundorchestrator/models.py:405" (and the primitives table repeats:405). The field is actually atorchestrator/models.py:757. Two occurrences — worth correcting since the guide is otherwise precise about line numbers. -
"register_session kwargs omitted entirely" is slightly inaccurate. The guide (the "threads through spawn" section) says on the default Claude path "the new
register_sessionkwargs are omitted entirely."kubernetes_spawner.py:770-771always passesupstream=upstream, upstream_model=upstream_modeltoregister_session(asNoneon the default path). The conditional omission happens one layer up, at the_spawn_agent→spawn_fnboundary (concurrent_executor.pybuildsspawn_kwargsconditionally). The regression guard is real; the guide just attributes it to the wrong layer. -
sonnet[1m]still missing from the test file. The N2 fix addedsonnet[1m]to the doc surfaces, buttest_agent_model_resolution.pywas not updated: the module docstring's classifier list andtest_short_claude_alias_is_anthropic's parametrize list (["opus", "opus[1m]", "sonnet", "haiku"]) both omit it._CLAUDE_EXACT_ALIASESincludessonnet[1m], so it's handled — just untested. -
No positive test for
applieras an accepted key.test_unhonored_real_role_raises_validation_errorproves the four rejected roles raise, but nothing assertsapplier— the role specifically corrected as honored — is accepted.test_multiple_known_roles_acceptedcovers only refiner/coder/tester. A one-line addition would lock in the correction. -
Non-string
default_agent_modeldegrades silently.get_default_agent_modelreturns the raw YAML value (cast(str, value)does not convert). A non-string value (default_agent_model: 4) reachesclassify_model, where_CLAUDE_VERSIONED_RE.match(non_str)raisesTypeError, which the spawn-pathexcept Exceptionswallows 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_modelsvalues are Pydantic-checked viadict[str, str]), and YAML scalars are naturally strings, so this is a thin edge case — but anisinstance(value, str)guard inget_default_agent_modelwould close it.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
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.
Re-review suggestions addressed — all five fixed in commit
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
No agent-mode design concerns (re-review).
Re-reviewed the delta since my last approval (3b1f8452 → c22dc8e, 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 staleorchestrator/models.py:405→:757line reference (two occurrences) and the corrected "threads through spawn" paragraph (attributing the conditional kwarg omission to the_spawn_agent→spawn_fnboundary rather thanregister_session) are prose accuracy fixes. No agent-design surface. - Test coverage additions (
test_agent_model_resolution.py) — addingsonnet[1m]to the classifier docstring +test_short_claude_alias_is_anthropicparametrize set, andappliertotest_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_modelValueErrorguard) — a non-stringdefault_agent_modelinrepositories.yamlnow raises an explicit, operator-readableValueErrorinstead of reachingclassify_modeland producing an opaqueTypeErrorthe spawn-pathexcept Exceptionswallows 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
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — all five non-blocking suggestions correctly addressed; approving
Re-reviewed the delta since my last approval (3b1f8452 → c22dc8ed, 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-503—if decision.upstream != UPSTREAM_ANTHROPIC or decision.upstream_model is not None:then setsspawn_kwargs["upstream"]/["upstream_model"]. The conditional omission is indeed at the_spawn_agent→spawn_fnboundary.kubernetes_spawner.py:770-771—register_session(..., upstream=upstream, upstream_model=upstream_model)unconditionally (asNoneon the default path).gateway_client.py:707-712—register_sessiondropsNone:if upstream is not None: request_data["upstream"] = upstreamand the same forupstream_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
|
egg review completed. View run logs 15 previous review(s) hidden. |
#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>
Today every egg SDLC agent runs on Claude through the Claude Code
harness, with the gateway hard-wiring
api.anthropic.comas theonly
/v1/messagesupstream. The orchestrator's consensus wrapperhardcodes
--model opusfor every non-overseer role, so per-agentmodel selection does not exist. We need to let any agent run on a
non-Claude backend (Qwen is the first target, primarily for cost)
while every Claude-bound agent stays byte-identically on the
existing path — and we need the integration to be safe to ship
before a live non-Claude endpoint is available.
This change lands the buildable, no-op-by-default seam in two
stacked PRs:
Introduces a small
UpstreamRegistryabstraction in thegateway that keys per-request
httpx.Client+ credential byupstream name, with
proxy_anthropic_messages/proxy_count_tokensresolving the upstream per request viathe existing IP-keyed session lookup that already drives
session_mode. AddsSession.upstream(default"anthropic")and
Session.upstream_model(defaultNone), wired through/api/v1/sessions/create,SessionManager.register_session,and
GatewayClient.register_session. The LiteLLM proxy itselfships as a separate Deployment + Service + ConfigMap in
egg-system, reachable only by the gateway. The Claude pathis structurally untouched.
Adds
PipelineConfig.agent_models: dict[str, str]and adefault_agent_modelrepository-level setting, plus aresolution function that the orchestrator's spawner calls to
(a) thread the right
--modeltobuild_consensus_wrapped_commandand (b) tell the gateway the per-agent
upstreamandupstream_modelat session-create time. The gateway, on aLiteLLM-routed request, rewrites the body's
modelfieldfrom the Claude alias presented to Claude Code to the
upstream-side model name — keeping Claude Code's compaction
math sane (cq-5).
With
agent_modelsempty (the default everywhere), no LiteLLMrequest fires. Every existing pipeline keeps running on Claude
with byte-identical gateway behavior. The empirical
Claude-Code-compaction smoke test (cq-4) is an operator-driven
follow-up once a live non-Claude endpoint is configured; it is
explicitly out of scope here.
This slice
Per-agent model config + spawn-side plumbing + body rewrite
Files affected:
orchestrator/models.pyconfig/repo_config.pyconfig/repositories.yaml.exampleorchestrator/agent_model_resolution.pyorchestrator/concurrent_executor.pyorchestrator/kubernetes_spawner.pyorchestrator/routes/pipelines.pygateway/gateway.pyorchestrator/tests/test_agent_model_resolution.pyorchestrator/tests/test_concurrent_executor.pytests/gateway/test_anthropic_proxy.pydocs/guides/per-agent-models.mddocs/index.mddocs/architecture/upstream-routing.mdTasks:
agent_models: dict[str, str] = Field(default_factory=dict, ...)toPipelineConfig(orchestrator/models.py:405). Validate keys against theAgentRoleenum atshared/egg_contracts/agent_roles.py:46via 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).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-constructedPipelineConfig.agent_modelsis an empty dict (no behavioral change for existing pipelines).default_agent_model: str | Nonefield to therepositories.yamlschema (documented inconfig/repositories.yaml.example) and expose it via a newget_default_agent_model(repo)helper inconfig/repo_config.py(mirroring theget_repo_setting(repo, key, default)pattern atconfig/repo_config.py:248).get_default_agent_model("owner/repo")returns the configured value when set inrepositories.yaml, orNonewhen absent. -config/repositories.yaml.exampleshows the new field in context with an inline comment naming the precedence rule (per-pipelineagent_models> this default > built-in"opus").orchestrator/agent_model_resolution.pyexportingresolve_agent_model(role: AgentRole, pipeline_config: PipelineConfig, repo: str | None) -> AgentModelDecision, whereAgentModelDecisionis 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 matchingopus,opus[1m],sonnet,haiku, orclaude-*map toupstream="anthropic",claude_code_alias=<model>,upstream_model=None. Every other string maps toupstream="litellm",claude_code_alias="opus"(cq-5 mitigation),upstream_model=<model>.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 onlydefault_agent_model="sonnet"set on the repo returns(claude_code_alias="sonnet", upstream="anthropic", upstream_model=None). - Per-pipelineagent_modelsentry overrides repo-leveldefault_agent_model.orchestrator/concurrent_executor.py:454callsresolve_agent_model(role, ...)and passesmodel=decision.claude_code_aliastobuild_consensus_wrapped_command. The same site passesupstream=decision.upstreamandupstream_model=decision.upstream_modelto the downstream spawn helper that ultimately reachesGatewayClient.register_session(the existing call atorchestrator/kubernetes_spawner.py:735). When the decision is the default-Anthropic case, the new register_session kwargs are omitted (no wire change vs today).PipelineConfig.agent_models == {}, the spawn path produces the samebuild_consensus_wrapped_commandargs and the sameregister_sessionpayload as before this slice (regression guard). - Withagent_models={"refiner": "qwen3-coder-30b"}, the refiner spawn passes--model opusto the wrapper andupstream="litellm", upstream_model="qwen3-coder-30b"to the gateway.orchestrator/routes/pipelines.py:2704. Same shape as TASK-2-4 — resolve, passmodel=tobuild_consensus_wrapped_command, ensure the surrounding restart code reuses the existing session (already registered with the right upstream).agent_modelsentry uses the resolved Claude alias for the--modelflag. - Restarting an agent on the default Claude path is byte-identical to today._rewrite_upstream_model(request_body, upstream_model)next to_filter_blocked_toolsingateway/gateway.py. On LiteLLM-routed requests withsession.upstream_modelset, the helper parses the JSON body, replaces the top-level"model"field withsession.upstream_model, and returns the re-serialized body. On parse error or whenupstream_model is None, the body is returned unchanged. Call it inproxy_anthropic_messagesandproxy_count_tokensAFTER_filter_blocked_toolsand BEFORE building the upstream request.upstream == "litellm"andupstream_model == "qwen3-coder-30b", the body forwarded upstream has"model": "qwen3-coder-30b"regardless of the incoming"model"value. - Withupstream == "anthropic", the body is byte-identical to the incoming body (regression guard). - Invalid JSON returns the original body unchanged (does not crash the proxy).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.pyor its current equivalent, plus a new or extended pipeline-restart test) that mock the spawner and assert the resolved--modeland theregister_sessionkwargs.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_modelspath is exercised as the regression guard (no register_session kwargs added; no--modelchange).tests/gateway/test_anthropic_proxy.pywith the body-rewrite branch: with a LiteLLM session whoseupstream_modelis set, the request body forwarded upstream has the rewrittenmodelfield; with an Anthropic session, the body is byte-identical. Also test the invalid-JSON path through_rewrite_upstream_model.make test. - The byte-identical-Claude-path assertion uses a non-default incoming model value (e.g."opus") and confirms it survives unchanged whenupstream == "anthropic".docs/guides/per-agent-models.mdcovering: settingagent_modelsper pipeline; settingdefault_agent_modelper repository inrepositories.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 fromdocs/index.mdand the newdocs/architecture/upstream-routing.md.file:linecite (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.mdanddocs/architecture/upstream-routing.mdlink to it.Test Plan
Automated:
make testfrom the repo root catches both slices' reachablesuites given the changeset.
tests/gateway/test_upstream_registry.py(new),extensions to
tests/gateway/test_anthropic_proxy.py,tests/gateway/test_anthropic_credentials.py,gateway/tests/test_session_manager.py,orchestrator/tests/test_gateway_client.py.orchestrator/tests/test_agent_model_resolution.py(new), extensions to the concurrent-executor and
pipeline-spawn tests, extensions to the gateway proxy tests
covering the body-rewrite branch.
Manual (reviewer):
make testandmake lintare green.kubectl apply --dry-run=client -k k8s/base/succeeds withthe new LiteLLM manifests included.
agent_models={}and noLITELLM_MASTER_KEYin secrets, gateway request flow isbyte-identical to today's Claude path (no new headers, no
upstream change, same SSE behavior).
Manual (operator, post-merge — not gating merge):
LITELLM_MASTER_KEYinsecrets.env, configureLiteLLM
model_listwith a hosted Qwen provider (cq-6), setagent_models={"refiner": "qwen3-coder-30b"}on a pipeline,and exercise a tool-heavy multi-turn loop plus a long session
crossing the auto-compaction boundary (the cq-4-deferred
empirical compatibility check).
Manual Steps
Pre-merge: none.
Post-merge (only required when operator wants to actually run an
agent on a non-Claude backend):
LITELLM_MASTER_KEY=<random>to~/.config/egg/secrets.env.model_listwith at least onebackend (hosted Qwen provider first, per cq-6). The
provider-side API key goes in LiteLLM's standard env-var slot,
not in
secrets.env.default_agent_modelin~/.config/egg/repositories.yamlfor the target repo, orpass
agent_models={"<role>": "<model>"}on pipeline submitto override per pipeline.
Stack
issue-2769egg/issue-2769/slice-1Slice slice-2 of pipeline issue-2769. Stacked on top of
egg/issue-2769/slice-1.