icebear icebear0828 commited on
Commit
fce5280
·
unverified ·
1 Parent(s): 4f93977

feat: add POST /admin/refresh-models endpoint + plan routing integration tests (#104)

Browse files

Model-fetcher's ~1h cache caused stale plan maps that blocked valid
model+plan combinations (e.g. free accounts couldn't use gpt-5.4 after
it was opened to free tier). This adds a manual refresh endpoint and
integration tests covering the full proxy handler plan routing path.

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -6,6 +6,11 @@
6
 
7
  ## [Unreleased]
8
 
 
 
 
 
 
9
  ### Changed
10
 
11
  - Electron 桌面端从独立分支迁移为 npm workspace(`packages/electron/`),消除 master→electron 分支同步冲突;删除 `sync-electron.yml`,release.yml 改为 workspace 感知构建
 
6
 
7
  ## [Unreleased]
8
 
9
+ ### Added
10
+
11
+ - `POST /admin/refresh-models` 端点:手动触发模型列表刷新,解决 model-fetcher ~1h 缓存过时导致新模型不可用的问题;支持 Bearer auth(当配置 proxy_api_key 时)
12
+ - Plan routing integration tests:通过 proxy handler 完整路径验证 free/team 账号的模型路由(7 cases),覆盖 plan map 更新后请求解除阻塞的场景
13
+
14
  ### Changed
15
 
16
  - Electron 桌面端从独立分支迁移为 npm workspace(`packages/electron/`),消除 master→electron 分支同步冲突;删除 `sync-electron.yml`,release.yml 改为 workspace 感知构建
src/routes/__tests__/model-plan-routing.test.ts ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Integration tests for plan-based model routing through the proxy layer.
3
+ *
4
+ * Verifies that:
5
+ * 1. Account pool correctly filters by model plan types when proxy handler acquires an account
6
+ * 2. Plan map updates unblock previously rejected requests
7
+ * 3. POST /admin/refresh-models triggers immediate model refresh
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
11
+ import { Hono } from "hono";
12
+
13
+ // ── Mocks (before imports) ──────────────────────────────────────────
14
+
15
+ const mockConfig = {
16
+ server: { proxy_api_key: null as string | null },
17
+ model: { default: "gpt-5.2-codex" },
18
+ auth: {
19
+ jwt_token: undefined as string | undefined,
20
+ rotation_strategy: "least_used" as const,
21
+ rate_limit_backoff_seconds: 60,
22
+ },
23
+ };
24
+
25
+ vi.mock("../../config.js", () => ({
26
+ getConfig: vi.fn(() => mockConfig),
27
+ }));
28
+
29
+ vi.mock("../../paths.js", () => ({
30
+ getDataDir: vi.fn(() => "/tmp/test-plan-routing"),
31
+ getConfigDir: vi.fn(() => "/tmp/test-plan-routing-config"),
32
+ }));
33
+
34
+ vi.mock("fs", async (importOriginal) => {
35
+ const actual = await importOriginal<typeof import("fs")>();
36
+ return {
37
+ ...actual,
38
+ readFileSync: vi.fn(() => "models: []"),
39
+ writeFileSync: vi.fn(),
40
+ writeFile: vi.fn(
41
+ (_p: string, _d: string, _e: string, cb: (err: Error | null) => void) =>
42
+ cb(null),
43
+ ),
44
+ existsSync: vi.fn(() => false),
45
+ mkdirSync: vi.fn(),
46
+ renameSync: vi.fn(),
47
+ };
48
+ });
49
+
50
+ vi.mock("js-yaml", () => ({
51
+ default: {
52
+ load: vi.fn(() => ({ models: [], aliases: {} })),
53
+ dump: vi.fn(() => ""),
54
+ },
55
+ }));
56
+
57
+ vi.mock("../../auth/jwt-utils.js", () => ({
58
+ decodeJwtPayload: vi.fn(() => ({
59
+ exp: Math.floor(Date.now() / 1000) + 3600,
60
+ })),
61
+ extractChatGptAccountId: vi.fn((token: string) => `acct-${token}`),
62
+ extractUserProfile: vi.fn(() => null),
63
+ isTokenExpired: vi.fn(() => false),
64
+ }));
65
+
66
+ vi.mock("../../models/model-fetcher.js", () => ({
67
+ triggerImmediateRefresh: vi.fn(),
68
+ startModelRefresh: vi.fn(),
69
+ stopModelRefresh: vi.fn(),
70
+ }));
71
+
72
+ const mockCreateResponse = vi.fn();
73
+
74
+ vi.mock("../../proxy/codex-api.js", () => ({
75
+ CodexApi: vi.fn().mockImplementation(() => ({
76
+ createResponse: mockCreateResponse,
77
+ })),
78
+ CodexApiError: class extends Error {
79
+ status: number;
80
+ body: string;
81
+ constructor(status: number, body: string) {
82
+ super(body);
83
+ this.name = "CodexApiError";
84
+ this.status = status;
85
+ this.body = body;
86
+ }
87
+ },
88
+ }));
89
+
90
+ vi.mock("../../utils/retry.js", () => ({
91
+ withRetry: vi.fn(async (fn: () => Promise<unknown>) => fn()),
92
+ }));
93
+
94
+ // ── Imports ─────────────────────────────────────────────────────────
95
+
96
+ import { AccountPool } from "../../auth/account-pool.js";
97
+ import {
98
+ loadStaticModels,
99
+ applyBackendModelsForPlan,
100
+ } from "../../models/model-store.js";
101
+ import { extractUserProfile } from "../../auth/jwt-utils.js";
102
+ import {
103
+ handleProxyRequest,
104
+ type FormatAdapter,
105
+ type ProxyRequest,
106
+ } from "../shared/proxy-handler.js";
107
+ import type { StatusCode } from "hono/utils/http-status";
108
+ import { createModelRoutes } from "../models.js";
109
+ import { triggerImmediateRefresh } from "../../models/model-fetcher.js";
110
+
111
+ // ── Helpers ─────────────────────────────────────────────────────────
112
+
113
+ function makeModel(slug: string) {
114
+ return { slug, id: slug, name: slug };
115
+ }
116
+
117
+ function createTestFormat(): FormatAdapter {
118
+ return {
119
+ tag: "Test",
120
+ noAccountStatus: 503 as StatusCode,
121
+ formatNoAccount: () => ({
122
+ error: {
123
+ message: "No account available for this model",
124
+ type: "server_error",
125
+ },
126
+ }),
127
+ format429: (msg: string) => ({
128
+ error: { message: msg, type: "rate_limit_error" },
129
+ }),
130
+ formatError: (status: number, msg: string) => ({
131
+ error: { status, message: msg },
132
+ }),
133
+ streamTranslator: async function* () {
134
+ yield "data: {}\n\n";
135
+ },
136
+ collectTranslator: async () => ({
137
+ response: { id: "resp-test", object: "chat.completion", choices: [] },
138
+ usage: { input_tokens: 10, output_tokens: 20 },
139
+ responseId: "resp-test",
140
+ }),
141
+ };
142
+ }
143
+
144
+ function makeProxyRequest(model: string): ProxyRequest {
145
+ return {
146
+ codexRequest: { model } as ProxyRequest["codexRequest"],
147
+ model,
148
+ isStreaming: false,
149
+ };
150
+ }
151
+
152
+ // ── Tests ───────────────────────────────────────────────────────────
153
+
154
+ describe("plan routing through proxy handler", () => {
155
+ let pool: AccountPool;
156
+
157
+ beforeEach(() => {
158
+ vi.clearAllMocks();
159
+ mockConfig.auth.jwt_token = undefined;
160
+ mockConfig.server.proxy_api_key = null;
161
+ delete process.env.CODEX_JWT_TOKEN;
162
+
163
+ loadStaticModels();
164
+
165
+ vi.mocked(extractUserProfile).mockImplementation((token: string) => {
166
+ if (token.startsWith("free-"))
167
+ return { email: "free@test.com", chatgpt_plan_type: "free" };
168
+ if (token.startsWith("team-"))
169
+ return { email: "team@test.com", chatgpt_plan_type: "team" };
170
+ return null;
171
+ });
172
+
173
+ pool = new AccountPool();
174
+ mockCreateResponse.mockResolvedValue(
175
+ new Response(JSON.stringify({ ok: true })),
176
+ );
177
+ });
178
+
179
+ afterEach(() => {
180
+ pool?.destroy();
181
+ });
182
+
183
+ it("free-only pool + team-only model → 503", async () => {
184
+ pool.addAccount("free-token-1");
185
+ applyBackendModelsForPlan("team", [
186
+ makeModel("gpt-5.4"),
187
+ makeModel("gpt-5.2-codex"),
188
+ ]);
189
+ applyBackendModelsForPlan("free", [makeModel("gpt-5.2-codex")]);
190
+
191
+ const app = new Hono();
192
+ app.post("/test", (c) =>
193
+ handleProxyRequest(
194
+ c,
195
+ pool,
196
+ undefined,
197
+ makeProxyRequest("gpt-5.4"),
198
+ createTestFormat(),
199
+ ),
200
+ );
201
+
202
+ const res = await app.request("/test", { method: "POST" });
203
+ expect(res.status).toBe(503);
204
+ const body = await res.json();
205
+ expect(body.error.message).toContain("No account");
206
+ });
207
+
208
+ it("free-only pool + model in both plans → 200", async () => {
209
+ pool.addAccount("free-token-1");
210
+ applyBackendModelsForPlan("free", [
211
+ makeModel("gpt-5.4"),
212
+ makeModel("gpt-5.2-codex"),
213
+ ]);
214
+ applyBackendModelsForPlan("team", [
215
+ makeModel("gpt-5.4"),
216
+ makeModel("gpt-5.2-codex"),
217
+ ]);
218
+
219
+ const app = new Hono();
220
+ app.post("/test", (c) =>
221
+ handleProxyRequest(
222
+ c,
223
+ pool,
224
+ undefined,
225
+ makeProxyRequest("gpt-5.4"),
226
+ createTestFormat(),
227
+ ),
228
+ );
229
+
230
+ const res = await app.request("/test", { method: "POST" });
231
+ expect(res.status).toBe(200);
232
+ });
233
+
234
+ it("plan map update → previously blocked request now succeeds", async () => {
235
+ pool.addAccount("free-token-1");
236
+ applyBackendModelsForPlan("team", [makeModel("gpt-5.4")]);
237
+ applyBackendModelsForPlan("free", [makeModel("gpt-5.2-codex")]);
238
+
239
+ const app = new Hono();
240
+ app.post("/test", (c) =>
241
+ handleProxyRequest(
242
+ c,
243
+ pool,
244
+ undefined,
245
+ makeProxyRequest("gpt-5.4"),
246
+ createTestFormat(),
247
+ ),
248
+ );
249
+
250
+ // Blocked — free can't use team-only model
251
+ const res1 = await app.request("/test", { method: "POST" });
252
+ expect(res1.status).toBe(503);
253
+
254
+ // Backend refresh: free now has gpt-5.4
255
+ applyBackendModelsForPlan("free", [
256
+ makeModel("gpt-5.2-codex"),
257
+ makeModel("gpt-5.4"),
258
+ ]);
259
+
260
+ // Same request now succeeds
261
+ const res2 = await app.request("/test", { method: "POST" });
262
+ expect(res2.status).toBe(200);
263
+ });
264
+
265
+ it("team-only pool + team model → 200", async () => {
266
+ pool.addAccount("team-token-1");
267
+ applyBackendModelsForPlan("team", [
268
+ makeModel("gpt-5.4"),
269
+ makeModel("gpt-5.2-codex"),
270
+ ]);
271
+ applyBackendModelsForPlan("free", [makeModel("gpt-5.2-codex")]);
272
+
273
+ const app = new Hono();
274
+ app.post("/test", (c) =>
275
+ handleProxyRequest(
276
+ c,
277
+ pool,
278
+ undefined,
279
+ makeProxyRequest("gpt-5.4"),
280
+ createTestFormat(),
281
+ ),
282
+ );
283
+
284
+ const res = await app.request("/test", { method: "POST" });
285
+ expect(res.status).toBe(200);
286
+ });
287
+
288
+ it("mixed pool prefers plan-matched account", async () => {
289
+ pool.addAccount("free-token-1");
290
+ pool.addAccount("team-token-1");
291
+ applyBackendModelsForPlan("team", [
292
+ makeModel("gpt-5.4"),
293
+ makeModel("gpt-5.2-codex"),
294
+ ]);
295
+ applyBackendModelsForPlan("free", [makeModel("gpt-5.2-codex")]);
296
+
297
+ const app = new Hono();
298
+ app.post("/test", (c) =>
299
+ handleProxyRequest(
300
+ c,
301
+ pool,
302
+ undefined,
303
+ makeProxyRequest("gpt-5.4"),
304
+ createTestFormat(),
305
+ ),
306
+ );
307
+
308
+ const res = await app.request("/test", { method: "POST" });
309
+ expect(res.status).toBe(200);
310
+ // Team account is used (only plan supporting gpt-5.4)
311
+ expect(mockCreateResponse).toHaveBeenCalledOnce();
312
+ });
313
+ });
314
+
315
+ describe("POST /admin/refresh-models", () => {
316
+ beforeEach(() => {
317
+ vi.clearAllMocks();
318
+ mockConfig.server.proxy_api_key = null;
319
+ loadStaticModels();
320
+ });
321
+
322
+ it("triggers model refresh and returns 200", async () => {
323
+ const app = createModelRoutes();
324
+ const res = await app.request("/admin/refresh-models", { method: "POST" });
325
+ expect(res.status).toBe(200);
326
+ const body = await res.json();
327
+ expect(body.ok).toBe(true);
328
+ expect(body.message).toBe("Model refresh triggered");
329
+ expect(triggerImmediateRefresh).toHaveBeenCalledOnce();
330
+ });
331
+
332
+ it("requires auth when proxy_api_key is set", async () => {
333
+ mockConfig.server.proxy_api_key = "test-secret";
334
+ const app = createModelRoutes();
335
+
336
+ // No auth → 401
337
+ const res1 = await app.request("/admin/refresh-models", {
338
+ method: "POST",
339
+ });
340
+ expect(res1.status).toBe(401);
341
+ expect(triggerImmediateRefresh).not.toHaveBeenCalled();
342
+
343
+ // Wrong auth → 401
344
+ const res2 = await app.request("/admin/refresh-models", {
345
+ method: "POST",
346
+ headers: { Authorization: "Bearer wrong-key" },
347
+ });
348
+ expect(res2.status).toBe(401);
349
+
350
+ // Correct auth → 200
351
+ const res3 = await app.request("/admin/refresh-models", {
352
+ method: "POST",
353
+ headers: { Authorization: "Bearer test-secret" },
354
+ });
355
+ expect(res3.status).toBe(200);
356
+ expect(triggerImmediateRefresh).toHaveBeenCalledOnce();
357
+ });
358
+ });
src/routes/models.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
  getModelStoreDebug,
12
  type CodexModelInfo,
13
  } from "../models/model-store.js";
 
 
14
 
15
  // --- Routes ---
16
 
@@ -102,5 +104,21 @@ export function createModelRoutes(): Hono {
102
  return c.json(getModelStoreDebug());
103
  });
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  return app;
106
  }
 
11
  getModelStoreDebug,
12
  type CodexModelInfo,
13
  } from "../models/model-store.js";
14
+ import { triggerImmediateRefresh } from "../models/model-fetcher.js";
15
+ import { getConfig } from "../config.js";
16
 
17
  // --- Routes ---
18
 
 
104
  return c.json(getModelStoreDebug());
105
  });
106
 
107
+ // Admin endpoint: trigger immediate model refresh
108
+ app.post("/admin/refresh-models", (c) => {
109
+ const config = getConfig();
110
+ const configKey = config.server.proxy_api_key;
111
+ if (configKey) {
112
+ const authHeader = c.req.header("Authorization") ?? "";
113
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
114
+ if (token !== configKey) {
115
+ c.status(401);
116
+ return c.json({ error: "Unauthorized" });
117
+ }
118
+ }
119
+ triggerImmediateRefresh();
120
+ return c.json({ ok: true, message: "Model refresh triggered" });
121
+ });
122
+
123
  return app;
124
  }