Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/agentic_commands.yml

Large diffs are not rendered by default.

2,464 changes: 2,464 additions & 0 deletions .github/workflows/smoke-claude-routed-anthropic.lock.yml

Large diffs are not rendered by default.

90 changes: 90 additions & 0 deletions .github/workflows/smoke-claude-routed-anthropic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
private: true
emoji: "🧭"
description: Smoke Claude Code model routing on native Anthropic inference with deterministic runner and proxy evidence checks
on:
schedule: every 2 days
workflow_dispatch:
label_command:
names: ["smoke-routing", "smoke"]
events: [pull_request]
strategy: decentralized
bots: [Copilot]
status-comment: false
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
permissions:
contents: read
name: Smoke Claude Routed Anthropic
concurrency:
group: "smoke-claude-routed-anthropic-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }}"
cancel-in-progress: true
job-discriminator: ${{ github.run_id }}
strict: true
engine:
id: claude
bare: true
model-routing:
goal: cost
mode: economy
# Native Anthropic routing keeps ANTHROPIC_API_KEY inference. claude-sonnet-5
# has maintained AWF routing metadata (Messages, low..max effort); Haiku has
# none on the native path, so it is not a routing candidate here.
allowed-models: [claude-sonnet-5]
imports:
- shared/smoke-test-brevity.md
- shared/reporting.md
tools:
bash:
- "echo:*"
safe-outputs:
create-issue:
expires: 2h
group: true
close-older-issues: true
close-older-key: "smoke-claude-routed-anthropic"
labels: [automation, testing]
timeout-minutes: 10
features:
gh-aw-detection: false
sandbox:
agent:
id: awf
post-steps:
- name: Assert model-routing evidence
if: always()
uses: actions/github-script@v9.0.0
env:
SMOKE_EXECUTION: ${{ steps.agentic_execution.outcome }}
with:
script: |
const path = require("node:path");
const { main } = require(path.join(process.env.RUNNER_TEMP, "gh-aw/actions/smoke_model_routing_assertions.cjs"));
await main({
core,
engine: "claude",
executionOutcome: process.env.SMOKE_EXECUTION,
allowedModels: ["claude-sonnet-5"],
mainEndpoint: "/v1/messages",
routingMode: "economy",
nativeProvider: "anthropic",
expectRequestedEffortMatchesSelection: true,
expectModelInventory: true,
resolutionRoles: [],
});
---

# Smoke Test: Claude Code Model Routing on Anthropic

This run checks native Anthropic model routing, not the task. Keep it trivial and short.

## Task

Run `echo 42` once and confirm the output is `42`. Do not make any other tool calls except the issue below.

## Output

Create an issue titled **"Smoke Test: Claude Routed Anthropic - ${{ github.run_id }}"** with:
- ✅ or ❌ for the task above
- Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

The routing verdict comes from the post-step, which checks runner- and proxy-written evidence, not this report. It fails unless the proxy selected and served the native `anthropic` provider with the exact wire model and the selected effort.
2,316 changes: 2,316 additions & 0 deletions .github/workflows/smoke-codex-routed-openai.lock.yml

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions .github/workflows/smoke-codex-routed-openai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
private: true
emoji: "🧭"
description: Smoke Codex model routing on native OpenAI inference with deterministic runner and proxy evidence checks
on:
schedule: every 2 days
workflow_dispatch:
label_command:
names: ["smoke-routing", "smoke"]
events: [pull_request]
strategy: decentralized
bots: [Copilot]
status-comment: false
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
permissions:
contents: read
name: Smoke Codex Routed OpenAI
concurrency:
group: "smoke-codex-routed-openai-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }}"
cancel-in-progress: true
job-discriminator: ${{ github.run_id }}
strict: true
engine:
id: codex
model-routing:
goal: cost
mode: economy
# Native OpenAI routing keeps CODEX_API_KEY / OPENAI_API_KEY inference.
# gpt-5.4 has maintained AWF routing metadata (Responses, none..xhigh effort).
allowed-models: [gpt-5.4]
imports:
- shared/smoke-test-brevity.md
- shared/reporting.md
checkout: false
tools:
github: false
safe-outputs:
create-issue:
expires: 2h
group: true
close-older-issues: true
close-older-key: "smoke-codex-routed-openai"
labels: [automation, testing]
timeout-minutes: 10
features:
gh-aw-detection: false
sandbox:
agent:
id: awf
post-steps:
- name: Assert model-routing evidence
if: always()
uses: actions/github-script@v9.0.0
env:
SMOKE_EXECUTION: ${{ steps.agentic_execution.outcome }}
with:
script: |
const path = require("node:path");
const { main } = require(path.join(process.env.RUNNER_TEMP, "gh-aw/actions/smoke_model_routing_assertions.cjs"));
await main({
core,
engine: "codex",
executionOutcome: process.env.SMOKE_EXECUTION,
allowedModels: ["gpt-5.4"],
mainEndpoint: "/responses",
routingMode: "economy",
nativeProvider: "openai",
expectRequestedEffortMatchesSelection: true,
expectModelInventory: true,
resolutionRoles: [],
});
---

# Smoke Test: Codex Model Routing on OpenAI

This run checks native OpenAI model routing, not the task. Keep it trivial and short.

## Task

Do not inspect the repository or run commands.

## Output

Create an issue titled **"Smoke Test: Codex Routed OpenAI - ${{ github.run_id }}"** with:
- ✅ for the task above
- Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

The routing verdict comes from the post-step, which checks runner- and proxy-written evidence, not this report. It fails unless the proxy selected and served the native `openai` provider with the exact wire model and the selected effort.
137 changes: 136 additions & 1 deletion actions/setup/js/awf_model_routing.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,49 @@ const AWF_MODEL_ROUTING_POLICIES = Object.freeze({
pi: Object.freeze({ endpoints: ROUTING_ENDPOINTS, allowEndpointOverride: true }),
});
const EMPTY_MODEL_ROUTING_POLICY = Object.freeze({ endpoints: Object.freeze([]), allowEndpointOverride: false });
// Engines that can route on their own provider's API (AWF apiProxy.routing.provider).
// AWF native routing never crosses providers; Copilot and pi route only on GitHub Copilot.
const AWF_NATIVE_ROUTING_PROVIDERS = Object.freeze({ claude: "anthropic", codex: "openai" });
// AWF reports native model endpoints as protocol names ("messages", "responses",
// "chat_completions") or as paths; map both to the routing endpoint paths.
const ROUTING_PROTOCOL_ENDPOINTS = Object.freeze({
messages: "/v1/messages",
responses: "/responses",
chat_completions: "/chat/completions",
"chat-completions": "/chat/completions",
});

/**
* Resolve the routing provider ("github", "anthropic" or "openai") for an engine and
* its resolved LLM provider (GH_AW_LLM_PROVIDER). Returns "" when the engine cannot
* route on that provider.
* @param {string} engine
* @param {string|undefined} llmProvider
* @returns {string}
*/
function resolveAWFRoutingProvider(engine, llmProvider) {
const engineName = String(engine || "").toLowerCase();
const nativeProvider = AWF_NATIVE_ROUTING_PROVIDERS[engineName] || "";
const provider = normalizeReflectProviderName(llmProvider, nativeProvider || "github");
if (REFLECT_PROVIDER_ALIASES.github.has(provider)) return "github";
return nativeProvider && provider === nativeProvider ? nativeProvider : "";
}

/**
* Map a model's advertised endpoints (protocol names or paths) to routing endpoint paths.
* @param {unknown[]} supportedEndpoints
* @returns {string[]}
*/
function routingEndpointPaths(supportedEndpoints) {
const paths = [];
for (const value of supportedEndpoints) {
if (typeof value !== "string") continue;
const endpoint = value.trim().toLowerCase();
const mapped = ROUTING_PROTOCOL_ENDPOINTS[endpoint] || (/\/messages$/.test(endpoint) ? "/v1/messages" : /\/responses$/.test(endpoint) ? "/responses" : /\/chat\/completions$/.test(endpoint) ? "/chat/completions" : "");
if (mapped && !paths.includes(mapped)) paths.push(mapped);
}
return paths;
}

/**
* @param {string} engine
Expand Down Expand Up @@ -69,13 +112,18 @@ function getAWFModelRoutingFailureCode(error) {

/**
* Resolve the task-level AWF model selection when routing is enabled.
* expectedProvider is the engine's routing provider: "github" (GitHub Copilot, the
* default) or a native provider ("anthropic" for Claude, "openai" for Codex). A native
* selection must come from that provider and be backed by its configured /reflect
* endpoint; it never falls back to another provider that lists the same model.
* @param {any} reflectData
* @param {boolean} routingRequired
* @param {string[]|null} [allowedEndpoints]
* @param {boolean} [allowEndpointOverride]
* @param {string} [expectedProvider]
* @returns {{selection: {provider: string, model: string, wire_model: string, effort: string|null, endpoint: string, selected_endpoint: string}|null, error: string|null}}
*/
function resolveAWFModelRoutingSelection(reflectData, routingRequired = false, allowedEndpoints = null, allowEndpointOverride = false) {
function resolveAWFModelRoutingSelection(reflectData, routingRequired = false, allowedEndpoints = null, allowEndpointOverride = false, expectedProvider = "github") {
const routing = reflectData && typeof reflectData === "object" ? reflectData.routing : null;
if (routing == null) {
return routingRequired ? { selection: null, error: "AWF /reflect did not return the required model-routing selection" } : { selection: null, error: null };
Expand All @@ -92,6 +140,10 @@ function resolveAWFModelRoutingSelection(reflectData, routingRequired = false, a
const wireModel = typeof selection.wire_model === "string" ? selection.wire_model.trim() : "";
const endpoint = typeof selection.endpoint === "string" ? selection.endpoint.trim() : "";
const selectedEndpoint = typeof selection.selected_endpoint === "string" ? selection.selected_endpoint.trim() : endpoint;
const routingProvider = normalizeReflectProviderName(expectedProvider, "github");
if (routingProvider !== "github") {
return resolveNativeAWFModelRoutingSelection(reflectData, selection, routingProvider, allowedEndpoints, allowEndpointOverride);
}
if (!["copilot", "github-copilot", "github"].includes(provider) || !wireModel || !endpoint) {
return { selection: null, error: "AWF /reflect returned an incomplete or unsupported Copilot routing selection" };
}
Expand Down Expand Up @@ -141,6 +193,86 @@ function resolveAWFModelRoutingSelection(reflectData, routingRequired = false, a
};
}

/**
* Validate a native (Anthropic or OpenAI) AWF routing selection against the
* provider-scoped /reflect endpoint: the selected provider must equal the engine's
* provider, that provider must have exactly one configured endpoint listing the wire
* model with complete routing metadata, the endpoint must be one the model advertises
* (or an engine endpoint it advertises, when overrides are allowed), and a selected
* effort must be one the model advertises.
* @param {any} reflectData
* @param {any} selection
* @param {string} expectedProvider
* @param {string[]|null} allowedEndpoints
* @param {boolean} allowEndpointOverride
* @returns {{selection: {provider: string, model: string, wire_model: string, effort: string|null, endpoint: string, selected_endpoint: string}|null, error: string|null}}
*/
function resolveNativeAWFModelRoutingSelection(reflectData, selection, expectedProvider, allowedEndpoints, allowEndpointOverride) {
const providerAliases = REFLECT_PROVIDER_ALIASES[expectedProvider];
if (!providerAliases || expectedProvider === "github") {
return { selection: null, error: `AWF model routing provider ${expectedProvider} is not supported by this engine` };
}
const provider = normalizeReflectProviderName(selection.provider);
const wireModel = typeof selection.wire_model === "string" ? selection.wire_model.trim() : "";
const endpoint = typeof selection.endpoint === "string" ? selection.endpoint.trim() : "";
if (!wireModel || !endpoint) {
return { selection: null, error: `AWF /reflect returned an incomplete native ${expectedProvider} routing selection` };
}
if (!providerAliases.has(provider)) {
return { selection: null, error: `AWF /reflect selected provider ${provider || "(none)"}, but this engine routes on the native ${expectedProvider} provider; native routing does not cross providers` };
}
const endpoints = Array.isArray(reflectData?.endpoints) ? reflectData.endpoints : [];
const providerEndpoints = endpoints.filter(candidate => candidate?.configured === true && providerAliases.has(normalizeReflectProviderName(candidate.provider)));
if (providerEndpoints.length === 0) {
return { selection: null, error: `AWF /reflect has no configured ${expectedProvider} endpoint for native model routing; refusing to fall back to another provider` };
}
if (!isModelAvailableInReflectData(wireModel, reflectData, providerAliases)) {
return { selection: null, error: `AWF /reflect selected unavailable ${expectedProvider} wire model ${wireModel}` };
}
const normalizedModel = wireModel.toLowerCase();
const routingModels = providerEndpoints.flatMap(candidate =>
Array.isArray(candidate.routing_models) ? candidate.routing_models.filter(model => typeof model?.model_id === "string" && model.model_id.toLowerCase() === normalizedModel) : []
);
if (routingModels.length > 1) {
return { selection: null, error: `AWF /reflect has ambiguous routing metadata for model ${wireModel}; ${routingModels.length} configured ${expectedProvider} entries list this model` };
}
const routingModel = routingModels[0];
const supportedEndpoints = routingModel?.supported_endpoints;
if (routingModel?.candidate_metadata_complete !== true || !Array.isArray(supportedEndpoints)) {
return { selection: null, error: `AWF /reflect cannot verify endpoints for ${expectedProvider} model ${wireModel}; candidate metadata is incomplete` };
}
const supportedPaths = routingEndpointPaths(supportedEndpoints);
let effectiveEndpoint = endpoint;
if (allowedEndpoints && !allowedEndpoints.includes(endpoint)) {
if (!allowEndpointOverride || !ROUTING_ENDPOINTS.includes(endpoint)) {
return { selection: null, error: `AWF /reflect selected endpoint ${endpoint}, which is not supported by this engine` };
}
effectiveEndpoint = allowedEndpoints.find(candidate => supportedPaths.includes(candidate)) || "";
if (!effectiveEndpoint) {
return {
selection: null,
error: `AWF model ${wireModel} advertises endpoints [${supportedPaths.join(", ")}], none compatible with this engine API [${allowedEndpoints.join(", ")}]`,
};
}
} else if (!supportedPaths.includes(endpoint)) {
return { selection: null, error: `AWF model ${wireModel} does not advertise endpoint ${endpoint}` };
}
const effort = typeof selection.effort === "string" && selection.effort.trim() ? selection.effort.trim().toLowerCase() : null;
if (effort) {
if (!isRoutingReasoningEffort(effort)) {
return { selection: null, error: `AWF /reflect returned unsupported reasoning effort ${effort}` };
}
const efforts = routingModel.supported_reasoning_efforts;
if (!Array.isArray(efforts) || !efforts.includes(effort)) {
return { selection: null, error: `AWF /reflect returned unsupported reasoning effort ${effort} for ${expectedProvider} model ${wireModel}` };
}
}
return {
selection: { provider, model: String(selection.model || ""), wire_model: wireModel, effort, endpoint: effectiveEndpoint, selected_endpoint: endpoint },
error: null,
};
}

/**
* @param {any} reflectData
* @param {string} wireModel
Expand Down Expand Up @@ -229,7 +361,10 @@ module.exports = {
ROUTING_REASONING_EFFORTS,
ROUTING_ENDPOINTS,
AWF_MODEL_ROUTING_POLICIES,
AWF_NATIVE_ROUTING_PROVIDERS,
getAWFModelRoutingPolicy,
resolveAWFRoutingProvider,
routingEndpointPaths,
isRoutingReasoningEffort,
mapAWFRoutingEffort,
getAWFModelRoutingFailureCode,
Expand Down
26 changes: 25 additions & 1 deletion actions/setup/js/awf_model_routing.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import os from "os";
import path from "path";

const require = createRequire(import.meta.url);
const { ROUTING_REASONING_EFFORTS, getAWFModelRoutingFailureCode, mapAWFRoutingEffort, recordAWFModelRoutingOutcome, resolveAWFModelRoutingSelection } = require("./awf_model_routing.cjs");
const { ROUTING_REASONING_EFFORTS, getAWFModelRoutingFailureCode, mapAWFRoutingEffort, recordAWFModelRoutingOutcome, resolveAWFModelRoutingSelection, resolveAWFRoutingProvider, routingEndpointPaths } = require("./awf_model_routing.cjs");
const reflectFixtures = require("./fixtures/model_resolution_reflect.cjs");

const reflectData = {
Expand All @@ -17,6 +17,30 @@ const reflectData = {
};

describe("awf_model_routing.cjs", () => {
it("resolves the routing provider from the engine and its resolved LLM provider", () => {
expect(resolveAWFRoutingProvider("claude", undefined)).toBe("anthropic");
expect(resolveAWFRoutingProvider("claude", "anthropic")).toBe("anthropic");
expect(resolveAWFRoutingProvider("claude", "github")).toBe("github");
expect(resolveAWFRoutingProvider("claude", "openai")).toBe("");
expect(resolveAWFRoutingProvider("codex", "")).toBe("openai");
expect(resolveAWFRoutingProvider("codex", "copilot")).toBe("github");
expect(resolveAWFRoutingProvider("codex", "anthropic")).toBe("");
expect(resolveAWFRoutingProvider("copilot", undefined)).toBe("github");
expect(resolveAWFRoutingProvider("pi", "anthropic")).toBe("");
});

it("maps native protocol names and paths to routing endpoints", () => {
expect(routingEndpointPaths(["messages"])).toEqual(["/v1/messages"]);
expect(routingEndpointPaths(["chat_completions", "responses"])).toEqual(["/chat/completions", "/responses"]);
expect(routingEndpointPaths(["/v1/responses", "/chat/completions", "/v1/messages", "embeddings", 7])).toEqual(["/responses", "/chat/completions", "/v1/messages"]);
});

it("keeps Copilot selections on the default GitHub provider", () => {
const anthropic = { ...reflectData, endpoints: [{ provider: "anthropic", configured: true, models: ["claude-opus-5"] }] };
expect(resolveAWFModelRoutingSelection(anthropic, true, ["/v1/messages"], true).error).toContain("unavailable Copilot wire model");
expect(resolveAWFModelRoutingSelection(reflectData, true, ["/v1/messages"], true, "anthropic").error).toContain("native routing does not cross providers");
});

it("accepts the AWF effort set and validates endpoint compatibility", () => {
expect(ROUTING_REASONING_EFFORTS).toEqual(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
expect(resolveAWFModelRoutingSelection(reflectData, true, ["/v1/messages"]).selection).toMatchObject({
Expand Down
Loading
Loading