| import { describe, expect, it } from "vitest"; |
| import { |
| resolveActiveFallbackState, |
| resolveFallbackTransition, |
| type FallbackNoticeState, |
| } from "./fallback-state.js"; |
|
|
| const baseAttempt = { |
| provider: "fireworks", |
| model: "fireworks/minimax-m2p5", |
| error: "Provider fireworks is in cooldown (all profiles unavailable)", |
| reason: "rate_limit" as const, |
| }; |
|
|
| describe("fallback-state", () => { |
| it("treats fallback as active only when state matches selected and active refs", () => { |
| const state: FallbackNoticeState = { |
| fallbackNoticeSelectedModel: "fireworks/minimax-m2p5", |
| fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", |
| fallbackNoticeReason: "rate limit", |
| }; |
|
|
| const resolved = resolveActiveFallbackState({ |
| selectedModelRef: "fireworks/minimax-m2p5", |
| activeModelRef: "deepinfra/moonshotai/Kimi-K2.5", |
| state, |
| }); |
|
|
| expect(resolved.active).toBe(true); |
| expect(resolved.reason).toBe("rate limit"); |
| }); |
|
|
| it("does not treat runtime drift as fallback when persisted state does not match", () => { |
| const state: FallbackNoticeState = { |
| fallbackNoticeSelectedModel: "anthropic/claude", |
| fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", |
| fallbackNoticeReason: "rate limit", |
| }; |
|
|
| const resolved = resolveActiveFallbackState({ |
| selectedModelRef: "fireworks/minimax-m2p5", |
| activeModelRef: "deepinfra/moonshotai/Kimi-K2.5", |
| state, |
| }); |
|
|
| expect(resolved.active).toBe(false); |
| expect(resolved.reason).toBeUndefined(); |
| }); |
|
|
| it("marks fallback transition when selected->active pair changes", () => { |
| const resolved = resolveFallbackTransition({ |
| selectedProvider: "fireworks", |
| selectedModel: "fireworks/minimax-m2p5", |
| activeProvider: "deepinfra", |
| activeModel: "moonshotai/Kimi-K2.5", |
| attempts: [baseAttempt], |
| state: {}, |
| }); |
|
|
| expect(resolved.fallbackActive).toBe(true); |
| expect(resolved.fallbackTransitioned).toBe(true); |
| expect(resolved.fallbackCleared).toBe(false); |
| expect(resolved.stateChanged).toBe(true); |
| expect(resolved.reasonSummary).toBe("rate limit"); |
| expect(resolved.nextState.selectedModel).toBe("fireworks/minimax-m2p5"); |
| expect(resolved.nextState.activeModel).toBe("deepinfra/moonshotai/Kimi-K2.5"); |
| }); |
|
|
| it("normalizes fallback reason whitespace for summaries", () => { |
| const resolved = resolveFallbackTransition({ |
| selectedProvider: "fireworks", |
| selectedModel: "fireworks/minimax-m2p5", |
| activeProvider: "deepinfra", |
| activeModel: "moonshotai/Kimi-K2.5", |
| attempts: [{ ...baseAttempt, reason: "rate_limit\n\tburst" }], |
| state: {}, |
| }); |
|
|
| expect(resolved.reasonSummary).toBe("rate limit burst"); |
| }); |
|
|
| it("refreshes reason when fallback remains active with same model pair", () => { |
| const resolved = resolveFallbackTransition({ |
| selectedProvider: "fireworks", |
| selectedModel: "fireworks/minimax-m2p5", |
| activeProvider: "deepinfra", |
| activeModel: "moonshotai/Kimi-K2.5", |
| attempts: [{ ...baseAttempt, reason: "timeout" }], |
| state: { |
| fallbackNoticeSelectedModel: "fireworks/minimax-m2p5", |
| fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", |
| fallbackNoticeReason: "rate limit", |
| }, |
| }); |
|
|
| expect(resolved.fallbackTransitioned).toBe(false); |
| expect(resolved.stateChanged).toBe(true); |
| expect(resolved.nextState.reason).toBe("timeout"); |
| }); |
|
|
| it("marks fallback as cleared when runtime returns to selected model", () => { |
| const resolved = resolveFallbackTransition({ |
| selectedProvider: "fireworks", |
| selectedModel: "fireworks/minimax-m2p5", |
| activeProvider: "fireworks", |
| activeModel: "fireworks/minimax-m2p5", |
| attempts: [], |
| state: { |
| fallbackNoticeSelectedModel: "fireworks/minimax-m2p5", |
| fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", |
| fallbackNoticeReason: "rate limit", |
| }, |
| }); |
|
|
| expect(resolved.fallbackActive).toBe(false); |
| expect(resolved.fallbackCleared).toBe(true); |
| expect(resolved.fallbackTransitioned).toBe(false); |
| expect(resolved.stateChanged).toBe(true); |
| expect(resolved.nextState.selectedModel).toBeUndefined(); |
| expect(resolved.nextState.activeModel).toBeUndefined(); |
| expect(resolved.nextState.reason).toBeUndefined(); |
| }); |
| }); |
|
|