diff --git a/actions/setup/js/effective_tokens.cjs b/actions/setup/js/effective_tokens.cjs index 2f3f8cd6cd0..9c890d8f79c 100644 --- a/actions/setup/js/effective_tokens.cjs +++ b/actions/setup/js/effective_tokens.cjs @@ -212,6 +212,111 @@ function formatET(n) { return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; } +/** + * Build a deterministic 5-character model identifier for footer rendering. + * Uses well-known shortcuts for popular model families and a deterministic fallback. + * + * Examples: + * - claude-sonnet-4.6 -> son46 + * - gpt-5.5 -> gpt55 + * - claude-opus-4-7 -> opu47 + * + * @param {string|undefined|null} modelName + * @returns {string} + */ +function reduceModelNameToIdentifier(modelName) { + const normalized = String(modelName || "") + .trim() + .toLowerCase(); + if (!normalized) return ""; + + const VERSION_SUFFIX_PATTERN = "[-_\\s]*([0-9]+)(?:[._-]+([0-9]+))?"; + const FALLBACK_LETTER_LENGTH = 3; + const FALLBACK_DIGIT_LENGTH = 2; + const FALLBACK_PADDING_CHAR = "x"; + + /** @type {Array<{ familyPattern: RegExp, versionPattern: RegExp, prefix: string }>} */ + const shortcuts = [ + { familyPattern: /sonnet/, versionPattern: new RegExp(`sonnet${VERSION_SUFFIX_PATTERN}`), prefix: "son" }, + { familyPattern: /opus/, versionPattern: new RegExp(`opus${VERSION_SUFFIX_PATTERN}`), prefix: "opu" }, + { familyPattern: /haiku/, versionPattern: new RegExp(`haiku${VERSION_SUFFIX_PATTERN}`), prefix: "hai" }, + { familyPattern: /gpt/, versionPattern: new RegExp(`gpt${VERSION_SUFFIX_PATTERN}`), prefix: "gpt" }, + { familyPattern: /gemini/, versionPattern: new RegExp(`gemini${VERSION_SUFFIX_PATTERN}`), prefix: "gem" }, + ]; + + for (const { familyPattern, versionPattern, prefix } of shortcuts) { + if (!familyPattern.test(normalized)) continue; + const version = extractModelVersionDigits(normalized, versionPattern); + return `${prefix}${version}`; + } + + return buildFallbackModelIdentifier(normalized, FALLBACK_LETTER_LENGTH, FALLBACK_DIGIT_LENGTH, FALLBACK_PADDING_CHAR); +} + +/** + * @param {string} normalizedModelName + * @param {RegExp} familyVersionPattern + * @returns {string} + */ +function extractModelVersionDigits(normalizedModelName, familyVersionPattern) { + const familyMatch = normalizedModelName.match(familyVersionPattern); + if (familyMatch) { + return normalizeVersionDigits(familyMatch[1], familyMatch[2]); + } + + const firstNumericMatch = normalizedModelName.match(/([0-9]+)(?:[._-]+([0-9]+))?/); + if (firstNumericMatch) { + return normalizeVersionDigits(firstNumericMatch[1], firstNumericMatch[2]); + } + + return "00"; +} + +/** + * @param {string|undefined} major + * @param {string|undefined} minor + * @returns {string} + */ +function normalizeVersionDigits(major, minor) { + const majorDigit = getFirstDigit(major); + // Treat any 3+ digit minor segment as a build/date-like stamp (e.g. 100, 20250514), + // not a semantic minor version, so identifiers stay stable (gpt-5-2025-08-07 -> gpt50). + const minorIsDateLike = minor && /^\d{3,}$/.test(minor); + const minorDigit = getFirstDigit(minor, Boolean(minorIsDateLike)); + return `${majorDigit}${minorDigit}`; +} + +/** + * @param {string|undefined} value + * @param {boolean} [treatAsMissing=false] + * @returns {string} + */ +function getFirstDigit(value, treatAsMissing = false) { + if (!value || treatAsMissing) return "0"; + const digitMatch = value.match(/\d/); + return digitMatch ? digitMatch[0] : "0"; +} + +/** + * @param {string} normalizedModelName + * @param {number} fallbackLetterLength + * @param {number} fallbackDigitLength + * @param {string} fallbackPaddingChar + * @returns {string} + */ +function buildFallbackModelIdentifier(normalizedModelName, fallbackLetterLength, fallbackDigitLength, fallbackPaddingChar) { + const compact = normalizedModelName.replace(/[^a-z0-9]+/g, ""); + if (!compact) return ""; + + // Pad with "x" to keep a fixed family slot for short/unknown model names. + const letterPart = compact.replace(/[0-9]/g, "").slice(0, fallbackLetterLength).padEnd(fallbackLetterLength, fallbackPaddingChar); + const digitPart = compact + .replace(/[^0-9]/g, "") + .slice(0, fallbackDigitLength) + .padEnd(fallbackDigitLength, "0"); + return `${letterPart}${digitPart}`.slice(0, 5); +} + /** * Resets the cached multipliers (for testing purposes). * @internal @@ -231,7 +336,9 @@ function getEffectiveTokensSuffix() { const parsed = parseInt(raw, 10); if (!isNaN(parsed) && parsed > 0) { - return ` · ● ${formatET(parsed)}`; + const reducedModel = reduceModelNameToIdentifier(process.env.GH_AW_ENGINE_MODEL); + const modelPrefix = reducedModel ? `${reducedModel} ` : ""; + return ` · ● ${modelPrefix}${formatET(parsed)}`; } return ""; } @@ -336,6 +443,7 @@ module.exports = { computeBaseWeightedTokens, computeEffectiveTokens, formatET, + reduceModelNameToIdentifier, getEffectiveTokensSuffix, AGENT_USAGE_PATH, readAgentUsage, diff --git a/actions/setup/js/effective_tokens.test.cjs b/actions/setup/js/effective_tokens.test.cjs index 2e45847fdfa..776044918e9 100644 --- a/actions/setup/js/effective_tokens.test.cjs +++ b/actions/setup/js/effective_tokens.test.cjs @@ -1,7 +1,17 @@ // @ts-check /// -const { defaultTokenClassWeights, getTokenClassWeights, getModelMultiplier, computeBaseWeightedTokens, computeEffectiveTokens, formatET, _resetCache } = require("./effective_tokens.cjs"); +const { + defaultTokenClassWeights, + getTokenClassWeights, + getModelMultiplier, + computeBaseWeightedTokens, + computeEffectiveTokens, + formatET, + reduceModelNameToIdentifier, + getEffectiveTokensSuffix, + _resetCache, +} = require("./effective_tokens.cjs"); // Model multipliers JSON used in tests (matches pkg/cli/data/model_multipliers.json) const TEST_MULTIPLIERS_JSON = JSON.stringify({ @@ -329,6 +339,69 @@ describe("effective_tokens", () => { expect(formatET(999)).toBe("999"); }); + describe("reduceModelNameToIdentifier", () => { + test("returns empty string for null input", () => { + expect(reduceModelNameToIdentifier(null)).toBe(""); + }); + + test("returns empty string for undefined input", () => { + expect(reduceModelNameToIdentifier(undefined)).toBe(""); + }); + + test("returns empty string for empty string input", () => { + expect(reduceModelNameToIdentifier("")).toBe(""); + }); + + test("uses well-known sonnet shortcut", () => { + expect(reduceModelNameToIdentifier("claude-sonnet-4.6")).toBe("son46"); + }); + + test("uses well-known gpt shortcut", () => { + expect(reduceModelNameToIdentifier("gpt-5.5")).toBe("gpt55"); + }); + + test("uses well-known opus shortcut", () => { + expect(reduceModelNameToIdentifier("claude-opus-4-7")).toBe("opu47"); + }); + + test("uses well-known haiku shortcut", () => { + expect(reduceModelNameToIdentifier("claude-haiku-4.5")).toBe("hai45"); + }); + + test("uses well-known gemini shortcut", () => { + expect(reduceModelNameToIdentifier("gemini-2.5-pro")).toBe("gem25"); + }); + + test("handles date-like suffixes deterministically", () => { + expect(reduceModelNameToIdentifier("gpt-5-2025-08-07")).toBe("gpt50"); + expect(reduceModelNameToIdentifier("claude-sonnet-4-20250514")).toBe("son40"); + expect(reduceModelNameToIdentifier("gpt-4-100")).toBe("gpt40"); + }); + + test("returns deterministic 5-character fallback for unknown models", () => { + expect(reduceModelNameToIdentifier("my-custom-engine-v2")).toBe("myc20"); + }); + }); + + describe("getEffectiveTokensSuffix", () => { + afterEach(() => { + delete process.env.GH_AW_EFFECTIVE_TOKENS; + delete process.env.GH_AW_ENGINE_MODEL; + }); + + test("prepends reduced model identifier when model is available", () => { + process.env.GH_AW_EFFECTIVE_TOKENS = "12500"; + process.env.GH_AW_ENGINE_MODEL = "claude-sonnet-4.6"; + expect(getEffectiveTokensSuffix()).toBe(" · ● son46 12.5K"); + }); + + test("falls back to token-only suffix when model is unavailable", () => { + process.env.GH_AW_EFFECTIVE_TOKENS = "12500"; + delete process.env.GH_AW_ENGINE_MODEL; + expect(getEffectiveTokensSuffix()).toBe(" · ● 12.5K"); + }); + }); + test("formats values in the thousands as K", () => { expect(formatET(1000)).toBe("1K"); expect(formatET(1200)).toBe("1.2K"); diff --git a/actions/setup/js/messages.test.cjs b/actions/setup/js/messages.test.cjs index 5e096f9ce88..5eff7d8f4c3 100644 --- a/actions/setup/js/messages.test.cjs +++ b/actions/setup/js/messages.test.cjs @@ -366,6 +366,20 @@ describe("messages.cjs", () => { expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) · ● 12.5K"); }); + it("should prepend reduced model identifier to effective tokens when engine model is set", async () => { + process.env.GH_AW_EFFECTIVE_TOKENS = "12500"; + process.env.GH_AW_ENGINE_MODEL = "claude-sonnet-4.6"; + + const { getFooterMessage } = await import("./messages.cjs"); + + const result = getFooterMessage({ + workflowName: "Test Workflow", + runUrl: "https://github.com/test/repo/actions/runs/123", + }); + + expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) · ● son46 12.5K"); + }); + it("should include effective tokens from env var before history link", async () => { process.env.GH_AW_EFFECTIVE_TOKENS = "5000"; const historyUrl = "https://github.com/search?q=repo:test/repo+is:issue&type=issues"; @@ -425,6 +439,23 @@ describe("messages.cjs", () => { expect(result).toBe("> Custom: [Test Workflow](https://github.com/test/repo/actions/runs/123) · ● 5K"); }); + + it("should include reduced model identifier in effective_tokens_suffix for custom templates", async () => { + process.env.GH_AW_EFFECTIVE_TOKENS = "5000"; + process.env.GH_AW_ENGINE_MODEL = "gpt-5.5"; + process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ + footer: "> Custom: [{workflow_name}]({run_url}){effective_tokens_suffix}", + }); + + const { getFooterMessage } = await import("./messages.cjs"); + + const result = getFooterMessage({ + workflowName: "Test Workflow", + runUrl: "https://github.com/test/repo/actions/runs/123", + }); + + expect(result).toBe("> Custom: [Test Workflow](https://github.com/test/repo/actions/runs/123) · ● gpt55 5K"); + }); }); describe("getFooterInstallMessage", () => { diff --git a/actions/setup/js/messages_footer.cjs b/actions/setup/js/messages_footer.cjs index 92a58e31bbb..c5635dd5044 100644 --- a/actions/setup/js/messages_footer.cjs +++ b/actions/setup/js/messages_footer.cjs @@ -12,7 +12,7 @@ const { getMessages, renderTemplate, renderTemplateFromFile, toSnakeCase, getPro const { getMissingInfoSections } = require("./missing_messages_helper.cjs"); const { getBlockedDomains, generateBlockedDomainsSection } = require("./firewall_blocked_domains.cjs"); const { getDifcFilteredEvents, generateDifcFilteredSection } = require("./gateway_difc_filtered.cjs"); -const { formatET } = require("./effective_tokens.cjs"); +const { formatET, reduceModelNameToIdentifier } = require("./effective_tokens.cjs"); const { getDetectionWarningMessage } = require("./messages_run_status.cjs"); /** @@ -37,18 +37,29 @@ function getDetectionCautionAlert(workflowName, runUrl) { * both the raw count, compact formatted string, and a pre-formatted suffix. * Returns undefined/empty for all fields when the variable is absent or the parsed value * is not a positive integer. + * @param {string} modelName * @returns {{ effectiveTokens: number|undefined, effectiveTokensFormatted: string|undefined, effectiveTokensSuffix: string }} */ -function getEffectiveTokensFromEnv() { +function getEffectiveTokensFromEnv(modelName) { const raw = process.env.GH_AW_EFFECTIVE_TOKENS; const parsed = raw ? parseInt(raw, 10) : NaN; if (!isNaN(parsed) && parsed > 0) { + const modelPrefix = buildModelPrefix(modelName); const effectiveTokensFormatted = formatET(parsed); - return { effectiveTokens: parsed, effectiveTokensFormatted, effectiveTokensSuffix: ` · ● ${effectiveTokensFormatted}` }; + return { effectiveTokens: parsed, effectiveTokensFormatted, effectiveTokensSuffix: ` · ● ${modelPrefix}${effectiveTokensFormatted}` }; } return { effectiveTokens: undefined, effectiveTokensFormatted: undefined, effectiveTokensSuffix: "" }; } +/** + * @param {string} modelName + * @returns {string} + */ +function buildModelPrefix(modelName) { + const reducedModel = reduceModelNameToIdentifier(modelName); + return reducedModel ? `${reducedModel} ` : ""; +} + /** * @typedef {Object} FooterContext * @property {string} workflowName - Name of the workflow @@ -60,6 +71,7 @@ function getEffectiveTokensFromEnv() { * @property {string} [historyUrl] - GitHub search URL for items created by this workflow (for the history link) * @property {string} [historyLink] - Pre-formatted markdown history link (e.g. " · [◷](url)"), or "" if unavailable * @property {number} [effectiveTokens] - Total effective token count for the run (shown as ● N when > 0, in compact format) + * @property {string} [model] - Model name used for the run, used to build a compact model identifier in ET suffixes * @property {string} [emoji] - Optional emoji representing the workflow (from frontmatter) */ @@ -74,7 +86,8 @@ function getFooterMessage(ctx) { // Use effectiveTokens from context if provided, otherwise fall back to env var. // This ensures callers that don't pass effectiveTokens (e.g. update_activation_comment.cjs) // still get the effective token count in the footer when GH_AW_EFFECTIVE_TOKENS is set. - const { effectiveTokens: envEffectiveTokens } = getEffectiveTokensFromEnv(); + const resolvedModelName = ctx.model || process.env.GH_AW_ENGINE_MODEL || ""; + const { effectiveTokens: envEffectiveTokens, effectiveTokensFormatted: envEffectiveTokensFormatted, effectiveTokensSuffix: envEffectiveTokensSuffix } = getEffectiveTokensFromEnv(resolvedModelName); const effectiveTokens = ctx.effectiveTokens ?? envEffectiveTokens; // Pre-compute history_link as a ready-to-use markdown suffix (empty string when unavailable) @@ -84,9 +97,19 @@ function getFooterMessage(ctx) { const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); // Pre-compute effective_tokens_formatted and effective_tokens_suffix for use in custom templates - const effectiveTokensFormatted = effectiveTokens ? formatET(effectiveTokens) : undefined; + const hasExplicitContextEffectiveTokens = ctx.effectiveTokens !== undefined && ctx.effectiveTokens !== null; + let effectiveTokensFormatted = envEffectiveTokensFormatted; // effective_tokens_suffix is always a string: either " · ● 1.2K" or "" (for safe use in templates) - const effectiveTokensSuffix = effectiveTokensFormatted ? ` · ● ${effectiveTokensFormatted}` : ""; + let effectiveTokensSuffix = envEffectiveTokensSuffix; + if (hasExplicitContextEffectiveTokens) { + effectiveTokensFormatted = effectiveTokens ? formatET(effectiveTokens) : undefined; + if (effectiveTokensFormatted) { + const modelPrefix = buildModelPrefix(resolvedModelName); + effectiveTokensSuffix = ` · ● ${modelPrefix}${effectiveTokensFormatted}`; + } else { + effectiveTokensSuffix = ""; + } + } // Create context with both camelCase and snake_case keys, including computed history_link and agentic_workflow_url const templateContext = toSnakeCase({ ...ctx, effectiveTokens, historyLink, agenticWorkflowUrl, effectiveTokensFormatted, effectiveTokensSuffix }); @@ -104,7 +127,7 @@ function getFooterMessage(ctx) { } // Append effective tokens with ● symbol when available (compact format, no "ET" label) if (effectiveTokens) { - defaultFooter += ` · ● ${formatET(effectiveTokens)}`; + defaultFooter += effectiveTokensSuffix; } // Append history link when available if (ctx.historyUrl) { @@ -157,7 +180,8 @@ function getFooterWorkflowRecompileMessage(ctx) { const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); // Read effective tokens from environment variable if available - const { effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix } = getEffectiveTokensFromEnv(); + const modelName = process.env.GH_AW_ENGINE_MODEL || ""; + const { effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix } = getEffectiveTokensFromEnv(modelName); // Create context with both camelCase and snake_case keys const templateContext = toSnakeCase({ ...ctx, agenticWorkflowUrl, effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix }); @@ -183,7 +207,8 @@ function getFooterWorkflowRecompileCommentMessage(ctx) { const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); // Read effective tokens from environment variable if available - const { effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix } = getEffectiveTokensFromEnv(); + const modelName = process.env.GH_AW_ENGINE_MODEL || ""; + const { effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix } = getEffectiveTokensFromEnv(modelName); // Create context with both camelCase and snake_case keys const templateContext = toSnakeCase({ ...ctx, agenticWorkflowUrl, effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix }); @@ -222,7 +247,8 @@ function getFooterAgentFailureIssueMessage(ctx) { const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); // Read effective tokens from environment variable if available - const { effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix } = getEffectiveTokensFromEnv(); + const modelName = process.env.GH_AW_ENGINE_MODEL || ""; + const { effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix } = getEffectiveTokensFromEnv(modelName); // Create context with both camelCase and snake_case keys, including computed history_link and agentic_workflow_url const templateContext = toSnakeCase({ ...ctx, historyLink, agenticWorkflowUrl, effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix }); @@ -263,7 +289,8 @@ function getFooterAgentFailureCommentMessage(ctx) { const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); // Read effective tokens from environment variable if available - const { effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix } = getEffectiveTokensFromEnv(); + const modelName = process.env.GH_AW_ENGINE_MODEL || ""; + const { effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix } = getEffectiveTokensFromEnv(modelName); // Create context with both camelCase and snake_case keys, including computed history_link and agentic_workflow_url const templateContext = toSnakeCase({ ...ctx, historyLink, agenticWorkflowUrl, effectiveTokens, effectiveTokensFormatted, effectiveTokensSuffix }); @@ -386,7 +413,8 @@ function generateFooterWithMessages(workflowName, runUrl, workflowSource, workfl // Read effective tokens from environment variable if available. // GH_AW_EFFECTIVE_TOKENS is set by parse_mcp_gateway_log.cjs after computing ET // from the token-usage.jsonl produced by the firewall proxy. - const { effectiveTokens } = getEffectiveTokensFromEnv(); + const modelName = process.env.GH_AW_ENGINE_MODEL || ""; + const { effectiveTokens } = getEffectiveTokensFromEnv(modelName); // Read workflow emoji from environment variable if available. const emoji = process.env.GH_AW_WORKFLOW_EMOJI || undefined;