fix(ai): recognized OpenRouter daily key limits

- Classified free-models-per-day failures as credential-scoped quota exhaustion.
- Added regression coverage proving auth retries switch to a healthy sibling key.

Fixes #4832
This commit is contained in:
roboomp
2026-07-14 16:39:28 +00:00
parent ac2ea80fa3
commit 0c5458a248
3 changed files with 34 additions and 1 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed OpenRouter daily free-model allowance errors (`free-models-per-day`) being treated as transient rate limits, so requests rotate from an exhausted API key to a healthy sibling credential. ([#4832](https://github.com/can1357/oh-my-pi/issues/4832))
## [16.3.11] - 2026-07-06
### Fixed
+10 -1
View File
@@ -19,6 +19,7 @@ const SERVER_ERROR_BACKOFF_MS = 20 * 1000; // 20s
const ACCOUNT_RATE_LIMIT_PATTERN =
/\baccount(?:'s)?\b[^\n]{0,80}\brate.?limit\b|\brate.?limit\b[^\n]{0,80}\baccount\b/i;
const INSUFFICIENT_BALANCE_PATTERN = /insufficient.?balance/i;
const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b/i;
/**
* Classify a rate-limit error message into a reason category.
@@ -54,6 +55,10 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
return "QUOTA_EXHAUSTED";
}
if (OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)) {
return "QUOTA_EXHAUSTED";
}
if (
lower.includes("per minute") ||
lower.includes("rate limit") ||
@@ -157,5 +162,9 @@ export function isOpaqueStatusBody(message: string): boolean {
* {@link isUsageLimitOutcome} uses it for the account-rotation decision.
*/
export function matchesUsageLimitText(errorMessage: string): boolean {
return USAGE_LIMIT_PATTERN.test(errorMessage) || ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage);
return (
USAGE_LIMIT_PATTERN.test(errorMessage) ||
ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage) ||
OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)
);
}
+20
View File
@@ -99,6 +99,26 @@ describe("withAuth", () => {
]);
});
it("switches credentials when OpenRouter exhausts the daily free-model allowance", async () => {
const keys: string[] = [];
const result = await withAuth(
ctx => (ctx.error === undefined || !ctx.lastChance ? "exhausted-key" : "healthy-key"),
async key => {
keys.push(key);
if (key === "healthy-key") return "success";
throw Object.assign(
new Error(
"429 Rate limit exceeded: free-models-per-day. Add 10 credits to unlock 1000 free model requests per day",
),
{ status: 429 },
);
},
);
expect(result).toBe("success");
expect(keys).toEqual(["exhausted-key", "healthy-key"]);
});
it("stops retrying when the resolver returns undefined", async () => {
const keys: string[] = [];
const original = authError();