- Add class-based ErrorBoundary component wrapping all 7 main views inside WorkspaceChrome; fallback shows view name, error, reload button - Add 30 new unit tests (boot null-project path × 9, onboarding pure-function logic × 21); all 43 web/lib tests pass - Add web/README.md: architecture, auth flow, 7 views, dev setup, API route pattern, test instructions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
273 lines
9.6 KiB
TypeScript
273 lines
9.6 KiB
TypeScript
// SF Provider Fallback Resolver Tests
|
|
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
|
|
|
|
import assert from "node:assert/strict";
|
|
import type { Api, Model } from "@singularity-forge/ai";
|
|
import { describe, it, vi } from "vitest";
|
|
import type { AuthStorage } from "./auth-storage.js";
|
|
import { FallbackResolver } from "./fallback-resolver.js";
|
|
import type { ModelRegistry } from "./model-registry.js";
|
|
import type {
|
|
FallbackChainEntry,
|
|
SettingsManager,
|
|
} from "./settings-manager.js";
|
|
|
|
function createMockModel(provider: string, id: string): Model<Api> {
|
|
return {
|
|
id,
|
|
name: id,
|
|
api: "openai-completions" as Api,
|
|
provider,
|
|
baseUrl: `https://api.${provider}.com`,
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 16384,
|
|
} as Model<Api>;
|
|
}
|
|
|
|
const zaiModel = createMockModel("zai", "glm-5");
|
|
const alibabaModel = createMockModel("alibaba", "glm-5");
|
|
const openaiModel = createMockModel("openai", "gpt-4.1");
|
|
|
|
const defaultChain: FallbackChainEntry[] = [
|
|
{ provider: "zai", model: "glm-5", priority: 1 },
|
|
{ provider: "alibaba", model: "glm-5", priority: 2 },
|
|
{ provider: "openai", model: "gpt-4.1", priority: 3 },
|
|
];
|
|
|
|
function createResolver(overrides?: {
|
|
enabled?: boolean;
|
|
isProviderAvailable?: (provider: string) => boolean;
|
|
hasAuth?: (provider: string) => boolean;
|
|
isProviderRequestReady?: (provider: string) => boolean;
|
|
find?: (provider: string, modelId: string) => Model<Api> | undefined;
|
|
getAvailable?: () => Model<Api>[];
|
|
}) {
|
|
const settingsManager = {
|
|
getFallbackSettings: () => ({
|
|
enabled: overrides?.enabled ?? true,
|
|
chains: { coding: defaultChain },
|
|
}),
|
|
} as unknown as SettingsManager;
|
|
|
|
const authStorage = {
|
|
markProviderExhausted: vi.fn(),
|
|
isProviderAvailable: overrides?.isProviderAvailable ?? (() => true),
|
|
hasAuth: overrides?.hasAuth ?? (() => true),
|
|
} as unknown as AuthStorage;
|
|
|
|
const modelRegistry = {
|
|
find:
|
|
overrides?.find ??
|
|
((provider: string, modelId: string) => {
|
|
if (provider === "zai" && modelId === "glm-5") return zaiModel;
|
|
if (provider === "alibaba" && modelId === "glm-5") return alibabaModel;
|
|
if (provider === "openai" && modelId === "gpt-4.1") return openaiModel;
|
|
return undefined;
|
|
}),
|
|
isProviderRequestReady:
|
|
overrides?.isProviderRequestReady ?? overrides?.hasAuth ?? (() => true),
|
|
getAvailable:
|
|
overrides?.getAvailable ?? (() => [zaiModel, alibabaModel, openaiModel]),
|
|
} as unknown as ModelRegistry;
|
|
|
|
return {
|
|
resolver: new FallbackResolver(settingsManager, authStorage, modelRegistry),
|
|
authStorage,
|
|
};
|
|
}
|
|
|
|
// ─── findFallback ────────────────────────────────────────────────────────────
|
|
|
|
describe("FallbackResolver — findFallback", () => {
|
|
it("reselects from the current available models when current fails", async () => {
|
|
const { resolver } = createResolver();
|
|
const result = await resolver.findFallback(zaiModel, "quota_exhausted");
|
|
|
|
assert.notEqual(result, null);
|
|
assert.equal(result!.model.provider, "alibaba");
|
|
assert.equal(result!.chainName, "fresh-selection");
|
|
});
|
|
|
|
it("marks current provider as exhausted for rate_limit errors", async () => {
|
|
const { resolver, authStorage } = createResolver();
|
|
await resolver.findFallback(zaiModel, "rate_limit");
|
|
|
|
const fn = authStorage.markProviderExhausted as any;
|
|
assert.equal(fn.mock.calls.length, 1);
|
|
assert.equal(fn.mock.calls[0][0], "zai");
|
|
assert.equal(fn.mock.calls[0][1], "rate_limit");
|
|
});
|
|
|
|
it("does NOT mark provider as exhausted for quota_exhausted (per-model quota)", async () => {
|
|
const { resolver, authStorage } = createResolver();
|
|
await resolver.findFallback(zaiModel, "quota_exhausted");
|
|
|
|
const fn = authStorage.markProviderExhausted as any;
|
|
assert.equal(
|
|
fn.mock.calls.length,
|
|
0,
|
|
"quota_exhausted should not mark entire provider exhausted — other models may have quota",
|
|
);
|
|
});
|
|
|
|
it("skips backed-off providers", async () => {
|
|
const { resolver } = createResolver({
|
|
isProviderAvailable: (provider: string) => provider !== "alibaba",
|
|
});
|
|
|
|
const result = await resolver.findFallback(zaiModel, "quota_exhausted");
|
|
|
|
assert.notEqual(result, null);
|
|
assert.equal(result!.model.provider, "openai");
|
|
assert.equal(result!.model.id, "gpt-4.1");
|
|
});
|
|
|
|
it("returns null when all providers are backed off", async () => {
|
|
const { resolver } = createResolver({
|
|
isProviderAvailable: () => false,
|
|
getAvailable: () => [zaiModel, alibabaModel, openaiModel],
|
|
});
|
|
|
|
const result = await resolver.findFallback(zaiModel, "quota_exhausted");
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
it("returns null when fallback is disabled", async () => {
|
|
const { resolver } = createResolver({ enabled: false });
|
|
const result = await resolver.findFallback(zaiModel, "quota_exhausted");
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
it("reselects from scratch when model is not in any chain", async () => {
|
|
const { resolver } = createResolver();
|
|
const unknownModel = createMockModel("unknown", "some-model");
|
|
const result = await resolver.findFallback(unknownModel, "quota_exhausted");
|
|
assert.notEqual(result, null);
|
|
assert.equal(result!.chainName, "fresh-selection");
|
|
// Should pick an available model with different provider
|
|
assert.notEqual(result!.model.provider, "unknown");
|
|
});
|
|
|
|
it("free selection prefers models with matching reasoning capability", async () => {
|
|
const reasoningModel = createMockModel("openai", "gpt-4.1");
|
|
reasoningModel.reasoning = true;
|
|
const nonReasoningModel = createMockModel("alibaba", "glm-5");
|
|
nonReasoningModel.reasoning = false;
|
|
|
|
const { resolver } = createResolver({
|
|
getAvailable: () => [nonReasoningModel, reasoningModel],
|
|
});
|
|
|
|
const currentModel = createMockModel("unknown", "some-model");
|
|
currentModel.reasoning = true;
|
|
|
|
const result = await resolver.findFallback(currentModel, "quota_exhausted");
|
|
assert.notEqual(result, null);
|
|
assert.equal(result!.model.provider, "openai");
|
|
assert.equal(result!.model.reasoning, true);
|
|
});
|
|
|
|
it("free selection excludes same provider", async () => {
|
|
const sameProviderModel = createMockModel("zai", "glm-5-other");
|
|
const differentProviderModel = createMockModel("alibaba", "glm-5");
|
|
|
|
const { resolver } = createResolver({
|
|
getAvailable: () => [sameProviderModel, differentProviderModel],
|
|
});
|
|
|
|
const result = await resolver.findFallback(zaiModel, "quota_exhausted");
|
|
assert.notEqual(result, null);
|
|
assert.equal(result!.model.provider, "alibaba");
|
|
});
|
|
|
|
it("skips providers that are not request-ready", async () => {
|
|
const { resolver } = createResolver({
|
|
isProviderRequestReady: (provider: string) => provider !== "alibaba",
|
|
});
|
|
|
|
const result = await resolver.findFallback(zaiModel, "quota_exhausted");
|
|
|
|
assert.notEqual(result, null);
|
|
assert.equal(result!.model.provider, "openai");
|
|
});
|
|
|
|
it("allows fallback to external-cli style providers without stored auth", async () => {
|
|
const { resolver } = createResolver({
|
|
hasAuth: () => false,
|
|
isProviderRequestReady: (provider: string) => provider === "alibaba",
|
|
});
|
|
|
|
const result = await resolver.findFallback(zaiModel, "quota_exhausted");
|
|
assert.notEqual(result, null);
|
|
assert.equal(result!.model.provider, "alibaba");
|
|
});
|
|
|
|
it("skips providers with no model in registry", async () => {
|
|
const { resolver } = createResolver({
|
|
getAvailable: () => [openaiModel],
|
|
});
|
|
|
|
const result = await resolver.findFallback(zaiModel, "quota_exhausted");
|
|
|
|
assert.notEqual(result, null);
|
|
assert.equal(result!.model.provider, "openai");
|
|
});
|
|
});
|
|
|
|
// ─── checkForRestoration ─────────────────────────────────────────────────────
|
|
|
|
describe("FallbackResolver — checkForRestoration", () => {
|
|
it("returns null because restoration is disabled", async () => {
|
|
const { resolver } = createResolver();
|
|
const result = await resolver.checkForRestoration(alibabaModel);
|
|
assert.equal(result, null);
|
|
});
|
|
});
|
|
|
|
// ─── getBestAvailable ────────────────────────────────────────────────────────
|
|
|
|
describe("FallbackResolver — getBestAvailable", () => {
|
|
it("returns highest-priority available provider", async () => {
|
|
const { resolver } = createResolver();
|
|
const result = await resolver.getBestAvailable("coding");
|
|
|
|
assert.notEqual(result, null);
|
|
assert.equal(result!.model.provider, "zai");
|
|
});
|
|
|
|
it("skips backed-off providers", async () => {
|
|
const { resolver } = createResolver({
|
|
isProviderAvailable: (provider: string) => provider !== "zai",
|
|
});
|
|
|
|
const result = await resolver.getBestAvailable("coding");
|
|
|
|
assert.notEqual(result, null);
|
|
assert.equal(result!.model.provider, "alibaba");
|
|
});
|
|
|
|
it("returns null for unknown chain", async () => {
|
|
const { resolver } = createResolver();
|
|
const result = await resolver.getBestAvailable("nonexistent");
|
|
assert.equal(result, null);
|
|
});
|
|
});
|
|
|
|
// ─── findChainsForModel ──────────────────────────────────────────────────────
|
|
|
|
describe("FallbackResolver — findChainsForModel", () => {
|
|
it("finds chains containing a model", () => {
|
|
const { resolver } = createResolver();
|
|
const chains = resolver.findChainsForModel("zai", "glm-5");
|
|
assert.deepEqual(chains, ["coding"]);
|
|
});
|
|
|
|
it("returns empty array for model not in any chain", () => {
|
|
const { resolver } = createResolver();
|
|
const chains = resolver.findChainsForModel("unknown", "model");
|
|
assert.deepEqual(chains, []);
|
|
});
|
|
});
|