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
32 changes: 32 additions & 0 deletions actions/setup/js/awf_model_routing.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"use strict";

const { REFLECT_PROVIDER_ALIASES, normalizeReflectProviderName } = require("./awf_reflect.cjs");
const fs = require("fs");
const path = require("path");

const ROUTING_REASONING_EFFORTS = Object.freeze(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);

Expand All @@ -21,6 +23,10 @@ function isRoutingReasoningEffort(effort) {
*/
function mapAWFRoutingEffort(engine, effort) {
if (effort === null) return { effort: null, error: null };
if (!isRoutingReasoningEffort(effort)) {
return { effort: null, error: `AWF model routing effort "${effort}" is not supported by the ${engine} engine` };
}
if (engine === "copilot") return { effort, error: null };
const supported = {
claude: new Set(["low", "medium", "high", "xhigh", "max"]),
codex: new Set(["minimal", "low", "medium", "high", "xhigh"]),
Expand Down Expand Up @@ -72,6 +78,31 @@ function resolveAWFModelRoutingSelection(reflectData, routingRequired = false, a
return { selection: { provider, model: String(selection.model || ""), wire_model: wireModel, effort, endpoint }, error: null };
}

function recordAWFModelRoutingOutcome(outcome, env = process.env, filePath = path.join(env.GH_AW_TMP_DIR || "/tmp/gh-aw", "agent", "awf-routing-outcome.json")) {
if (env.GH_AW_MODEL_ROUTING !== "1" || !outcome || !["selected", "failed", "rejected"].includes(outcome.status)) return false;
const wireModel = typeof outcome.wire_model === "string" && outcome.wire_model.length <= 128 && /^[A-Za-z0-9._/:@-]+$/.test(outcome.wire_model) ? outcome.wire_model : "";
if (outcome.status === "selected" && !wireModel) return false;
const effort = outcome.effort == null ? null : isRoutingReasoningEffort(outcome.effort) ? outcome.effort : null;
if (outcome.effort != null && effort === null) return false;
const appliedEffort = outcome.applied_effort == null ? null : outcome.applied_effort === "off" || isRoutingReasoningEffort(outcome.applied_effort) ? outcome.applied_effort : null;
if (outcome.applied_effort != null && appliedEffort === null) return false;
const record = {
status: outcome.status,
...(wireModel ? { wire_model: wireModel } : {}),
...(effort ? { effort } : {}),
...(appliedEffort ? { applied_effort: appliedEffort } : {}),
...(typeof outcome.failure_code === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(outcome.failure_code) ? { failure_code: outcome.failure_code } : {}),
...(typeof outcome.detail === "string" ? { detail: outcome.detail.slice(0, 512) } : {}),
};
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(record) + "\n");
return true;
} catch {
return false;
}
}

/**
* Check whether a model is present in AWF /reflect endpoint data.
* @param {string} model
Expand All @@ -94,4 +125,5 @@ module.exports = {
mapAWFRoutingEffort,
resolveAWFModelRoutingSelection,
isModelAvailableInReflectData,
recordAWFModelRoutingOutcome,
};
30 changes: 29 additions & 1 deletion actions/setup/js/awf_model_routing.test.cjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "module";
import fs from "fs";
import os from "os";
import path from "path";

const require = createRequire(import.meta.url);
const { ROUTING_REASONING_EFFORTS, mapAWFRoutingEffort, resolveAWFModelRoutingSelection } = require("./awf_model_routing.cjs");
const { ROUTING_REASONING_EFFORTS, mapAWFRoutingEffort, recordAWFModelRoutingOutcome, resolveAWFModelRoutingSelection } = require("./awf_model_routing.cjs");

const reflectData = {
endpoints: [{ provider: "github", configured: true, models: ["claude-opus-5", "gpt-5.6-sol"] }],
Expand Down Expand Up @@ -43,6 +46,31 @@ describe("awf_model_routing.cjs", () => {
expect(mapAWFRoutingEffort("pi", input)).toEqual({ effort: expected, error: null });
});

it.each(ROUTING_REASONING_EFFORTS)("preserves the %s effort for Copilot", effort => {
expect(mapAWFRoutingEffort("copilot", effort)).toEqual({ effort, error: null });
});

it("records successful and refused harness outcomes only when routing is enabled", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "awf-routing-outcome-"));
const env = { GH_AW_MODEL_ROUTING: "1", GH_AW_TMP_DIR: tmpDir };
const recordPath = path.join(tmpDir, "agent", "awf-routing-outcome.json");
try {
expect(recordAWFModelRoutingOutcome({ status: "selected", wire_model: "gpt-5.6-luna", effort: "high", applied_effort: "high" }, env)).toBe(true);
expect(JSON.parse(fs.readFileSync(recordPath, "utf8"))).toMatchObject({
status: "selected",
wire_model: "gpt-5.6-luna",
effort: "high",
applied_effort: "high",
});
expect(recordAWFModelRoutingOutcome({ status: "rejected", failure_code: "unsupported_effort" }, env)).toBe(true);
expect(JSON.parse(fs.readFileSync(recordPath, "utf8"))).toMatchObject({ status: "rejected", failure_code: "unsupported_effort" });
expect(recordAWFModelRoutingOutcome({ status: "selected", wire_model: "gpt-5.6-luna\nrouted: false" }, env)).toBe(false);
expect(recordAWFModelRoutingOutcome({ status: "selected", wire_model: "gpt-5.6-luna" }, { ...env, GH_AW_MODEL_ROUTING: "0" })).toBe(false);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});

it.each([
["claude", "none"],
["claude", "minimal"],
Expand Down
10 changes: 8 additions & 2 deletions actions/setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const { isCrashSignalExitCode, crashSignalNameForExitCode } = require("./harness
const { MODEL_NOT_SUPPORTED_PATTERN: INVALID_MODEL_ERROR_PATTERN } = require("./detect_agent_errors.cjs");
const { applyModelFallback, normalizeClaudeModel, normalizeClaudeModelArgs } = require("./model_fallback.cjs");
const { parseMaxAICreditsExceededFromAuditLog } = require("./ai_credits_context.cjs");
const { resolveAWFModelRoutingSelection, mapAWFRoutingEffort } = require("./awf_model_routing.cjs");
const { resolveAWFModelRoutingSelection, mapAWFRoutingEffort, recordAWFModelRoutingOutcome } = require("./awf_model_routing.cjs");

const CLAUDE_ROUTING_ENDPOINTS = ["/v1/messages"];

Expand Down Expand Up @@ -379,14 +379,19 @@ async function buildClaudeChildEnv(reflectData, env = process.env, logger = log,
if (env.GH_AW_MODEL_ROUTING === "1") {
const result = modelRoutingSelection ? { selection: modelRoutingSelection, error: null } : resolveAWFModelRoutingSelection(reflectData, true, CLAUDE_ROUTING_ENDPOINTS);
if (result.error || !result.selection) {
recordAWFModelRoutingOutcome({ status: reflectData?.routing?.status === "failed" ? "failed" : "rejected", failure_code: reflectData?.routing?.failure_code, detail: result.error });
throw new Error(`${result.error || "AWF model routing selection is missing"}; refusing to start Claude`);
}
const mappedEffort = mapAWFRoutingEffort("claude", result.selection.effort);
if (mappedEffort.error) throw new Error(`${mappedEffort.error}; refusing to start Claude`);
if (mappedEffort.error) {
recordAWFModelRoutingOutcome({ status: "rejected", failure_code: "unsupported_effort", detail: mappedEffort.error });
throw new Error(`${mappedEffort.error}; refusing to start Claude`);
}
childEnv.ANTHROPIC_MODEL = result.selection.wire_model;
delete childEnv.GH_AW_MODEL_FALLBACK;
if (mappedEffort.effort) childEnv.CLAUDE_CODE_EFFORT_LEVEL = mappedEffort.effort;
else delete childEnv.CLAUDE_CODE_EFFORT_LEVEL;
recordAWFModelRoutingOutcome({ status: "selected", wire_model: result.selection.wire_model, effort: result.selection.effort, applied_effort: mappedEffort.effort }, env);
logger(`inference routing: mode=awf-routed model=${result.selection.wire_model} effort=${result.selection.effort || "(unset)"}`);
} else {
applyModelFallback(childEnv, "ANTHROPIC_MODEL", logger);
Expand Down Expand Up @@ -466,6 +471,7 @@ async function main() {
if (process.env.GH_AW_MODEL_ROUTING === "1") {
const result = resolveAWFModelRoutingSelection(reflection.reflectData, true, CLAUDE_ROUTING_ENDPOINTS);
if (result.error || !result.selection) {
recordAWFModelRoutingOutcome({ status: reflection.reflectData?.routing?.status === "failed" ? "failed" : "rejected", failure_code: reflection.reflectData?.routing?.failure_code, detail: result.error });
throw new Error(`${result.error || "AWF model routing selection is missing"}; refusing to start Claude`);
}
modelRoutingSelection = result.selection;
Expand Down
13 changes: 10 additions & 3 deletions actions/setup/js/codex_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const { resolveRetryConfig } = require("./harness_retry_config.cjs");
const { applyModelFallback, injectModelFlagAfterExec, normalizeCodexModel, normalizeCodexModelArgs } = require("./model_fallback.cjs");
const { parseMaxAICreditsExceededFromAuditLog } = require("./ai_credits_context.cjs");
const { calculateWorkingSetFromJSONL } = require("./working_set_metrics.cjs");
const { resolveAWFModelRoutingSelection, mapAWFRoutingEffort } = require("./awf_model_routing.cjs");
const { resolveAWFModelRoutingSelection, mapAWFRoutingEffort, recordAWFModelRoutingOutcome } = require("./awf_model_routing.cjs");

const CODEX_ROUTING_ENDPOINTS = ["/responses"];

Expand Down Expand Up @@ -515,12 +515,19 @@ function applyCodexRoutingEffort(args, effort) {
*/
function resolveCodexModelRouting(reflectData, args) {
const result = resolveAWFModelRoutingSelection(reflectData, true, CODEX_ROUTING_ENDPOINTS);
if (result.error || !result.selection) return { selection: null, model: "", args, error: result.error || "AWF model routing selection is missing" };
if (result.error || !result.selection) {
recordAWFModelRoutingOutcome({ status: reflectData?.routing?.status === "failed" ? "failed" : "rejected", failure_code: reflectData?.routing?.failure_code, detail: result.error });
return { selection: null, model: "", args, error: result.error || "AWF model routing selection is missing" };
}
const mappedEffort = mapAWFRoutingEffort("codex", result.selection.effort);
if (mappedEffort.error) return { selection: null, model: "", args, error: mappedEffort.error };
if (mappedEffort.error) {
recordAWFModelRoutingOutcome({ status: "rejected", failure_code: "unsupported_effort", detail: mappedEffort.error });
return { selection: null, model: "", args, error: mappedEffort.error };
}
let routedArgs = removeCodexRoutingOverrides(args);
routedArgs = injectModelFlagAfterExec(routedArgs, result.selection.wire_model);
if (mappedEffort.effort) routedArgs = applyCodexRoutingEffort(routedArgs, mappedEffort.effort);
recordAWFModelRoutingOutcome({ status: "selected", wire_model: result.selection.wire_model, effort: result.selection.effort, applied_effort: mappedEffort.effort });
return { selection: result.selection, model: result.selection.wire_model, args: routedArgs, error: null };
}

Expand Down
3 changes: 3 additions & 0 deletions actions/setup/js/copilot_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const { isCAPIQuotaExceededError, isCAPIServerError } = require("./detect_agent_
const { applyModelFallback } = require("./model_fallback.cjs");
const { isRoutingReasoningEffort } = require("./copilot_routing_effort.cjs");
const { resolveAWFModelRoutingSelection, isModelAvailableInReflectData } = require("./awf_model_routing.cjs");
const { recordAWFModelRoutingOutcome } = require("./awf_model_routing.cjs");
const { loadModelsJson } = require("./model_costs.cjs");
const { resolveConfiguredCopilotModel, ModelAliasResolutionError } = require("./resolve_model_alias.cjs");
const { parseAICreditsErrorInfoFromAuditLog, parseMaxAICreditsFromAuditLog, parseMaxAICreditsExceededFromAuditLog, parseAPIProxyGuardRejectionFromEventLog, formatAPIProxyGuardRejection } = require("./ai_credits_context.cjs");
Expand Down Expand Up @@ -503,6 +504,7 @@ function applyCopilotRoutingSelection(selection, logger = log) {
} else {
delete process.env.GH_AW_COPILOT_ROUTING_EFFORT;
}
recordAWFModelRoutingOutcome({ status: "selected", wire_model: selection.wire_model, effort: selection.effort, applied_effort: selection.effort });
logger(`inference routing: mode=awf-routed model=${selection.wire_model} effort=${selection.effort || "(unset)"} wire_api=${wireApi}`);
}

Expand Down Expand Up @@ -1187,6 +1189,7 @@ async function main() {

const routingResult = resolveAWFModelRoutingSelection(awfReflectData, modelRoutingRequired, ["/responses", "/chat/completions"]);
if (routingResult.error) {
recordAWFModelRoutingOutcome({ status: awfReflectData?.routing?.status === "failed" ? "failed" : "rejected", failure_code: awfReflectData?.routing?.failure_code, detail: routingResult.error });
log(`unexpected error: AWF model routing failed: ${routingResult.error}; refusing to start Copilot`);
process.exit(1);
return;
Expand Down
11 changes: 9 additions & 2 deletions actions/setup/js/generate_footer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ function matchesWorkflowId(body, workflowId) {
* @returns {string} XML comment marker with workflow metadata
*/
function generateXMLMarker(workflowName, runUrl) {
const { getFallbackModel, validateModelIdentifier } = require("./model_attribution.cjs");
const { resolveEffectiveModel, validateModelIdentifier } = require("./model_attribution.cjs");
// Read engine metadata from environment variables
const engineId = process.env.GH_AW_ENGINE_ID || "";
const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
const engineModel = getFallbackModel() || validateModelIdentifier(process.env.GH_AW_ENGINE_MODEL);
const attribution = resolveEffectiveModel();
const engineModel = attribution.model || (attribution.routing ? attribution.requestedModel : validateModelIdentifier(process.env.GH_AW_ENGINE_MODEL));
const trackerId = process.env.GH_AW_TRACKER_ID || "";
const runId = process.env.GITHUB_RUN_ID || "";
const workflowId = process.env.GH_AW_WORKFLOW_ID || "";
Expand Down Expand Up @@ -88,6 +89,12 @@ function generateXMLMarker(workflowName, runUrl) {
if (engineModel) {
parts.push(`model: ${engineModel}`);
}
if (attribution.routing?.status === "selected") {
if (attribution.effort) parts.push(`effort: ${attribution.effort}`);
parts.push("routed: true");
} else if (attribution.routing) {
parts.push(`routed: ${attribution.routing.status}`);
}

// Add numeric run ID if available
if (runId) {
Expand Down
3 changes: 1 addition & 2 deletions actions/setup/js/handle_noop_message.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const { loadAgentOutput } = require("./load_agent_output.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");
const { generateHistoryUrl } = require("./generate_history_link.cjs");
const { formatAIC } = require("./model_costs.cjs");
const { reduceModelNameToIdentifier } = require("./model_aliases.cjs");
const { buildNoopConclusionSummary } = require("./conclusion_summary.cjs");
/**
* Search for or create the parent issue for all agentic workflow no-op runs
Expand Down Expand Up @@ -108,7 +107,7 @@ function buildAICSuffix() {
const agentAIC = parsePositiveAIC(process.env.GH_AW_AIC);
const detectionAIC = parsePositiveAIC(process.env.GH_AW_THREAT_DETECTION_AIC);
const evalsAIC = parsePositiveAIC(process.env.GH_AW_EVALS_AIC);
const compressedModelName = reduceModelNameToIdentifier(require("./model_attribution.cjs").getFallbackModel() || process.env.GH_AW_PRIMARY_MODEL || process.env.GH_AW_ENGINE_MODEL);
const compressedModelName = require("./model_attribution.cjs").getEffectiveModelLabel();
const agentSuffix = buildAICEntry("", agentAIC, compressedModelName);
const detectionSuffix = buildAICEntry("⌖", detectionAIC);
const evalsSuffix = buildAICEntry("◇", evalsAIC);
Expand Down
37 changes: 37 additions & 0 deletions actions/setup/js/messages.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ describe("messages.cjs", () => {
delete process.env.GH_AW_ENGINE_VERSION;
delete process.env.GH_AW_ENGINE_MODEL;
delete process.env.GH_AW_PRIMARY_MODEL;
delete process.env.GH_AW_ENGINE_MODEL_EFFORT;
delete process.env.GH_AW_MODEL_ROUTING_STATUS;
delete process.env.GH_AW_TMP_DIR;
delete process.env.GH_AW_TRACKER_ID;
delete process.env.GITHUB_RUN_ID;
delete process.env.GH_AW_WORKFLOW_ID;
Expand Down Expand Up @@ -1244,6 +1247,40 @@ describe("messages.cjs", () => {
expect(result).toBe("> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123) · sonnet46 · 1.25 AIC · ⊞ 900");
});

it("identifies the routed model and effort in the visible failure footer", async () => {
process.env.GH_AW_TMP_DIR = path.join(process.cwd(), ".missing-routed-attribution");
process.env.GH_AW_ENGINE_ID = "copilot";
process.env.GH_AW_ENGINE_MODEL = "gpt-5.6-luna";
process.env.GH_AW_ENGINE_MODEL_EFFORT = "xhigh";
process.env.GH_AW_MODEL_ROUTING_STATUS = "selected";
process.env.GH_AW_AIC = "2.64";

const { getFooterAgentFailureIssueMessage } = await import("./messages.cjs");
const result = getFooterAgentFailureIssueMessage({
workflowName: "Test Workflow",
runUrl: "https://github.com/test/repo/actions/runs/123",
});

expect(result).toContain("copilot · routed: gpt56 xhigh · 2.64 AIC");
});

it("does not display a configured placeholder when routing failed", async () => {
process.env.GH_AW_TMP_DIR = path.join(process.cwd(), ".missing-routed-attribution");
process.env.GH_AW_ENGINE_ID = "copilot";
process.env.GH_AW_ENGINE_MODEL = "auto";
process.env.GH_AW_MODEL_ROUTING_STATUS = "failed";
process.env.GH_AW_AIC = "0.50";

const { getFooterAgentFailureIssueMessage } = await import("./messages.cjs");
const result = getFooterAgentFailureIssueMessage({
workflowName: "Test Workflow",
runUrl: "https://github.com/test/repo/actions/runs/123",
});

expect(result).toContain("copilot · routing failed");
expect(result).not.toContain("copilot · auto");
});

it("should include evals AI Credits in the default footer when available", async () => {
process.env.GH_AW_AIC = "1.25";
process.env.GH_AW_EVALS_AIC = "0.25";
Expand Down
16 changes: 12 additions & 4 deletions actions/setup/js/messages_footer.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const { formatCompactInteger } = require("./compact_numbers.cjs");
const { formatAIC } = require("./model_costs.cjs");
const { reduceModelNameToIdentifier } = require("./model_aliases.cjs");
const { getDetectionWarningMessage } = require("./messages_run_status.cjs");
const { getFallbackModel, validateModelIdentifier } = require("./model_attribution.cjs");
const { getEffectiveModelLabel, resolveEffectiveModel, validateModelIdentifier } = require("./model_attribution.cjs");

/**
* Get the detection caution alert if the detection job found a potential issue.
Expand Down Expand Up @@ -115,8 +115,9 @@ function buildAICEntry(label, value, modelAlias) {
* }}
*/
function getAICFromEnv() {
const aiModel = getFallbackModel() || process.env.GH_AW_PRIMARY_MODEL || process.env.GH_AW_ENGINE_MODEL || undefined;
const compressedModelName = reduceModelNameToIdentifier(aiModel);
const attribution = resolveEffectiveModel();
const aiModel = attribution.model || undefined;
const compressedModelName = getEffectiveModelLabel();
const agenticEngine = process.env.GH_AW_ENGINE_ID || undefined;
const agentModelIdentifier = [agenticEngine, compressedModelName].filter(Boolean).join(" · ");
const totalAIC = parsePositiveAIC(process.env.GH_AW_AIC);
Expand Down Expand Up @@ -614,7 +615,8 @@ function generateXMLMarker(workflowName, runUrl) {
// Read engine metadata from environment variables
const engineId = process.env.GH_AW_ENGINE_ID || "";
const engineVersion = process.env.GH_AW_ENGINE_VERSION || "";
const engineModel = getFallbackModel() || validateModelIdentifier(process.env.GH_AW_ENGINE_MODEL);
const attribution = resolveEffectiveModel();
const engineModel = attribution.model || (attribution.routing ? attribution.requestedModel : validateModelIdentifier(process.env.GH_AW_ENGINE_MODEL));
const trackerId = process.env.GH_AW_TRACKER_ID || "";
const runId = process.env.GITHUB_RUN_ID || "";
const workflowId = process.env.GH_AW_WORKFLOW_ID || "";
Expand Down Expand Up @@ -644,6 +646,12 @@ function generateXMLMarker(workflowName, runUrl) {
if (engineModel) {
parts.push(`model: ${engineModel}`);
}
if (attribution.routing?.status === "selected") {
if (attribution.effort) parts.push(`effort: ${attribution.effort}`);
parts.push("routed: true");
} else if (attribution.routing) {
parts.push(`routed: ${attribution.routing.status}`);
}

// Add numeric run ID if available
if (runId) {
Expand Down
Loading
Loading