Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 3 additions & 19 deletions actions/setup/js/ai_credits_context.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +50 to +51
}

/**
Expand Down Expand Up @@ -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 };
}

Expand Down
22 changes: 22 additions & 0 deletions actions/setup/js/ai_credits_context.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});

Expand Down
22 changes: 14 additions & 8 deletions actions/setup/js/handle_agent_failure.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
}
Expand All @@ -2076,18 +2077,23 @@ 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);
} catch (error) {
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" +
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test asserts toContain("Used \236.1` of `1K` max")— this implicitly tests the rounding/formatting logic that lives inhandle_agent_failure.cjs. A follow-on test covering the edge case where both aiCreditsandmaxAICredits` are empty strings (under the throughput path) would close the coverage gap left by the analogous budget-exceeded test at line 53 of the other test file.

💡 Suggested test
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");
});

@copilot please address this.

expect(rendered).toContain("Used `236.1` of `1K` max");
expect(rendered).not.toContain("over by");
// no "Increase the limit" section
expect(rendered).not.toContain("<summary>Increase the limit</summary>");
// tips section is still present
expect(rendered).toContain("<summary>Tips for reducing rate limit issues</summary>");
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("");
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 `");
Expand All @@ -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");
Expand All @@ -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("");
});
});
14 changes: 14 additions & 0 deletions actions/setup/md/ai_credits_rate_limit_throttle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
> [!WARNING]
> **AI Credits Rate Limit**
>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The template text says "before the workflow's AI credits budget was exhausted" — this is slightly misleading when maxAICredits is not configured (i.e. there is no budget). Consider phrasing it as "before the configured AI credits budget was reached (or no budget was set)" to handle the zero-budget case clearly.

@copilot please address this.

> 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}

<details>
<summary>Tips for reducing rate limit issues</summary>

- 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.

</details>
Loading