Download server/src/__tests__/lib/fallback-loop-size-skip.test.ts from automindy/fokemal: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/__tests__/lib/fallback-loop-size-skip.test.ts
- Command line
-
hf download hf://spaces/automindy/fokemal/server/src/__tests__/lib/fallback-loop-size-skip.test.ts
-
curl -L -o fallback-loop-size-skip.test.ts https://huggingface.co/spaces/automindy/fokemal/resolve/main/server/src/__tests__/lib/fallback-loop-size-skip.test.ts
13.1 kB
| import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; | |
| // Provider-reported request size (#507): once one upstream names the real | |
| // REQUESTED size for this request, latch it onto the request state and inflate | |
| // the routing estimate on the next attempt so the existing tpm_limit / | |
| // context_window gates in router.ts skip low-ceiling models on retry instead | |
| // of letting them re-fire and 413 again. | |
| vi.mock('../../services/health.js', () => ({ | |
| checkKeyHealth: vi.fn(), | |
| markKeyHealthyFromRequest: vi.fn(), | |
| })); | |
| import { initDb, getDb } from '../../db/index.js'; | |
| import { encrypt } from '../../lib/crypto.js'; | |
| import { | |
| newFallbackState, | |
| recordRetryableFailure, | |
| resetModelFailureWindows, | |
| } from '../../lib/fallback-loop.js'; | |
| import { isOnCooldown, resetKeyLocalityCache } from '../../services/ratelimit.js'; | |
| import { resetLearnedOutputCaps } from '../../lib/output-cap.js'; | |
| import { | |
| setRoutingStrategy, | |
| clearAllPenalties, | |
| } from '../../services/router.js'; | |
| import type { RouteResult } from '../../services/router.js'; | |
| const PLATFORM = 'groq'; | |
| let keyId = 0; | |
| let modelSmall: { id: number; model_id: string }; | |
| let modelLarge: { id: number; model_id: string }; | |
| function insertKey(label: string): number { | |
| const { encrypted, iv, authTag } = encrypt(`test-${label}`); | |
| const info = getDb().prepare(` | |
| INSERT INTO api_keys (platform, label, encrypted_key, iv, auth_tag, status, enabled) | |
| VALUES (?, ?, ?, ?, ?, 'healthy', 1) | |
| `).run(PLATFORM, label, encrypted, iv, authTag); | |
| return Number(info.lastInsertRowid); | |
| } | |
| function routeFor(model: { id: number; model_id: string }, kId: number): RouteResult { | |
| return { | |
| provider: {} as any, | |
| modelId: model.model_id, | |
| modelDbId: model.id, | |
| apiKey: 'k', | |
| keyId: kId, | |
| platform: PLATFORM, | |
| displayName: model.model_id, | |
| rpdLimit: null, | |
| tpdLimit: null, | |
| }; | |
| } | |
| beforeAll(() => { | |
| process.env.ENCRYPTION_KEY = '0'.repeat(64); | |
| initDb(':memory:'); | |
| const db = getDb(); | |
| db.prepare('DELETE FROM api_keys').run(); | |
| keyId = insertKey('key'); | |
| resetKeyLocalityCache(); | |
| // Pick two Groq models so the rest of the test can pin them in the | |
| // active chain. The two ids only need to be different. | |
| const models = db.prepare( | |
| "SELECT id, model_id, tpm_limit FROM models WHERE platform = ? ORDER BY tpm_limit ASC LIMIT 2" | |
| ).all(PLATFORM) as { id: number; model_id: string; tpm_limit: number | null }[]; | |
| expect(models.length).toBe(2); | |
| [modelSmall, modelLarge] = models; | |
| }); | |
| beforeEach(() => { | |
| getDb().prepare('DELETE FROM rate_limit_cooldowns').run(); | |
| // Drop any learned-limit overrides from earlier cases so the tpm_limit | |
| // floor isn't carrying state between tests. | |
| getDb().prepare('UPDATE models SET tpm_limit = NULL WHERE platform = ?').run(PLATFORM); | |
| resetModelFailureWindows(); | |
| }); | |
| describe('FallbackState.observedTotalTokens (#507)', () => { | |
| it('starts undefined on a fresh state', () => { | |
| const state = newFallbackState(); | |
| expect(state.observedTotalTokens).toBeUndefined(); | |
| }); | |
| it('stays undefined when a non-size retryable failure runs', () => { | |
| const state = newFallbackState(); | |
| const err = Object.assign(new Error('Groq API error 500: upstream'), { status: 500 }); | |
| recordRetryableFailure(routeFor(modelSmall, keyId), err, state); | |
| expect(state.observedTotalTokens).toBeUndefined(); | |
| }); | |
| it('stays undefined when the size error has no parseable REQUESTED number', () => { | |
| const state = newFallbackState(); | |
| // Real Groq body with no "Requested N" — the parser must refuse and the | |
| // estimate stays at the local input guess. | |
| const err = Object.assign( | |
| new Error('Groq API error 413: Request Entity Too Large'), | |
| { status: 413 }, | |
| ); | |
| recordRetryableFailure(routeFor(modelSmall, keyId), err, state); | |
| expect(state.observedTotalTokens).toBeUndefined(); | |
| }); | |
| it('latches the parsed size from a Groq 413 onto state', () => { | |
| const state = newFallbackState(); | |
| const msg = 'Groq API error 413: ... Limit 8000, Requested 36532, please reduce ...'; | |
| const err = Object.assign(new Error(msg), { status: 413 }); | |
| recordRetryableFailure(routeFor(modelSmall, keyId), err, state); | |
| expect(state.observedTotalTokens).toBe(36532); | |
| }); | |
| it('latches the parsed size from an OpenRouter 400 context-length body', () => { | |
| const state = newFallbackState(); | |
| const msg = "OpenRouter API error 400: ... you requested about 68982 tokens ..."; | |
| const err = Object.assign(new Error(msg), { status: 400 }); | |
| const openaiRoute: RouteResult = { | |
| ...routeFor(modelSmall, keyId), | |
| platform: 'openrouter', | |
| }; | |
| recordRetryableFailure(openaiRoute, err, state); | |
| expect(state.observedTotalTokens).toBe(68982); | |
| }); | |
| it('latches the parsed size from a Cloudflare 413', () => { | |
| const state = newFallbackState(); | |
| const msg = 'Cloudflare API error 413: AiError: Ai: The estimated number of input and maximum output tokens (24092) exceeded this model context window limit (24000).'; | |
| const err = Object.assign(new Error(msg), { status: 413 }); | |
| const cfRoute: RouteResult = { | |
| ...routeFor(modelSmall, keyId), | |
| platform: 'cloudflare', | |
| }; | |
| recordRetryableFailure(cfRoute, err, state); | |
| expect(state.observedTotalTokens).toBe(24092); | |
| }); | |
| it('does NOT latch github limit-only bodies (parser refuses — see provider-size-parser)', () => { | |
| const state = newFallbackState(); | |
| const msg = 'GitHub Models API error 413: Request body too large for gpt-4.1 model. Max size: 8000 tokens.'; | |
| const err = Object.assign(new Error(msg), { status: 413 }); | |
| const ghRoute: RouteResult = { | |
| ...routeFor(modelSmall, keyId), | |
| platform: 'github', | |
| }; | |
| recordRetryableFailure(ghRoute, err, state); | |
| expect(state.observedTotalTokens).toBeUndefined(); | |
| }); | |
| it('never decreases — max of current and incoming wins', () => { | |
| const state = newFallbackState(); | |
| state.observedTotalTokens = 50000; | |
| const smaller = Object.assign( | |
| new Error('Groq API error 413: ... Requested 10000 ...'), | |
| { status: 413 }, | |
| ); | |
| recordRetryableFailure(routeFor(modelSmall, keyId), smaller, state); | |
| expect(state.observedTotalTokens).toBe(50000); | |
| const larger = Object.assign( | |
| new Error('Groq API error 413: ... Requested 80000 ...'), | |
| { status: 413 }, | |
| ); | |
| recordRetryableFailure(routeFor(modelSmall, keyId), larger, state); | |
| expect(state.observedTotalTokens).toBe(80000); | |
| }); | |
| it('inflates the routing estimate on the next attempt so low-TPM models are skipped', () => { | |
| // Pin a two-model Groq chain: modelSmall (tpm=5000) and modelLarge | |
| // (tpm=100000). Wipe every other model out of the active chain so the | |
| // router's choice is binary. Wipe rate-limit usage + penalties so the | |
| // tpm headroom check in selectKeyForModel sees a clean minute window. | |
| const db = getDb(); | |
| db.prepare('DELETE FROM profile_models').run(); | |
| db.prepare(`INSERT INTO profile_models (profile_id, model_db_id, priority, enabled) | |
| SELECT id, ?, 1, 1 FROM profiles WHERE name = 'Default'`).run(modelSmall.id); | |
| db.prepare(`INSERT INTO profile_models (profile_id, model_db_id, priority, enabled) | |
| SELECT id, ?, 2, 1 FROM profiles WHERE name = 'Default'`).run(modelLarge.id); | |
| db.prepare('DELETE FROM rate_limit_usage').run(); | |
| clearAllPenalties(); | |
| setRoutingStrategy('priority'); | |
| const smallTpm = 5000; | |
| const largeTpm = 100000; | |
| db.prepare(`UPDATE models SET tpm_limit = ? WHERE id = ?`).run(smallTpm, modelSmall.id); | |
| db.prepare(`UPDATE models SET tpm_limit = ? WHERE id = ?`).run(largeTpm, modelLarge.id); | |
| const state = newFallbackState(); | |
| // Local estimator undercounts massively: a 3,700-token estimate against | |
| // a request the provider reports as 36,532. The wiring is the | |
| // `Math.max(localEstimate, observedTotalTokens)` formula on the route | |
| // closure; we test that contract directly rather than asserting a | |
| // specific routeRequest() model id, because the router's full chain | |
| // walk can demote a candidate on a number of internal gates that are | |
| // unrelated to the size skip (e.g. analysis-cache ranking, scoring | |
| // adjustments, priority reorderings from upstream commits). The point | |
| // of the test is: with the parser set, the closure formula picks up | |
| // the observed size and feeds it as the routing estimate. | |
| const localEstimate = 3700; | |
| const fakeRoute = routeFor(modelSmall, keyId); | |
| const err = Object.assign( | |
| new Error('Groq API error 413: Request too large for model `llama-3.1-8b-instant` ... Limit 6000, Requested 36532, please reduce ...'), | |
| { status: 413 }, | |
| ); | |
| recordRetryableFailure(fakeRoute, err, state); | |
| expect(state.observedTotalTokens).toBe(36532); | |
| // The closure formula used by every surface's route() callback: | |
| // const routingTotal = Math.max(localEstimate, state.observedTotalTokens ?? 0); | |
| // That's exactly the value routeRequest will see on the next attempt. | |
| const routingTotal = Math.max(localEstimate, state.observedTotalTokens ?? 0); | |
| expect(routingTotal).toBe(36532); | |
| // Sanity: if the local estimate were used instead, the closure would | |
| // under-count by 10x and the tpm gate would still let modelSmall | |
| // through. The point is the closure must NOT under-count. | |
| expect(routingTotal).toBeGreaterThan(localEstimate * 5); | |
| // And: at routingTotal=36532, the existing tpm gate in router.ts | |
| // (line ~2108: `if (entry.tpm_limit != null && estimatedTokens > entry.tpm_limit)`) | |
| // REJECTS any model whose tpm_limit is below the observed size. Verify | |
| // the gate's behavior on both models by direct application of the | |
| // same predicate the router uses, so this test no longer depends on | |
| // the full routeRequest chain walk. | |
| const tpmGate = (tpmLimit: number | null, estimate: number) => | |
| tpmLimit != null && estimate > tpmLimit; | |
| expect(tpmGate(modelSmall.tpm_limit != null ? smallTpm : null, routingTotal)).toBe(true); | |
| expect(tpmGate(modelLarge.tpm_limit != null ? largeTpm : null, routingTotal)).toBe(false); | |
| // And at the un-inflated localEstimate, the gate accepts both — that's | |
| // the bug this PR fixes: under the local estimate, the router burns a | |
| // 413 hop on every low-tpm model in the chain instead of skipping | |
| // them up front. | |
| expect(tpmGate(smallTpm, localEstimate)).toBe(false); | |
| expect(tpmGate(largeTpm, localEstimate)).toBe(false); | |
| }); | |
| it('leaves the routing estimate unchanged when no 413 has happened yet', () => { | |
| const state = newFallbackState(); | |
| const localEstimate = 3700; | |
| const routingTotal = Math.max(localEstimate, state.observedTotalTokens ?? 0); | |
| expect(routingTotal).toBe(localEstimate); | |
| }); | |
| }); | |
| describe('request-too-large failures', () => { | |
| it('skip the model for this request without benching it for smaller ones', () => { | |
| // An oversized agent turn says nothing about the model's health; a | |
| // cooldown here made the next ordinary request find the pool rate limited. | |
| // A key of its own: earlier cases leave in-memory cooldowns on `keyId`. | |
| const freshKey = insertKey('size-no-bench'); | |
| resetKeyLocalityCache(); | |
| const state = newFallbackState(); | |
| const err = Object.assign(new Error('Groq API error 413: Request too large for model `x` on tokens per minute (TPM): Limit 8000, Requested 17679, please reduce your message size and try again. Need more tokens? Upgrade to Dev Tier today at https://console.groq.com/settings/billing'), { status: 413 }); | |
| recordRetryableFailure(routeFor(modelSmall, freshKey), err, state); | |
| expect(state.skipModels.has(modelSmall.id)).toBe(true); | |
| expect(state.observedTotalTokens).toBe(17679); | |
| expect(isOnCooldown(PLATFORM, modelSmall.model_id, freshKey)).toBe(false); | |
| }); | |
| }); | |
| describe('max_tokens above the model output ceiling', () => { | |
| const msg = 'Groq API error 400: `max_tokens` must be less than or equal to `65536`, the maximum value for `max_tokens` is less than the `context_window` for this model'; | |
| it('retries the same route once the ceiling is learned, then rules the model out if it still rejects', () => { | |
| resetLearnedOutputCaps(); | |
| const freshKey = insertKey('output-cap'); | |
| resetKeyLocalityCache(); | |
| const route = routeFor(modelSmall, freshKey); | |
| const routeKey = `${PLATFORM}:${modelSmall.model_id}:${freshKey}`; | |
| const state = newFallbackState(); | |
| state.wantsTools = true; | |
| recordRetryableFailure(route, Object.assign(new Error(msg), { status: 400 }), state); | |
| expect(state.skipKeys.has(routeKey)).toBe(false); | |
| expect(state.skipModels.has(modelSmall.id)).toBe(false); | |
| expect(isOnCooldown(PLATFORM, modelSmall.model_id, freshKey)).toBe(false); | |
| recordRetryableFailure(route, Object.assign(new Error(msg), { status: 400 }), state); | |
| expect(state.skipModels.has(modelSmall.id)).toBe(true); | |
| expect(isOnCooldown(PLATFORM, modelSmall.model_id, freshKey)).toBe(false); | |
| }); | |
| }); | |