Spaces:
Paused
Paused
icebear0828 commited on
Commit ·
9c1c54e
1
Parent(s): 1562152
test: add plan-based model routing tests (14 cases)
Browse files- model-store: plan map build, reverse index, plan updates, non-Codex
slug filtering, gpt-oss admission, store debug output
- account-pool: plan-matched acquire, team-only rejection, fallback
on empty plans, plan map update re-routing, preference ordering
Covers the exact code path where free accounts get blocked from models
that the backend has since opened to free tier (gpt-5.4 case).
src/auth/__tests__/plan-routing-acquire.test.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tests that account-pool correctly routes requests based on model→plan mapping.
|
| 3 |
+
*
|
| 4 |
+
* Verifies the critical path: when a model is available to both free and team,
|
| 5 |
+
* free accounts should be selected. When only team has it, free accounts must
|
| 6 |
+
* NOT be used (return null instead of wrong account).
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
| 10 |
+
|
| 11 |
+
// Mock getModelPlanTypes to control plan routing
|
| 12 |
+
const mockGetModelPlanTypes = vi.fn<(id: string) => string[]>(() => []);
|
| 13 |
+
|
| 14 |
+
vi.mock("../../models/model-store.js", () => ({
|
| 15 |
+
getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string),
|
| 16 |
+
}));
|
| 17 |
+
|
| 18 |
+
vi.mock("../../config.js", () => ({
|
| 19 |
+
getConfig: vi.fn(() => ({
|
| 20 |
+
server: { account_strategy: "round_robin" },
|
| 21 |
+
auth: { jwt_token: "" },
|
| 22 |
+
})),
|
| 23 |
+
}));
|
| 24 |
+
|
| 25 |
+
// Control planType by returning it from extractUserProfile
|
| 26 |
+
let profileForToken: Record<string, { chatgpt_plan_type: string; email: string }> = {};
|
| 27 |
+
|
| 28 |
+
vi.mock("../../auth/jwt-utils.js", () => ({
|
| 29 |
+
isTokenExpired: vi.fn(() => false),
|
| 30 |
+
decodeJwtPayload: vi.fn(() => ({})),
|
| 31 |
+
extractChatGptAccountId: vi.fn((token: string) => `aid-${token}`),
|
| 32 |
+
extractUserProfile: vi.fn((token: string) => profileForToken[token] ?? null),
|
| 33 |
+
}));
|
| 34 |
+
|
| 35 |
+
vi.mock("../../utils/jitter.js", () => ({
|
| 36 |
+
jitter: vi.fn((val: number) => val),
|
| 37 |
+
}));
|
| 38 |
+
|
| 39 |
+
vi.mock("fs", () => ({
|
| 40 |
+
readFileSync: vi.fn(() => JSON.stringify({ accounts: [] })),
|
| 41 |
+
writeFileSync: vi.fn(),
|
| 42 |
+
existsSync: vi.fn(() => false),
|
| 43 |
+
mkdirSync: vi.fn(),
|
| 44 |
+
}));
|
| 45 |
+
|
| 46 |
+
import { AccountPool } from "../account-pool.js";
|
| 47 |
+
|
| 48 |
+
function createPool(...accounts: Array<{ token: string; planType: string; email: string }>) {
|
| 49 |
+
// Set up profile mocks before creating pool
|
| 50 |
+
profileForToken = {};
|
| 51 |
+
for (const a of accounts) {
|
| 52 |
+
profileForToken[a.token] = { chatgpt_plan_type: a.planType, email: a.email };
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const pool = new AccountPool();
|
| 56 |
+
for (const a of accounts) {
|
| 57 |
+
pool.addAccount(a.token);
|
| 58 |
+
}
|
| 59 |
+
return pool;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
describe("account-pool plan-based routing", () => {
|
| 63 |
+
beforeEach(() => {
|
| 64 |
+
vi.clearAllMocks();
|
| 65 |
+
profileForToken = {};
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
it("returns null when model only supports team but only free accounts exist", () => {
|
| 69 |
+
mockGetModelPlanTypes.mockReturnValue(["team"]);
|
| 70 |
+
const pool = createPool(
|
| 71 |
+
{ token: "tok-free", planType: "free", email: "free@test.com" },
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
const acquired = pool.acquire({ model: "gpt-5.4" });
|
| 75 |
+
expect(acquired).toBeNull();
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
it("acquires team account when model only supports team", () => {
|
| 79 |
+
mockGetModelPlanTypes.mockReturnValue(["team"]);
|
| 80 |
+
const pool = createPool(
|
| 81 |
+
{ token: "tok-free", planType: "free", email: "free@test.com" },
|
| 82 |
+
{ token: "tok-team", planType: "team", email: "team@test.com" },
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
const acquired = pool.acquire({ model: "gpt-5.4" });
|
| 86 |
+
expect(acquired).not.toBeNull();
|
| 87 |
+
expect(acquired!.token).toBe("tok-team");
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
it("uses any account when model has no known plan requirements", () => {
|
| 91 |
+
mockGetModelPlanTypes.mockReturnValue([]);
|
| 92 |
+
const pool = createPool(
|
| 93 |
+
{ token: "tok-free", planType: "free", email: "free@test.com" },
|
| 94 |
+
);
|
| 95 |
+
|
| 96 |
+
const acquired = pool.acquire({ model: "unknown-model" });
|
| 97 |
+
expect(acquired).not.toBeNull();
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
it("after plan map update, free account can access previously team-only model", () => {
|
| 101 |
+
const pool = createPool(
|
| 102 |
+
{ token: "tok-free", planType: "free", email: "free@test.com" },
|
| 103 |
+
);
|
| 104 |
+
|
| 105 |
+
// Initially: gpt-5.4 only for team
|
| 106 |
+
mockGetModelPlanTypes.mockReturnValue(["team"]);
|
| 107 |
+
const before = pool.acquire({ model: "gpt-5.4" });
|
| 108 |
+
expect(before).toBeNull(); // blocked
|
| 109 |
+
|
| 110 |
+
// Backend updates: gpt-5.4 now available for free too
|
| 111 |
+
mockGetModelPlanTypes.mockReturnValue(["free", "team"]);
|
| 112 |
+
const after = pool.acquire({ model: "gpt-5.4" });
|
| 113 |
+
expect(after).not.toBeNull();
|
| 114 |
+
expect(after!.token).toBe("tok-free");
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
it("prefers plan-matched accounts over others", () => {
|
| 118 |
+
mockGetModelPlanTypes.mockReturnValue(["team"]);
|
| 119 |
+
const pool = createPool(
|
| 120 |
+
{ token: "tok-free1", planType: "free", email: "free1@test.com" },
|
| 121 |
+
{ token: "tok-free2", planType: "free", email: "free2@test.com" },
|
| 122 |
+
{ token: "tok-team", planType: "team", email: "team@test.com" },
|
| 123 |
+
);
|
| 124 |
+
|
| 125 |
+
const acquired = pool.acquire({ model: "gpt-5.4" });
|
| 126 |
+
expect(acquired).not.toBeNull();
|
| 127 |
+
expect(acquired!.token).toBe("tok-team");
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
it("acquires free account when model supports both free and team", () => {
|
| 131 |
+
mockGetModelPlanTypes.mockReturnValue(["free", "team"]);
|
| 132 |
+
const pool = createPool(
|
| 133 |
+
{ token: "tok-free", planType: "free", email: "free@test.com" },
|
| 134 |
+
{ token: "tok-team", planType: "team", email: "team@test.com" },
|
| 135 |
+
);
|
| 136 |
+
|
| 137 |
+
const acquired = pool.acquire({ model: "gpt-5.4" });
|
| 138 |
+
expect(acquired).not.toBeNull();
|
| 139 |
+
// Both are valid candidates, should get one of them
|
| 140 |
+
expect(["tok-free", "tok-team"]).toContain(acquired!.token);
|
| 141 |
+
});
|
| 142 |
+
});
|
src/models/__tests__/plan-routing.test.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tests for plan-based model routing.
|
| 3 |
+
*
|
| 4 |
+
* Verifies that:
|
| 5 |
+
* 1. applyBackendModelsForPlan correctly builds planModelMap
|
| 6 |
+
* 2. getModelPlanTypes returns correct plan associations
|
| 7 |
+
* 3. When both free and team plans include a model, both are returned
|
| 8 |
+
* 4. Account pool respects plan routing when acquiring accounts
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
| 12 |
+
|
| 13 |
+
vi.mock("../../config.js", () => ({
|
| 14 |
+
getConfig: vi.fn(() => ({
|
| 15 |
+
server: {},
|
| 16 |
+
model: {},
|
| 17 |
+
api: { base_url: "https://chatgpt.com/backend-api" },
|
| 18 |
+
client: { app_version: "1.0.0" },
|
| 19 |
+
})),
|
| 20 |
+
}));
|
| 21 |
+
|
| 22 |
+
vi.mock("fs", async (importOriginal) => {
|
| 23 |
+
const actual = await importOriginal<typeof import("fs")>();
|
| 24 |
+
return {
|
| 25 |
+
...actual,
|
| 26 |
+
readFileSync: vi.fn(() => "models: []"),
|
| 27 |
+
writeFileSync: vi.fn(),
|
| 28 |
+
writeFile: vi.fn((_p: string, _d: string, _e: string, cb: (err: Error | null) => void) => cb(null)),
|
| 29 |
+
existsSync: vi.fn(() => false),
|
| 30 |
+
mkdirSync: vi.fn(),
|
| 31 |
+
};
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
vi.mock("js-yaml", () => ({
|
| 35 |
+
default: {
|
| 36 |
+
load: vi.fn(() => ({ models: [], aliases: {} })),
|
| 37 |
+
dump: vi.fn(() => ""),
|
| 38 |
+
},
|
| 39 |
+
}));
|
| 40 |
+
|
| 41 |
+
import {
|
| 42 |
+
loadStaticModels,
|
| 43 |
+
applyBackendModelsForPlan,
|
| 44 |
+
getModelPlanTypes,
|
| 45 |
+
getModelStoreDebug,
|
| 46 |
+
} from "../model-store.js";
|
| 47 |
+
|
| 48 |
+
// Minimal backend model entry matching what Codex API returns
|
| 49 |
+
function makeModel(slug: string) {
|
| 50 |
+
return { slug, id: slug, name: slug };
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
describe("plan-based model routing", () => {
|
| 54 |
+
beforeEach(() => {
|
| 55 |
+
// Reset model store state by reloading empty static catalog
|
| 56 |
+
loadStaticModels();
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
it("applyBackendModelsForPlan registers models for a plan", () => {
|
| 60 |
+
applyBackendModelsForPlan("free", [
|
| 61 |
+
makeModel("gpt-5.2-codex"),
|
| 62 |
+
makeModel("gpt-5.4"),
|
| 63 |
+
]);
|
| 64 |
+
|
| 65 |
+
expect(getModelPlanTypes("gpt-5.2-codex")).toContain("free");
|
| 66 |
+
expect(getModelPlanTypes("gpt-5.4")).toContain("free");
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
it("models available in both plans return both plan types", () => {
|
| 70 |
+
applyBackendModelsForPlan("free", [
|
| 71 |
+
makeModel("gpt-5.2-codex"),
|
| 72 |
+
makeModel("gpt-5.4"),
|
| 73 |
+
]);
|
| 74 |
+
applyBackendModelsForPlan("team", [
|
| 75 |
+
makeModel("gpt-5.2-codex"),
|
| 76 |
+
makeModel("gpt-5.4"),
|
| 77 |
+
makeModel("gpt-5.4-mini"),
|
| 78 |
+
]);
|
| 79 |
+
|
| 80 |
+
const plans54 = getModelPlanTypes("gpt-5.4");
|
| 81 |
+
expect(plans54).toContain("free");
|
| 82 |
+
expect(plans54).toContain("team");
|
| 83 |
+
|
| 84 |
+
const plansCodex = getModelPlanTypes("gpt-5.2-codex");
|
| 85 |
+
expect(plansCodex).toContain("free");
|
| 86 |
+
expect(plansCodex).toContain("team");
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
it("model only in team plan does not include free", () => {
|
| 90 |
+
applyBackendModelsForPlan("free", [
|
| 91 |
+
makeModel("gpt-5.2-codex"),
|
| 92 |
+
]);
|
| 93 |
+
applyBackendModelsForPlan("team", [
|
| 94 |
+
makeModel("gpt-5.2-codex"),
|
| 95 |
+
makeModel("gpt-5.4"),
|
| 96 |
+
]);
|
| 97 |
+
|
| 98 |
+
const plans54 = getModelPlanTypes("gpt-5.4");
|
| 99 |
+
expect(plans54).toContain("team");
|
| 100 |
+
expect(plans54).not.toContain("free");
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
it("replacing a plan's models updates the index", () => {
|
| 104 |
+
// Initially free doesn't have gpt-5.4
|
| 105 |
+
applyBackendModelsForPlan("free", [makeModel("gpt-5.2-codex")]);
|
| 106 |
+
expect(getModelPlanTypes("gpt-5.4")).not.toContain("free");
|
| 107 |
+
|
| 108 |
+
// Backend now returns gpt-5.4 for free → re-fetch
|
| 109 |
+
applyBackendModelsForPlan("free", [
|
| 110 |
+
makeModel("gpt-5.2-codex"),
|
| 111 |
+
makeModel("gpt-5.4"),
|
| 112 |
+
]);
|
| 113 |
+
expect(getModelPlanTypes("gpt-5.4")).toContain("free");
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
it("unknown model returns empty plan list", () => {
|
| 117 |
+
applyBackendModelsForPlan("free", [makeModel("gpt-5.2-codex")]);
|
| 118 |
+
expect(getModelPlanTypes("nonexistent-model")).toEqual([]);
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
it("non-Codex model slugs are filtered out", () => {
|
| 122 |
+
applyBackendModelsForPlan("free", [
|
| 123 |
+
makeModel("gpt-5.2-codex"),
|
| 124 |
+
makeModel("research"), // not Codex-compatible
|
| 125 |
+
makeModel("gpt-5-2"), // hyphen instead of dot
|
| 126 |
+
makeModel("some-internal-slug"), // not Codex-compatible
|
| 127 |
+
]);
|
| 128 |
+
|
| 129 |
+
expect(getModelPlanTypes("gpt-5.2-codex")).toContain("free");
|
| 130 |
+
expect(getModelPlanTypes("research")).toEqual([]);
|
| 131 |
+
expect(getModelPlanTypes("gpt-5-2")).toEqual([]);
|
| 132 |
+
expect(getModelPlanTypes("some-internal-slug")).toEqual([]);
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
it("gpt-oss-* models are admitted", () => {
|
| 136 |
+
applyBackendModelsForPlan("free", [
|
| 137 |
+
makeModel("gpt-oss-120b"),
|
| 138 |
+
makeModel("gpt-oss-20b"),
|
| 139 |
+
]);
|
| 140 |
+
|
| 141 |
+
expect(getModelPlanTypes("gpt-oss-120b")).toContain("free");
|
| 142 |
+
expect(getModelPlanTypes("gpt-oss-20b")).toContain("free");
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
it("planMap in store info reflects current state", () => {
|
| 146 |
+
applyBackendModelsForPlan("free", [
|
| 147 |
+
makeModel("gpt-5.2-codex"),
|
| 148 |
+
makeModel("gpt-5.4"),
|
| 149 |
+
]);
|
| 150 |
+
applyBackendModelsForPlan("team", [
|
| 151 |
+
makeModel("gpt-5.4"),
|
| 152 |
+
]);
|
| 153 |
+
|
| 154 |
+
const info = getModelStoreDebug();
|
| 155 |
+
expect(info.planMap.free).toContain("gpt-5.2-codex");
|
| 156 |
+
expect(info.planMap.free).toContain("gpt-5.4");
|
| 157 |
+
expect(info.planMap.team).toContain("gpt-5.4");
|
| 158 |
+
expect(info.planMap.team).not.toContain("gpt-5.2-codex");
|
| 159 |
+
});
|
| 160 |
+
});
|