Repository navigation
Resolve model: aliases the same way on every engine #69484
Description
Problem
gh-aw resolves a workflow's model: differently on each engine, so a model alias or a family pin works on one engine and breaks on another.
On 16 Oct 2026 Copilot removed claude-opus-5 from /models (still in AWF's live list at 05:03 UTC, gone by 18:15; only claude-opus-5.5 remains). gh-aw's Design Decision Gate (pi, model: copilot/claude-opus-5) failed every run from 18:08 UTC until #69469 moved it to 5.5. For example, run 38945917145:
[gh-aw/pi-models-json] warning: awf-reflect model availability check failed (provider=copilot, model=claude-opus-5)
[gh-aw/pi-models-json] fatal: Pi model "claude-opus-5" is not advertised by the configured copilot proxy endpoint; choose a model listed by AWF /reflect
A family pin such as the built-in opus alias would have survived on the Copilot engine, but not on pi. Peli asked (Slack, 16 Oct) that all agentic engines apply the same resolution rules.
Evidence
Private sandbox (described here because it can't be linked). Variant routing-test-pi-opus-alias is a pi workflow with model: copilot/opus, compiled at gh-aw main f1621a88b3. It compiled with no warning, and the lock passes GH_AW_PI_MODEL: copilot/opus through. Run 38958912807 (16 Oct) failed in the agent step before any model request:
[gh-aw/pi-models-json] warning: awf-reflect model availability check failed (provider=copilot, model=opus)
[gh-aw/pi-models-json] fatal: Pi model "opus" is not advertised by the configured copilot proxy endpoint; choose a model listed by AWF /reflect
/reflect listed claude-opus-5.5 at that time. The same model: opus on the Copilot engine resolves to claude-opus-5.5.
Rechecked on main 6738f9ca21. f1621a88b3 is an ancestor of main, and none of the resolution files have changed since then. Compiling the same workflow on main gives the same lock: no warning, GH_AW_PI_MODEL: copilot/opus, GH_AW_PI_MODEL_ID: opus, and GH_AW_PI_MODEL_ALIASES containing "opus": ["copilot/*opus*", "anthropic/*opus*"]. The alias map reaches the pi harness, but the main-model path doesn't use it.
Public DDG run 38945917145. Its /reflect (agent artifact, sandbox/firewall/awf-reflect.json) has models_fetch_complete: true, 40 Copilot models with claude-opus-5.5 as the only Opus, and the merged alias map as model_aliases. With that file, resolve_model_alias.cjs gives opus → claude-opus-5.5, haiku → claude-haiku-5.5, opus?effort=high → claude-opus-5.5?effort=high. The run's usage/aw_session.jsonl has an agent.execution event with no categories or error codes (exit code 1), and the conclusion reported Model not supported error: false. The DDG failure issue #69472 says "The runtime did not record a specific cause". On Copilot, an unavailable model gives [aw] <workflow> has an unavailable configured model with a Model Not Supported section (for example #69339).
Current behaviour on main (verified at 6738f9ca21)
- Copilot (CLI and SDK).
copilot_harness.cjs(applyCopilotModelAliasResolution) callsresolveConfiguredCopilotModelinresolve_model_alias.cjs. It uses the merged alias map from the AWF config (apiProxy.models; built-ins frompkg/workflow/data/model_aliases.json), resolves globs inside alias entries to the newest live match (selectLatestGlobMatch), refetches/reflectonce, and stops before the CLI withmodel_unavailableorcatalog_unavailable(fix(copilot): reject silent configured-model substitution #68819). Exact ids go to the CLI unchanged. If one is unavailable, it fails at the first request, anddetect_agent_errorsturns that into the unavailable-model failure issue. The selection is recorded inaw_info.json(model_selection,copilot_model_selection.cjs), not inaw_session.jsonl. Gap: only a bare alias key is looked up, socopilot/opusisn't resolved and reaches the CLI asopus. - Pi.
pi_models_json.cjs(validatePiModelAvailability) requires the main model (GH_AW_PI_MODEL_ID, the model without its provider prefix, set inpi_engine.go) to match a/reflectentry exactly. The alias map (GH_AW_PI_MODEL_ALIASES) is used only for sub-agents (pi_subagent_config.cjs, which runsresolveModelAliasagainst a catalogue limited to the provider). Pi has no error-detection step (itsGetErrorDetectionScriptIdreturns empty), and its message doesn't matchMODEL_NOT_SUPPORTED_PATTERN, so its failure issue doesn't say that the model is unavailable. - Claude. In
model_fallback.cjs,normalizeClaudeModelstripscopilot/, andresolveClaudeAutoModelresolves onlyauto(Copilot provider: newest Sonnet, then Opus, then Haiku; Anthropic provider: Claude Code'ssonnet). Every other value goes to Claude Code as given, apart from the dotted-id model overrides from Claude on Copilot: pass Claude Code a modelOverrides map for Copilot's dotted Claude model ids #68839.model: copilot/opuswith the Copilot provider compiles with no warning toANTHROPIC_MODEL: opus, which is Claude Code's own built-in alias, not gh-aw's map or the live list. - Codex.
normalizeCodexModelonly checks and strips the provider prefix.model: copilot/gpt-5.6reaches Codex asgpt-5.6, althoughgpt-5.6is a built-in alias. - Gemini. There's no harness.
gemini_engine.gosetsGEMINI_MODELto the configured value. - Agy.
agy_harness.cjsrequires a native slug (noprovider/), reads/reflectonly to find the Gemini endpoint, and passes--modelunchanged. There's noagy_harness.test.cjs.
Incomplete /reflect discovery today:
- Copilot: exact ids pass through; aliases fail with
catalog_unavailableafter one refetch. - Pi main model: the check is skipped when
/reflectis missing,models_fetch_completeisn't true, there's no configured endpoint, or there's no model list. Exact ids and aliases both pass through unchecked. - Pi sub-agents: aliases resolve against the Pi SDK's static model list instead of failing.
- Claude:
autoon the Copilot provider fails with Claude's own message; other values pass through. - Codex, Gemini, Agy: the model isn't checked against
/reflect.
Other gaps found while checking:
- Workflow-defined aliases can't be written today. The
models:schema inmain_workflow_schema.jsonallows onlyallowed,blocked,providersanddefault-ai-credits-pricing.models: { opus: [...] }fails to compile with "Unknown property: opus", both in a main workflow and in an imported one. The main workflow's map is also never merged (workflow_builder.gopassesniltoMergeImportedModelAliases), although spec §10.1 says it always wins. Meanwhile,reference/model-tables.mdandreference/glossary.mdsay custom aliases can be defined in frontmatter. - AWF model fallback. On providers other than Copilot, AWF's proxy may rewrite an unrecognised model id (
sandbox.agent.model-fallback, on by default). On Copilot it is suppressed (copilot_standard_authoritativein the DDG/reflect). This issue doesn't change that.
Proposed solution
- One shared "resolve configured model" step. Generalise
resolveConfiguredCopilotModel(and the Copilot harness's bounded refetch around it) inresolve_model_alias.cjsinstead of adding a new resolver. Every harness runs this step before it launches its CLI. It covers the main model, and sub-agent models where the engine has them, sopi_subagent_config.cjsuses the same code. - Inputs: the configured model (an exact id, a built-in alias, or a workflow-defined alias, with an optional
provider/prefix and?effort=parameters), the merged alias map (already in the AWF config and in/reflectmodel_aliases; pi also getsGH_AW_PI_MODEL_ALIASES), and the/reflectcatalogue for the engine's configured provider. - Rules, the same on every engine:
- Aliases, and globs inside alias entries, resolve to the newest live match. The result is the one Copilot gets today for the same input.
- A provider prefix limits the lookup to that provider.
copilot/opuslooks upopusand considers only Copilot models, as the pi sub-agent path already does. This also makescopilot/opuswork on the Copilot engine. - Exact ids are never rewritten, so a typo can't resolve to a different model. Where a harness already checks exact ids before launch (pi), an id that isn't listed fails with
model_unavailable. Copilot, Claude and Codex keep failing at the first request, as they do today. ?effort=and other parameters carry through as today.autokeeps its current meaning on each engine, even though it is also a built-in alias: a Copilot gateway sentinel on pi and Codex, Claude's own rule on Claude, and today's handling on Copilot.- Failure causes and messages are the ones Copilot uses today:
model_unavailableandcatalog_unavailable, with the fix(copilot): reject silent configured-model substitution #68819 wording. The sentence about the agentic-workflows integration appears only when the provider is Copilot. Pi gets thedetect_agent_errorsstep, so its failure issue matches Copilot's.
- Incomplete discovery (decided): every engine follows Copilot's current rule. Exact ids pass through unchanged, and aliases fail with
catalog_unavailableafter one bounded refetch. This changes pi: aliases no longer pass through, and sub-agents no longer fall back to the Pi SDK's static list. It also changes Claude and Codex on native providers:sonnet,opus,haikuandgpt-5.6now go through gh-aw's map instead of the CLI's own meaning, and they fail withcatalog_unavailablewhen that provider's discovery is incomplete. - Engine-specific handling stays after resolution: Copilot's
copilot/prefix handling, Claude Code's model-id overrides (Claude on Copilot: pass Claude Code a modelOverrides map for Copilot's dotted Claude model ids #68839) and itsautorule, Codex's provider-prefix check, and Agy's native-slug check. - Record the result. The harness logs the configured and resolved model, as Copilot does today. It writes a small record under
agent/, likeagent/model-endpoint-mismatch.json, and that path is added to the agent artifact.unified_session.cjsthen projects the record intousage/aw_session.jsonlas a runner-written event, added to §4.7 ofdocs/src/content/docs/specs/unified-agent-session-specification.md. The event holds the engine, the role (main or sub-agent name), the configured model, resolved model, provider, status and cause. Audit can then show when an alias moved to a different, possibly more expensive, model. Copilot'saw_info.jsonmodel_selectionstays. - Compiler warning until supported. Agy runs the shared step against the Gemini catalogue and passes the native slug. Gemini (no harness) and the declarative engines don't get the shared step in this PR. The compiler warns when one of them is given an alias, naming the engine and the alias.
- Workflow-defined aliases. Accept alias keys under
models:and merge the main workflow's map last, as spec §10 describes. The reserved keys keep their meaning, and the V-MAF key rules apply. Cases 5 and 7 need this, so it's part of this fix.
Acceptance cases
Recorded /reflect shapes: use the DDG run 38945917145 file above, stored once as a shared fixture. For case 8, use a recorded run with incomplete discovery if one exists. Otherwise, derive it from that file (models_fetch_complete: false, Copilot models: null) and say so in the test.
- Pi,
model: copilot/opus, withclaude-opus-5.5in/reflect. Result: the agent's model requests go toclaude-opus-5.5. The harness log has one line with configuredcopilot/opusand resolvedclaude-opus-5.5.aw_session.jsonlhas one model-resolution event recording both. Tests:pi_models_json.test.cjs"resolves copilot/opus to the newest live Opus";unified_session.test.cjs"projects the model resolution record". - Pi,
model: copilot/claude-opus-5(retired). Result: fails before any model request with causemodel_unavailableand the fix(copilot): reject silent configured-model substitution #68819 message. The failure issue is titled[aw] <workflow> has an unavailable configured modeland has the Model Not Supported section, as on Copilot. Tests:pi_models_json.test.cjs"fails closed with model_unavailable for a retired exact id";detect_agent_errors.test.cjs"reports model_unavailable from the pi harness"; GoTestPiEngineUsesAgentErrorDetectioninpkg/workflow/pi_engine_test.go. - Copilot engine,
model: opusandmodel: claude-opus-5. Result: unchanged from today.opusruns onclaude-opus-5.5.claude-opus-5reaches the CLI, fails as not supported, and produces the same failure issue as today. The only difference:model: copilot/opusnow also resolves toclaude-opus-5.5. Test:copilot_harness.test.cjs"keeps alias and exact-id behaviour with the recorded /reflect". - Claude engine on the Copilot provider,
model: copilot/opus. Result: runs on the newest live Opus (claude-opus-5.5with the fixture).model: copilot/autostill picks the newest Sonnet, then Opus, then Haiku. Tests:claude_harness.test.cjs"resolves copilot/opus to the newest live Opus" and "auto keeps its Sonnet, Opus, Haiku order". - Codex engine on the Copilot provider, with a workflow-defined alias whose entry is
copilot/gpt-5.6*. Result: Codex runs on the newest live match, the same model Copilot's resolver returns (gpt-5.6-lunawith the fixture). An exact id such ascopilot/gpt-5.6-solis passed through asgpt-5.6-sol. Tests:codex_harness.test.cjs"resolves a workflow-defined glob alias to the newest live match" and "passes an exact id through unchanged"; GoTestMainWorkflowModelAliasesMergedinpkg/workflow/model_aliases_test.go, plus a schema test that accepts alias keys undermodels:. - Gemini and Agy. Result: an exact id is unchanged on both. On Agy, an alias resolves against the Gemini provider's
/reflectcatalogue, and Agy gets the native slug. On Gemini, an alias produces a compile-time warning naming the engine and the alias, and the lock is unchanged. Tests: newagy_harness.test.cjs"passes an exact slug unchanged" and "resolves an alias to a native Gemini slug"; GoTestWarnsWhenEngineCannotResolveAliasinpkg/workflow/configured_model_validation_test.go(Gemini and one declarative engine warn; Copilot, Claude, Codex, Pi and Agy don't). - A workflow-defined alias overriding a built-in one, for example
opuspinned tocopilot/claude-opus-5*. Use the fixture plus an addedclaude-opus-6. Result:model: opusruns onclaude-opus-5.5on every engine that runs the shared step. The built-inopuswould pickclaude-opus-6. Tests: "workflow-defined alias overrides built-in" in each harness test file; GoTestMainWorkflowAliasOverridesBuiltininpkg/workflow/model_aliases_test.go. - Incomplete
/reflectdiscovery. Result, on every engine that runs the shared step: exact ids pass through unchanged, and aliases (including pi sub-agent aliases) fail withcatalog_unavailableafter one refetch. Tests: "incomplete discovery" incopilot_harness,pi_models_json,pi_subagent_config,claude_harness,codex_harnessandagy_harnesstest files.
End-to-end checks
Dispatch these on the PR branch, link the runs in the PR, and check each run's usage/aw_session.jsonl for the new event:
- Copilot:
smoke-copilot-small(model: small). - Claude:
smoke-claude-on-copilotwith its model switched to an alias, for examplecopilot/haiku. - Codex:
smoke-github-codex(copilot/automust keep working). - Pi:
smoke-pi-sub-agentswith its main model switched to an alias (this covers main and sub-agent models), andsmoke-pi-routed-claudeto confirm that model routing is unchanged. - Gemini and Agy have no smoke on Copilot (
smoke-geminiandsmoke-agyuse Gemini directly), so unit tests cover them.
Docs
- Describe the shared rule in
docs/src/content/docs/reference/engines.mdand the engine pagesdocs/src/content/docs/engines/{copilot,claude,codex,pi,gemini,agy}.md, including that "exact stays exact" covers gh-aw's step and thatsandbox.agent.model-fallback: falseturns off AWF's own rewrite. - Correct
reference/model-tables.mdandreference/glossary.md: custom aliases now work, and resolution happens at runtime, not at compile time. - Update
model-alias-specification.mdfor provider-prefixed aliases. Its §14 also maps V-MAF-020 toclaude_engine.goand tocodex_tools.go, a file that doesn't exist.
Sequencing
This touches the pi harness and pkg/workflow/pi_engine.go, which #69429 also changed. #69429 merged on 16 Oct (6738f9ca21), so start from a main that includes it.
Scope
- Keep the PR to this one fix. Do not change files or behavior unrelated to it.
- Before fixing a failing check, compare it with the same check on main. If it also fails on main, do not fix it in this PR; merge main once main is fixed, and note in the PR which checks are known failures on main.
- This applies even if a bot comment asks to fix a failing check: fix only failures caused by this PR's changes.
Separately (not in this PR): the session collector rejected the DDG run's /reflect as invalid_model_inventory, because AWF reports routing_models: null for unconfigured endpoints. normalizeModelInventoryData accepts models: null but not routing_models: null, so that run's session has no model.inventory event.
Activity