diff --git a/actions/setup/js/ai_credits_context.cjs b/actions/setup/js/ai_credits_context.cjs index b2134d2630b..70ae4b3a79a 100644 --- a/actions/setup/js/ai_credits_context.cjs +++ b/actions/setup/js/ai_credits_context.cjs @@ -43,28 +43,12 @@ function parsePositiveNumberString(value) { return ""; } -/** - * @param {string} left - * @param {string} right - * @returns {boolean} - */ -function isNumberStringGreaterThanOrEqual(left, right) { - if (!left || !right) return false; - const leftNumber = Number.parseFloat(left); - const rightNumber = Number.parseFloat(right); - return Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && leftNumber >= rightNumber; -} - /** * @param {boolean} hasRateLimitSignal - * @param {string} aiCredits - * @param {string} maxAICredits * @returns {boolean} */ -function shouldReportAICreditsRateLimitError(hasRateLimitSignal, aiCredits, maxAICredits) { - if (!hasRateLimitSignal) return false; - if (!aiCredits || !maxAICredits) return true; - return isNumberStringGreaterThanOrEqual(aiCredits, maxAICredits); +function shouldReportAICreditsRateLimitError(hasRateLimitSignal) { + return hasRateLimitSignal; } /** @@ -525,7 +509,7 @@ function resolveAICreditsFailureState({ logProvenance = true } = {}) { const aiCredits = auditAICredits || stdioSignals.aiCredits || envAICredits || ""; const maxAICredits = auditMaxAICredits || stdioSignals.maxAICredits || envMaxAICredits || ""; const rawAICreditsRateLimitError = auditRateLimitError || stdioSignals.rateLimitError || envRateLimitSignalHasEvidence; - const aiCreditsRateLimitError = shouldReportAICreditsRateLimitError(rawAICreditsRateLimitError, aiCredits, maxAICredits); + const aiCreditsRateLimitError = shouldReportAICreditsRateLimitError(rawAICreditsRateLimitError); return { aiCredits, maxAICredits, aiCreditsRateLimitError, maxAICreditsExceeded: auditMaxAICreditsExceeded || stdioSignals.maxAICreditsExceeded }; } diff --git a/actions/setup/js/ai_credits_context.test.cjs b/actions/setup/js/ai_credits_context.test.cjs index 6b7b5890a48..de8fc0cd29d 100644 --- a/actions/setup/js/ai_credits_context.test.cjs +++ b/actions/setup/js/ai_credits_context.test.cjs @@ -174,6 +174,28 @@ describe("ai_credits_context max_ai_credits_exceeded detection", () => { expect(result.aiCredits).toBe(""); expect(result.maxAICredits).toBe("1000"); }); + + it("reports aiCreditsRateLimitError when rate-limit signal present but under budget", () => { + // Reproduces the failing run scenario: gateway log detected a 429 while the agent + // was under budget (236 AIC used vs 1000 max). Before the fix, the check + // `aiCredits >= maxAICredits` suppressed this signal, causing the conclusion handler + // to fall through to the generic "unexpected engine termination" path. + writeAuditLog([{ type: "response", ai_credits_rate_limit_error: true, ai_credits: 236, max_ai_credits: 1000 }]); + const result = resolveAICreditsFailureState(); + expect(result.aiCreditsRateLimitError).toBe(true); + expect(result.maxAICreditsExceeded).toBe(false); + }); + + it("reports aiCreditsRateLimitError from env signal combined with env AIC evidence when under budget", () => { + writeAuditLog([{ type: "response", status: 200 }]); + process.env.GH_AW_AI_CREDITS_RATE_LIMIT_ERROR = "true"; + process.env.GH_AW_AIC = "236.079"; + process.env.GH_AW_MAX_AI_CREDITS = "1000"; + const result = resolveAICreditsFailureState(); + expect(result.aiCreditsRateLimitError).toBe(true); + expect(result.maxAICreditsExceeded).toBe(false); + expect(result.aiCredits).toBe("236.079"); + }); }); }); diff --git a/actions/setup/js/handle_agent_failure.cjs b/actions/setup/js/handle_agent_failure.cjs index 9648332e61f..b1e56e2ee12 100644 --- a/actions/setup/js/handle_agent_failure.cjs +++ b/actions/setup/js/handle_agent_failure.cjs @@ -2050,9 +2050,10 @@ function readTokenUsageMarkdown() { * @param {string} aiCredits * @param {string} maxAICredits * @param {string} runUrl + * @param {boolean} [isBudgetExceeded] - true when the agent exceeded the configured max-ai-credits budget; false when the 429 was a throughput throttle * @returns {string} */ -function buildAICreditsRateLimitErrorContext(hasAICreditsRateLimitError, aiCredits, maxAICredits, runUrl) { +function buildAICreditsRateLimitErrorContext(hasAICreditsRateLimitError, aiCredits, maxAICredits, runUrl, isBudgetExceeded = false) { if (!hasAICreditsRateLimitError) { return ""; } @@ -2076,11 +2077,9 @@ function buildAICreditsRateLimitErrorContext(hasAICreditsRateLimitError, aiCredi metricsSummary = ` Used \`${formattedAICredits}\`.`; } - // Suggest a new limit: 2x current max, or 2x actual usage if max is unknown, or a reasonable default - const baseForSuggestion = Number.isFinite(numericMaxAICredits) && numericMaxAICredits > 0 ? numericMaxAICredits : Number.isFinite(numericAICredits) && numericAICredits > 0 ? numericAICredits : 0; - const suggestedCredits = baseForSuggestion > 0 ? Math.ceil(baseForSuggestion * 2) : 2000; - - const templateName = "ai_credits_rate_limit_error.md"; + // Use the budget-exceeded template when the agent exhausted its configured limit; + // use the throughput-throttle template when the 429 arrived before the budget was spent. + const templateName = isBudgetExceeded ? "ai_credits_rate_limit_error.md" : "ai_credits_rate_limit_throttle.md"; let templatePath = ""; try { templatePath = getPromptPath(templateName); @@ -2088,6 +2087,13 @@ function buildAICreditsRateLimitErrorContext(hasAICreditsRateLimitError, aiCredi throw new Error(`failed to resolve template path for ${templateName} (${getErrorMessage(error)}); ensure RUNNER_TEMP or GH_AW_PROMPTS_DIR is set and the template file exists`, { cause: error }); } + let suggestedCredits; + if (isBudgetExceeded) { + // Suggest a new limit: 2x current max, or 2x actual usage if max is unknown, or a reasonable default. + const baseForSuggestion = Number.isFinite(numericMaxAICredits) && numericMaxAICredits > 0 ? numericMaxAICredits : Number.isFinite(numericAICredits) && numericAICredits > 0 ? numericAICredits : 0; + suggestedCredits = baseForSuggestion > 0 ? Math.ceil(baseForSuggestion * 2) : 2000; + } + try { return ( "\n" + @@ -3733,7 +3739,7 @@ async function main() { // Build model not supported error context const modelNotSupportedErrorContext = buildModelNotSupportedErrorContext(modelNotSupportedError); const http400ResponseErrorContext = buildHTTP400ResponseErrorContext(http400ResponseError); - const aiCreditsRateLimitErrorContext = buildAICreditsRateLimitErrorContext(aiCreditsRateLimitError || maxAICreditsExceeded, aiCredits, maxAICredits, runUrl); + const aiCreditsRateLimitErrorContext = buildAICreditsRateLimitErrorContext(aiCreditsRateLimitError || maxAICreditsExceeded, aiCredits, maxAICredits, runUrl, maxAICreditsExceeded); const unknownModelAICreditsContext = buildUnknownModelAICreditsContext(unknownModelAICredits); // Build GitHub App token minting failure context @@ -3955,7 +3961,7 @@ async function main() { // Build model not supported error context const modelNotSupportedErrorContext = buildModelNotSupportedErrorContext(modelNotSupportedError); const http400ResponseErrorContext = buildHTTP400ResponseErrorContext(http400ResponseError); - const aiCreditsRateLimitErrorContext = buildAICreditsRateLimitErrorContext(aiCreditsRateLimitError || maxAICreditsExceeded, aiCredits, maxAICredits, runUrl); + const aiCreditsRateLimitErrorContext = buildAICreditsRateLimitErrorContext(aiCreditsRateLimitError || maxAICreditsExceeded, aiCredits, maxAICredits, runUrl, maxAICreditsExceeded); const unknownModelAICreditsContext = buildUnknownModelAICreditsContext(unknownModelAICredits); // Build GitHub App token minting failure context diff --git a/actions/setup/js/handle_agent_failure_ai_credits_rate_limit.test.cjs b/actions/setup/js/handle_agent_failure_ai_credits_rate_limit.test.cjs index 367032b61a5..34508e59176 100644 --- a/actions/setup/js/handle_agent_failure_ai_credits_rate_limit.test.cjs +++ b/actions/setup/js/handle_agent_failure_ai_credits_rate_limit.test.cjs @@ -20,7 +20,7 @@ describe("handle_agent_failure AI Credits rate-limit context", () => { }); it("shows inline usage and overage without table, no run URL, with suggested limit snippet", () => { - const rendered = buildAICreditsRateLimitErrorContext(true, "17.329230000000003", "10.1", "https://github.com/octo/repo/actions/runs/123"); + const rendered = buildAICreditsRateLimitErrorContext(true, "17.329230000000003", "10.1", "https://github.com/octo/repo/actions/runs/123", true); expect(rendered).toContain("AI Credits Budget Exceeded"); // inline metrics @@ -41,6 +41,35 @@ describe("handle_agent_failure AI Credits rate-limit context", () => { expect(rendered).not.toContain("Consult the billing dashboards for accurate usage and charges."); }); + it("shows throughput rate-limit message when under budget (isBudgetExceeded=false)", () => { + const rendered = buildAICreditsRateLimitErrorContext(true, "236.079", "1000", "https://github.com/octo/repo/actions/runs/123", false); + + expect(rendered).toContain("AI Credits Rate Limit"); + expect(rendered).not.toContain("AI Credits Budget Exceeded"); + // inline metrics show usage without an overage + expect(rendered).toContain("Used `236.1` of `1K` max"); + expect(rendered).not.toContain("over by"); + // no "Increase the limit" section + expect(rendered).not.toContain("Increase the limit"); + // tips section is still present + expect(rendered).toContain("Tips for reducing rate limit issues"); + expect(rendered).toContain("https://github.github.com/gh-aw/reference/cost-management/"); + }); + + it("defaults to throughput template when isBudgetExceeded is omitted", () => { + const rendered = buildAICreditsRateLimitErrorContext(true, "236.079", "1000", ""); + + expect(rendered).toContain("AI Credits Rate Limit"); + expect(rendered).not.toContain("AI Credits Budget Exceeded"); + }); + + it("shows throughput message without metrics when no credit data is available", () => { + const rendered = buildAICreditsRateLimitErrorContext(true, "", "", "", false); + + expect(rendered).toContain("AI Credits Rate Limit"); + expect(rendered).not.toContain("Used"); + }); + it("returns empty string when the AI Credits rate-limit did not trigger", () => { expect(buildAICreditsRateLimitErrorContext(false, "17.3", "10", "")).toBe(""); }); diff --git a/actions/setup/js/handle_agent_failure_max_ai_credits_exceeded.test.cjs b/actions/setup/js/handle_agent_failure_max_ai_credits_exceeded.test.cjs index a1e0f60b998..d35fba00017 100644 --- a/actions/setup/js/handle_agent_failure_max_ai_credits_exceeded.test.cjs +++ b/actions/setup/js/handle_agent_failure_max_ai_credits_exceeded.test.cjs @@ -21,7 +21,7 @@ describe("handle_agent_failure Max AI Credits exceeded context", () => { }); it("shows budget exhaustion message with inline usage, limit, and overage — no table, no run URL", () => { - const rendered = buildAICreditsRateLimitErrorContext(true, "105000", "100000", "https://github.com/octo/repo/actions/runs/456"); + const rendered = buildAICreditsRateLimitErrorContext(true, "105000", "100000", "https://github.com/octo/repo/actions/runs/456", true); expect(rendered).toContain("AI Credits Budget Exceeded"); expect(rendered).toContain("hit the configured `max-ai-credits` guardrail"); @@ -42,7 +42,7 @@ describe("handle_agent_failure Max AI Credits exceeded context", () => { }); it("shows message without metrics when no credit data is available, still shows snippet with default limit", () => { - const rendered = buildAICreditsRateLimitErrorContext(true, "", "", ""); + const rendered = buildAICreditsRateLimitErrorContext(true, "", "", "", true); expect(rendered).toContain("AI Credits Budget Exceeded"); expect(rendered).not.toContain("Used `"); @@ -53,7 +53,10 @@ describe("handle_agent_failure Max AI Credits exceeded context", () => { }); it("does not show overage when usage does not exceed limit", () => { - const rendered = buildAICreditsRateLimitErrorContext(true, "50000", "100000", ""); + // isBudgetExceeded=true because the maxAICreditsExceeded signal was set (the credit amounts + // in the log may be sampled before the final over-budget request, so usage < max is possible + // even when the budget was truly exceeded). + const rendered = buildAICreditsRateLimitErrorContext(true, "50000", "100000", "", true); expect(rendered).toContain("AI Credits Budget Exceeded"); expect(rendered).toContain("Used `50K` of `100K` max"); @@ -65,6 +68,6 @@ describe("handle_agent_failure Max AI Credits exceeded context", () => { }); it("returns empty string when max_ai_credits_exceeded is false", () => { - expect(buildAICreditsRateLimitErrorContext(false, "105000", "100000", "https://github.com/octo/repo/actions/runs/456")).toBe(""); + expect(buildAICreditsRateLimitErrorContext(false, "105000", "100000", "https://github.com/octo/repo/actions/runs/456", true)).toBe(""); }); }); diff --git a/actions/setup/md/ai_credits_rate_limit_throttle.md b/actions/setup/md/ai_credits_rate_limit_throttle.md new file mode 100644 index 00000000000..b69c3f99487 --- /dev/null +++ b/actions/setup/md/ai_credits_rate_limit_throttle.md @@ -0,0 +1,14 @@ +> [!WARNING] +> **AI Credits Rate Limit** +> +> The Copilot API returned a rate limit response (HTTP 429), but the workflow did not report the explicit AI credits budget-exceeded guardrail signal.{metrics_summary} + +
+Tips for reducing rate limit issues + +- Review the [cost optimization guidance](https://github.github.com/gh-aw/reference/cost-management/). +- Reduce unnecessary model or tool calls in the prompt. +- Trim large inputs or excess context that does not change the outcome. +- Split large tasks across smaller runs when possible. + +