Skip to content

Support AWF task-level model routing in the Copilot engine #64402

Closed

Description

@lpcox

Summary

AWF's task-level model routing (experimental.modelRouting + apiProxy.routing) is live and tested end to end in AWF, but gh-aw can't use it yet. Two things are missing:

  1. Compiler: gh-aw only validates the experimental.modelRouting gate (pkg/workflow/awf_config_test.go, TestValidateAWFConfigJSON_ModelRoutingGate). It never emits apiProxy.routing.
  2. Copilot harness: actions/setup/js/copilot_harness.cjs picks the model, wire API, and (lack of) reasoning effort from the compile-time COPILOT_MODEL. Under routing, AWF's API proxy enforces the router's choice, so the agent is rejected.

Tracking on the AWF side: github/gh-aw-firewall#9238 (step 4). Evidence: github/gh-aw-firewall#9249.

How AWF routing works (agent-facing contract)

  • Before the agent container starts, AWF classifies the task description in the routing conversation file and records one selection: provider, model, optional effort, and endpoint.
  • The API proxy then pins every inference request to that selection:
    • body.model must equal wire_model.
    • An effort selection must go to /responses with reasoning.effort === effort.
    • An effortless selection must go to /chat/completions with no reasoning / reasoning_effort.
    • Anthropic selections go to /v1/messages with output_config.effort.
  • Anything else gets 403 {"error":{"code":"model_routing_mismatch"}}, is recorded as a terminal routing failure, and AWF exits 78.
  • The agent learns the selection from GET /reflect (AWF ≥ v0.28.29). It is available as soon as the agent starts:
"routing": {
  "status": "selected",
  "selection": {
    "provider": "copilot",
    "model": "github-copilot/gpt-5.4-mini",
    "wire_model": "gpt-5.4-mini",
    "effort": "xhigh",
    "endpoint": "/responses"
  }
}

routing is null when routing is inactive; status may also be pending or failed (with failure_code).

Reproduction

github/gh-aw-firewall#9249 runs Copilot CLI 1.0.89 (DefaultCopilotVersion) inside AWF with routing enabled and allowedModels: [github-copilot/claude-haiku-4.5, github-copilot/gpt-5.4-mini]. The router picks gpt-5.4-mini / xhigh / /responses.

  • --model gpt-5.4-mini --reasoning-effort xhigh alone: the CLI sends to /chat/completions, because COPILOT_PROVIDER_WIRE_API is unset. It gets 403; the CLI prints Authentication failed with provider at http://172.30.0.30:10002 (HTTP 403) and AWF exits 78 (run 36668364575).
  • Adding COPILOT_PROVIDER_WIRE_API=responses, taken from the selection's endpoint: ROUTED_OK, exit 0 (run 36669199288).

So model, effort, and wire API all have to come from the selection.

Proposed fix

0. Bump the default firewall to v0.28.29

pkg/constants/version_constants.go pins DefaultFirewallVersion = "v0.28.27". Bump it to v0.28.29, which is the first release that has everything the harness needs:

Also:

  • Add a routing min-version constant (e.g. AWFModelRoutingMinVersion = "v0.28.29"), and refuse to emit apiProxy.routing for workflows pinned below it.
  • Recompile the lock files after the bump.
  • The digest-pinned container.images manifest emitted for routed runs must use the v0.28.29 digests: agent sha256:edcf17ae…, api-proxy sha256:5cc683af…, squid sha256:1d5e169c…, plus a pinned router image.

1. Compiler: emit routing config

  • Add a frontmatter surface. The shape is open to discussion; for example:
    engine:
      id: copilot
      model-routing:
        goal: cost          # cost | cost-speed
        mode: balanced      # economy | balanced | robust | auto
        allowed-models: [claude-haiku-4.5, gpt-5.4-mini]
  • When it is set, emit into the AWF config:
    • experimental.modelRouting: true
    • apiProxy.routing: { objective: { goal, mode }, task: { conversationFile } }
    • apiProxy.allowedModels (provider-qualified, e.g. github-copilot/gpt-5.4-mini)
    • Optionally apiProxy.routing.provider
  • Add a host-side pre-agent step that writes the routing conversation file, e.g. /tmp/gh-aw/routing-conversation.json: [{"role":"user","parts":[{"text":"<task prompt>"}]}], built from the rendered workflow prompt. Its user messages are the task description the router classifies; the resulting labels are the classification. The step must run on the host, outside the agent.
  • A routed run requires a complete, digest-pinned container.images manifest that includes router. AWF rejects tag-only router images.
  • Routing is incompatible with --keep-containers, DinD / split filesystems, Docker-socket exposure, and non-Linux/non-runc hosts. The compiler should reject those combinations early, or let AWF's validation surface them.
  • Gate on AWFModelRoutingMinVersion (see step 0).
  • Don't emit a fixed COPILOT_MODEL for routed workflows, or treat it as ignored (see below).

2. Copilot harness: honour the selection

The harness already fetches /reflect (AWF_REFLECT_ENABLED=1 → fetchAWFReflect). When reflectData.routing is non-null:

  1. Require status === "selected". On failed/pending, exit non-zero without starting Copilot (AWF will report exit 78 either way).
  2. Model: set COPILOT_MODEL = selection.wire_model. Skip applyModelFallback and applyCopilotModelAliasResolution: rewriting the model produces a mismatch.
  3. Wire API: set COPILOT_PROVIDER_WIRE_API = selection.endpoint === "/responses" ? "responses" : "completions". This must override any existing value; today applyCopilotWireAPI skips when the variable is already set, which is exactly the wrong precedence under routing.
  4. Effort: when selection.effort is non-null, pass --reasoning-effort <effort> to the CLI. In SDK mode, set the session's reasoning effort. When it is null, pass no effort.
  5. Sub-agents / task tool: the headless sidecar propagates COPILOT_MODEL to sub-agent sessions. They must use the same model, effort and wire API; any other model (e.g. a cheaper sub-agent model) is rejected with 403 and fails the run.
  6. Logging: log the applied selection (inference routing: mode=awf-routed model=… effort=… wire_api=…), without auth values.

Threat detection and other separate AWF invocations without apiProxy.routing are unaffected.

3. Failure classification

AWF exit 78 under routing means a routing failure (no route, router error, or model_routing_mismatch). Classify it separately from generic agent failures and from HTTP 403 budget/permission handling, so it isn't retried (it's terminal) and the report names the cause.

Acceptance

  • DefaultFirewallVersion is bumped to v0.28.29, AWFModelRoutingMinVersion is added, and the lock files are recompiled.
  • A workflow with model-routing compiles to an AWF config containing experimental.modelRouting, apiProxy.routing, allowedModels, a digest-pinned router image, and a conversation-file step.
  • The harness applies model, wire API and effort from /reflect routing.selection, overriding compile-time values.
  • A routed Copilot run completes with AWF exit 0. A deliberately mismatched model yields exit 78 and is classified as a routing failure.
  • Unit tests for the harness selection logic; a compile test for the emitted config.

Activity

  1. lpcox commented on Sep 30, 2026

    @lpcox
    CollaboratorAuthor

    Update (2026-09-30): AWF v0.28.30 is now released: https://github.com/github/gh-aw-firewall/releases/tag/v0.28.30

    This supersedes the v0.28.29 target in section 0 as the current routing baseline. v0.28.29 introduced the /reflect routing selection consumed by the harness; v0.28.30 adds broader routing smoke coverage: effort-routed Copilot, a two-candidate routing decision, live OpenAI and Anthropic routing, and running Copilot CLI against the routed selection (gh-aw-firewall#9243, #9245, #9247, #9249). The gh-aw implementation should pin/test against v0.28.30, while retaining the routing minimum-version gate as appropriate.

  2. lpcox commented on Sep 30, 2026

    @lpcox
    CollaboratorAuthor

    Additional compatibility blocker: inline ## agent: sub-agents can declare their own model: (commonly small; model: inherited is only the default). In a routed run, AWF admits exactly the selected wire_model/endpoint/effort tuple. If Copilot starts an inline sub-agent with its declared alternate model, that request will receive 403 model_routing_mismatch; AWF records a terminal routing failure and the overall run exits 78, even if the parent could otherwise handle the task-tool failure.

    The routed implementation therefore must cover generated inline-agent definitions, not only the parent harness environment:

    • For routing-enabled workflows, force every sub-agent session to inherit the AWF selection (model, wire API, and effort), overriding/ignoring inline model: aliases such as small.
    • Preferably emit a compile warning explaining that per-agent model overrides are disabled under task-level routing; alternatively reject the combination at compile time. Silently allowing the override is unsafe because it fails only when the sub-agent is invoked.
    • Do not resolve small/large aliases before applying the routing selection.
    • Add an end-to-end acceptance case where a routed workflow invokes an inline agent declared with model: small; verify the sub-agent actually uses routing.selection.wire_model and the run exits 0. Also keep a deliberate non-selected-model request test that exits 78.

    This affects the existing sub-agent acceptance item: propagating COPILOT_MODEL to the headless sidecar is insufficient if the .agent.md frontmatter still wins when the sub-agent session is created.

  3. lpcox commented on Sep 30, 2026

    @lpcox
    CollaboratorAuthor

    Correction to my previous comment: forcing all inline agents to inherit the parent selection would avoid the 403, but it would regress a core sub-agent capability. The harness should remain able to create sub-agents with different models.

    The underlying mismatch is that AWF currently makes one task-wide selection and admits only one (provider, wire_model, endpoint, effort) tuple. Supporting gh-aw sub-agents properly requires session/agent-scoped routing, not merely propagating the parent environment.

    Suggested contract:

    • Route the parent and each sub-agent independently from that agent’s bounded task/description and declared model constraint (small, large, explicit model, or inherited).
    • Return a selection per execution scope, e.g. parent plus agents.<name> or through a child-session selection API.
    • Bind each selection to an opaque route/scope ID that the Copilot harness/headless sidecar attaches to that sub-agent’s requests; the API proxy validates the request against the tuple for that scope.
    • model: inherited reuses the parent scope. model: small/large becomes a routing constraint, not a concrete client-selected model. An explicit model may be treated as a singleton constraint, subject to policy.
    • Do not simply admit every model in allowedModels globally: that would let any session bypass the router’s decision. Admission must be scoped to the parent or named child session.
    • If v0.28.30 only supports one task-wide selection, gh-aw should initially reject routing + non-inherited inline agents with a clear compile error rather than silently overriding them or allowing runtime exit 78.

    Acceptance should include a parent routed to one model and an inline model: small agent routed to a different model, with both requests accepted only in their respective scopes. Cross-using either selection must produce 403 model_routing_mismatch.

  4. lpcox commented on Sep 30, 2026

    @lpcox
    CollaboratorAuthor

    Correcting my two previous comments on sub-agents. Both assumed the API proxy must keep admitting only the routed tuple. That assumption is being dropped.

    Model routing is configuration — it starts the agent on a model that suits the task — not a containment boundary. Cost and policy already have a routing-independent enforcement surface in AWF (AWF_ALLOWED_MODELS / AWF_DISALLOWED_MODELS). Strict request pinning is therefore being removed on the AWF side: github/gh-aw-firewall#9267.

    What this means here:

    • Ignore the sub-agent blocker. Once the selection is advisory, an inline ## agent: with model: small simply runs on its own model. No 403 model_routing_mismatch, no exit 78.
    • Ignore the scoped-routing proposal. Per-agent selections, scope IDs, and per-scope admission are unnecessary. Sub-agents keep their existing model semantics.
    • No compile-time rejection of routing combined with non-inherited inline agents, and no forced inheritance.
    • Acceptance item 5 in the issue body softens. Propagating the selection to sub-agent sessions is a quality choice, not a correctness requirement.

    The harness work in section 2 still stands, but its failure mode changes. Applying model, wire API, and effort from /reflect routing.selection is what makes routing effective; not applying it now degrades to an unrouted run rather than exit 78. The reproduction in the issue body (/chat/completions → 403 → exit 78) is specific to pinning and will no longer occur.

    Exit 78 remains meaningful for genuine routing failures — no_route, router unreachable, contract errors — so section 3 stands as written.

    Suggest holding the harness changes until gh-aw-firewall#9267 lands, since it determines whether a mismatch is fatal.

  5. lpcox commented on Sep 30, 2026

    @lpcox
    CollaboratorAuthor

    The PR removing strict request pinning has merged into github/gh-aw-firewall on main:

    Summary of merged changes & current state

    1. Selection is advisory, not enforced:

      • The API proxy no longer screens requests against the router's choice. Body checks for model, effort, and endpoint, header rejections, and 403 model_routing_mismatch are completely removed.
      • WebSocket upgrades are no longer rejected during routed runs.
      • Deviation telemetry is emitted per inference request (event: "model_routing", stage: "request", routed: "deviated" | "selected").
    2. Model policy remains the cost/allowlist guard:

      • Cost and model restrictions stay enforced via AWF_ALLOWED_MODELS / AWF_DISALLOWED_MODELS (model-policy-guard.js), not the router.
    3. Sub-agents and alternate models work seamlessly:

      • Inline ## agent: sub-agents with their own model: (e.g. small) or alternate parent model choices run cleanly without 403 or exit 78.
      • Upstream failure tracking only treats errors on the selected model as fatal exit-78 triggers; an alternate sub-agent model's upstream error or policy rejection does not trigger exit 78.
    4. Next steps for gh-aw:

      • Once a new AWF release containing Add comprehensive tests for idiomatic Bubble Tea spinner implementation #9272 is cut (or testing against main), gh-aw can proceed with the compiler and harness integration without any sub-agent model restrictions, forced inheritance, or scope-ID plumbing.
      • The harness should still seed COPILOT_MODEL, COPILOT_PROVIDER_WIRE_API, and reasoning effort from /reflect routing.selection to make the routing suggestion effective.
  6. assigned and unassigned on Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions