icebear icebear0828 commited on
Commit
56be298
·
unverified ·
1 Parent(s): f25b99c

fix: model list not updating at startup — fast-retry on auth race (#149)

Browse files

Root cause: token refresh and model fetch race at startup. Initial fetch
fires 1s after boot, but accounts may still be refreshing OAuth tokens.
isAuthenticated() returns false → fetch silently skips → next retry is
~1 hour later.

- model-fetcher: fast-retry (10s × 12 attempts) until accounts are ready
- config/models.yaml: add gpt-5.4/5.4-mini/5.3-codex (restored 2026-03-18)
- Export hasFetchedModels() for observability
- 4 new tests covering retry/fallback/immediate-success scenarios

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

CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@
6
 
7
  ## [Unreleased]
8
 
 
 
 
 
 
 
9
  ### Added
10
 
11
  - 账号封禁检测:上游返回非 Cloudflare 的 403 时自动标记为 `banned` 状态
 
6
 
7
  ## [Unreleased]
8
 
9
+ ### Fixed
10
+
11
+ - 模型列表启动时不更新:token 刷新与 model fetch 存在竞态,初始 fetch 跳过后直接等 1 小时
12
+ - model-fetcher 改为 fast-retry(10s 间隔,最多 12 次),账号就绪后立即拉取
13
+ - `config/models.yaml` 补回 gpt-5.4/5.4-mini/5.3-codex(3/18 后端已恢复)
14
+
15
  ### Added
16
 
17
  - 账号封禁检测:上游返回非 Cloudflare 的 403 时自动标记为 `banned` 状态
config/models.yaml CHANGED
@@ -7,10 +7,54 @@
7
  # Dynamic fetch merges with static; backend entries win for shared IDs.
8
  # Models endpoint now requires ?client_version= query parameter.
9
  #
10
- # Last updated: 2026-03-10 (backend removed gpt-5.4, gpt-5.3-codex family)
11
 
12
  models:
13
- # ── GPT-5.2 Codex (current flagship) ───────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  - id: gpt-5.2-codex
15
  displayName: GPT-5.2 Codex
16
  description: Frontier agentic coding model
 
7
  # Dynamic fetch merges with static; backend entries win for shared IDs.
8
  # Models endpoint now requires ?client_version= query parameter.
9
  #
10
+ # Last updated: 2026-03-22 (gpt-5.4 family restored to backend)
11
 
12
  models:
13
+ # ── GPT-5.4 family (current flagship) ───────────────────────────────
14
+ - id: gpt-5.4
15
+ displayName: GPT-5.4
16
+ description: Latest frontier agentic coding model
17
+ isDefault: false
18
+ supportedReasoningEfforts:
19
+ - { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
20
+ - { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
21
+ - { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
22
+ - { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
23
+ defaultReasoningEffort: medium
24
+ inputModalities: [text, image]
25
+ supportsPersonality: false
26
+ upgrade: null
27
+
28
+ - id: gpt-5.4-mini
29
+ displayName: GPT-5.4 Mini
30
+ description: Smaller frontier agentic coding model
31
+ isDefault: false
32
+ supportedReasoningEfforts:
33
+ - { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
34
+ - { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
35
+ - { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
36
+ - { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
37
+ defaultReasoningEffort: medium
38
+ inputModalities: [text, image]
39
+ supportsPersonality: false
40
+ upgrade: null
41
+
42
+ # ── GPT-5.3 Codex ──────────────────────────────────────────────────
43
+ - id: gpt-5.3-codex
44
+ displayName: GPT-5.3 Codex
45
+ description: Frontier Codex-optimized agentic coding model
46
+ isDefault: false
47
+ supportedReasoningEfforts:
48
+ - { reasoningEffort: low, description: "Fast responses with lighter reasoning" }
49
+ - { reasoningEffort: medium, description: "Balances speed and reasoning depth" }
50
+ - { reasoningEffort: high, description: "Greater reasoning depth for complex problems" }
51
+ - { reasoningEffort: xhigh, description: "Extra high reasoning depth" }
52
+ defaultReasoningEffort: medium
53
+ inputModalities: [text, image]
54
+ supportsPersonality: false
55
+ upgrade: null
56
+
57
+ # ── GPT-5.2 Codex ──────────────────────────────────────────────────
58
  - id: gpt-5.2-codex
59
  displayName: GPT-5.2 Codex
60
  description: Frontier agentic coding model
src/models/__tests__/model-fetcher-retry.test.ts ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ vi.mock("../../config.js", () => ({
4
+ getConfig: vi.fn(() => ({
5
+ model: { default: "gpt-5.2-codex" },
6
+ })),
7
+ }));
8
+
9
+ vi.mock("../../paths.js", () => ({
10
+ getConfigDir: vi.fn(() => "/tmp/test-config"),
11
+ getDataDir: vi.fn(() => "/tmp/test-data"),
12
+ }));
13
+
14
+ const mockGetModels = vi.fn<() => Promise<Array<{ slug: string }>>>();
15
+
16
+ vi.mock("../../proxy/codex-api.js", () => ({
17
+ CodexApi: vi.fn().mockImplementation(() => ({
18
+ getModels: mockGetModels,
19
+ })),
20
+ }));
21
+
22
+ vi.mock("../model-store.js", () => ({
23
+ applyBackendModelsForPlan: vi.fn(),
24
+ }));
25
+
26
+ vi.mock("../../utils/jitter.js", () => ({
27
+ jitter: vi.fn((ms: number) => ms),
28
+ }));
29
+
30
+ import type { AccountPool } from "../../auth/account-pool.js";
31
+ import type { CookieJar } from "../../proxy/cookie-jar.js";
32
+ import {
33
+ startModelRefresh,
34
+ stopModelRefresh,
35
+ hasFetchedModels,
36
+ } from "../model-fetcher.js";
37
+
38
+ function createMockAccountPool(authenticated: boolean): AccountPool {
39
+ return {
40
+ isAuthenticated: vi.fn(() => authenticated),
41
+ getDistinctPlanAccounts: vi.fn(() =>
42
+ authenticated
43
+ ? [{ planType: "team", entryId: "e1", token: "t1", accountId: "a1" }]
44
+ : [],
45
+ ),
46
+ release: vi.fn(),
47
+ } as unknown as AccountPool;
48
+ }
49
+
50
+ const mockCookieJar = {} as CookieJar;
51
+
52
+ describe("model-fetcher retry logic", () => {
53
+ beforeEach(() => {
54
+ vi.useFakeTimers();
55
+ vi.clearAllMocks();
56
+ stopModelRefresh();
57
+ });
58
+
59
+ afterEach(() => {
60
+ stopModelRefresh();
61
+ vi.useRealTimers();
62
+ });
63
+
64
+ it("retries when accounts are not authenticated at startup", async () => {
65
+ const pool = createMockAccountPool(false);
66
+ startModelRefresh(pool, mockCookieJar);
67
+
68
+ expect(hasFetchedModels()).toBe(false);
69
+
70
+ // Advance past initial delay (1s)
71
+ await vi.advanceTimersByTimeAsync(1_000);
72
+ expect(pool.isAuthenticated).toHaveBeenCalled();
73
+ expect(hasFetchedModels()).toBe(false);
74
+
75
+ // Should retry at 10s intervals — advance to first retry
76
+ await vi.advanceTimersByTimeAsync(10_000);
77
+ expect((pool.isAuthenticated as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(2);
78
+ });
79
+
80
+ it("succeeds on retry when accounts become ready", async () => {
81
+ let authenticated = false;
82
+ const pool = {
83
+ isAuthenticated: vi.fn(() => authenticated),
84
+ getDistinctPlanAccounts: vi.fn(() =>
85
+ authenticated
86
+ ? [{ planType: "free", entryId: "e1", token: "t1", accountId: "a1" }]
87
+ : [],
88
+ ),
89
+ release: vi.fn(),
90
+ } as unknown as AccountPool;
91
+
92
+ mockGetModels.mockResolvedValue([{ slug: "gpt-5.4" }]);
93
+ startModelRefresh(pool, mockCookieJar);
94
+
95
+ // Initial attempt — not authenticated
96
+ await vi.advanceTimersByTimeAsync(1_000);
97
+ expect(hasFetchedModels()).toBe(false);
98
+
99
+ // Now accounts become active
100
+ authenticated = true;
101
+
102
+ // Advance to first retry (10s)
103
+ await vi.advanceTimersByTimeAsync(10_000);
104
+ expect(hasFetchedModels()).toBe(true);
105
+ });
106
+
107
+ it("falls back to hourly after max retries", async () => {
108
+ const pool = createMockAccountPool(false);
109
+ startModelRefresh(pool, mockCookieJar);
110
+
111
+ // Initial delay + 12 retries × 10s = 1s + 120s
112
+ await vi.advanceTimersByTimeAsync(1_000 + 12 * 10_000);
113
+ expect(hasFetchedModels()).toBe(false);
114
+
115
+ // Should have logged max retries and scheduled hourly
116
+ // Verify no more retries by advancing another 10s
117
+ const callsBefore = (pool.isAuthenticated as ReturnType<typeof vi.fn>).mock.calls.length;
118
+ await vi.advanceTimersByTimeAsync(10_000);
119
+ const callsAfter = (pool.isAuthenticated as ReturnType<typeof vi.fn>).mock.calls.length;
120
+ // No additional calls at 10s interval (hourly is much later)
121
+ expect(callsAfter).toBe(callsBefore);
122
+ });
123
+
124
+ it("succeeds immediately when accounts are ready at startup", async () => {
125
+ const pool = createMockAccountPool(true);
126
+ mockGetModels.mockResolvedValue([{ slug: "gpt-5.4" }]);
127
+
128
+ startModelRefresh(pool, mockCookieJar);
129
+ await vi.advanceTimersByTimeAsync(1_000);
130
+
131
+ expect(hasFetchedModels()).toBe(true);
132
+ expect(pool.release).toHaveBeenCalledWith("e1");
133
+ });
134
+ });
src/models/model-fetcher.ts CHANGED
@@ -15,31 +15,35 @@ import { jitter } from "../utils/jitter.js";
15
 
16
  const REFRESH_INTERVAL_HOURS = 1;
17
  const INITIAL_DELAY_MS = 1_000; // 1s after startup (fast plan-map population for mixed-plan routing)
 
 
18
 
19
  let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
20
  let _accountPool: AccountPool | null = null;
21
  let _cookieJar: CookieJar | null = null;
22
  let _proxyPool: ProxyPool | null = null;
 
23
 
24
  /**
25
  * Fetch models from the Codex backend, one query per distinct plan type.
26
- * This discovers plan-specific model availability (e.g. Team has gpt-5.4, Free has gpt-oss-*).
27
  */
28
  async function fetchModelsFromBackend(
29
  accountPool: AccountPool,
30
  cookieJar: CookieJar,
31
  proxyPool: ProxyPool | null,
32
- ): Promise<void> {
33
- if (!accountPool.isAuthenticated()) return; // silently skip when no accounts
34
 
35
  const planAccounts = accountPool.getDistinctPlanAccounts();
36
  if (planAccounts.length === 0) {
37
  console.warn("[ModelFetcher] No available accounts — skipping model fetch");
38
- return;
39
  }
40
 
41
  console.log(`[ModelFetcher] Fetching models for ${planAccounts.length} plan(s): ${planAccounts.map((p) => p.planType).join(", ")}`);
42
 
 
43
  const results = await Promise.allSettled(
44
  planAccounts.map(async (pa) => {
45
  try {
@@ -49,6 +53,7 @@ async function fetchModelsFromBackend(
49
  if (models && models.length > 0) {
50
  applyBackendModelsForPlan(pa.planType, models);
51
  console.log(`[ModelFetcher] Plan "${pa.planType}": ${models.length} models`);
 
52
  } else {
53
  console.log(`[ModelFetcher] Plan "${pa.planType}": empty model list — keeping existing`);
54
  }
@@ -64,11 +69,14 @@ async function fetchModelsFromBackend(
64
  console.warn(`[ModelFetcher] Plan fetch failed: ${msg}`);
65
  }
66
  }
 
 
67
  }
68
 
69
  /**
70
  * Start the background model refresh loop.
71
  * - First fetch after a short delay (auth must be ready)
 
72
  * - Subsequent fetches every ~1 hour with jitter
73
  */
74
  export function startModelRefresh(
@@ -79,19 +87,46 @@ export function startModelRefresh(
79
  _accountPool = accountPool;
80
  _cookieJar = cookieJar;
81
  _proxyPool = proxyPool ?? null;
 
82
 
83
  // Initial fetch after short delay
84
- _refreshTimer = setTimeout(async () => {
85
- try {
86
- await fetchModelsFromBackend(accountPool, cookieJar, _proxyPool);
87
- } finally {
88
- scheduleNext(accountPool, cookieJar);
89
- }
90
  }, INITIAL_DELAY_MS);
91
 
92
  console.log("[ModelFetcher] Scheduled initial model fetch in 1s");
93
  }
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  function scheduleNext(
96
  accountPool: AccountPool,
97
  cookieJar: CookieJar,
@@ -107,18 +142,27 @@ function scheduleNext(
107
  }
108
 
109
  /**
110
- * Trigger an immediate model refresh (e.g. after hot-reload).
111
  * No-op if startModelRefresh() hasn't been called yet.
112
  */
113
  export function triggerImmediateRefresh(): void {
114
  if (_accountPool && _cookieJar) {
115
- fetchModelsFromBackend(_accountPool, _cookieJar, _proxyPool).catch((err) => {
116
- const msg = err instanceof Error ? err.message : String(err);
117
- console.warn(`[ModelFetcher] Immediate refresh failed: ${msg}`);
118
- });
 
 
 
 
119
  }
120
  }
121
 
 
 
 
 
 
122
  /**
123
  * Stop the background refresh timer.
124
  */
 
15
 
16
  const REFRESH_INTERVAL_HOURS = 1;
17
  const INITIAL_DELAY_MS = 1_000; // 1s after startup (fast plan-map population for mixed-plan routing)
18
+ const RETRY_DELAY_MS = 10_000; // 10s retry when accounts aren't ready yet
19
+ const MAX_RETRIES = 12; // ~2 minutes of retries before falling back to hourly
20
 
21
  let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
22
  let _accountPool: AccountPool | null = null;
23
  let _cookieJar: CookieJar | null = null;
24
  let _proxyPool: ProxyPool | null = null;
25
+ let _hasFetchedOnce = false;
26
 
27
  /**
28
  * Fetch models from the Codex backend, one query per distinct plan type.
29
+ * Returns true if at least one plan's models were fetched successfully.
30
  */
31
  async function fetchModelsFromBackend(
32
  accountPool: AccountPool,
33
  cookieJar: CookieJar,
34
  proxyPool: ProxyPool | null,
35
+ ): Promise<boolean> {
36
+ if (!accountPool.isAuthenticated()) return false;
37
 
38
  const planAccounts = accountPool.getDistinctPlanAccounts();
39
  if (planAccounts.length === 0) {
40
  console.warn("[ModelFetcher] No available accounts — skipping model fetch");
41
+ return false;
42
  }
43
 
44
  console.log(`[ModelFetcher] Fetching models for ${planAccounts.length} plan(s): ${planAccounts.map((p) => p.planType).join(", ")}`);
45
 
46
+ let anySuccess = false;
47
  const results = await Promise.allSettled(
48
  planAccounts.map(async (pa) => {
49
  try {
 
53
  if (models && models.length > 0) {
54
  applyBackendModelsForPlan(pa.planType, models);
55
  console.log(`[ModelFetcher] Plan "${pa.planType}": ${models.length} models`);
56
+ anySuccess = true;
57
  } else {
58
  console.log(`[ModelFetcher] Plan "${pa.planType}": empty model list — keeping existing`);
59
  }
 
69
  console.warn(`[ModelFetcher] Plan fetch failed: ${msg}`);
70
  }
71
  }
72
+
73
+ return anySuccess;
74
  }
75
 
76
  /**
77
  * Start the background model refresh loop.
78
  * - First fetch after a short delay (auth must be ready)
79
+ * - If accounts aren't ready, retry every 10s (up to ~2 min) before falling back to hourly
80
  * - Subsequent fetches every ~1 hour with jitter
81
  */
82
  export function startModelRefresh(
 
87
  _accountPool = accountPool;
88
  _cookieJar = cookieJar;
89
  _proxyPool = proxyPool ?? null;
90
+ _hasFetchedOnce = false;
91
 
92
  // Initial fetch after short delay
93
+ _refreshTimer = setTimeout(() => {
94
+ attemptInitialFetch(accountPool, cookieJar, 0);
 
 
 
 
95
  }, INITIAL_DELAY_MS);
96
 
97
  console.log("[ModelFetcher] Scheduled initial model fetch in 1s");
98
  }
99
 
100
+ /**
101
+ * Attempt initial fetch with fast retry.
102
+ * Accounts may still be refreshing tokens at startup (Electron race condition).
103
+ * Retry every 10s until success or max retries, then fall back to hourly.
104
+ */
105
+ function attemptInitialFetch(
106
+ accountPool: AccountPool,
107
+ cookieJar: CookieJar,
108
+ attempt: number,
109
+ ): void {
110
+ fetchModelsFromBackend(accountPool, cookieJar, _proxyPool)
111
+ .then((success) => {
112
+ if (success) {
113
+ _hasFetchedOnce = true;
114
+ scheduleNext(accountPool, cookieJar);
115
+ } else if (attempt < MAX_RETRIES) {
116
+ console.log(`[ModelFetcher] Accounts not ready, retry ${attempt + 1}/${MAX_RETRIES} in ${RETRY_DELAY_MS / 1000}s`);
117
+ _refreshTimer = setTimeout(() => {
118
+ attemptInitialFetch(accountPool, cookieJar, attempt + 1);
119
+ }, RETRY_DELAY_MS);
120
+ } else {
121
+ console.warn("[ModelFetcher] Max retries reached, falling back to hourly refresh");
122
+ scheduleNext(accountPool, cookieJar);
123
+ }
124
+ })
125
+ .catch(() => {
126
+ scheduleNext(accountPool, cookieJar);
127
+ });
128
+ }
129
+
130
  function scheduleNext(
131
  accountPool: AccountPool,
132
  cookieJar: CookieJar,
 
142
  }
143
 
144
  /**
145
+ * Trigger an immediate model refresh (e.g. after hot-reload or account login).
146
  * No-op if startModelRefresh() hasn't been called yet.
147
  */
148
  export function triggerImmediateRefresh(): void {
149
  if (_accountPool && _cookieJar) {
150
+ fetchModelsFromBackend(_accountPool, _cookieJar, _proxyPool)
151
+ .then((success) => {
152
+ if (success) _hasFetchedOnce = true;
153
+ })
154
+ .catch((err) => {
155
+ const msg = err instanceof Error ? err.message : String(err);
156
+ console.warn(`[ModelFetcher] Immediate refresh failed: ${msg}`);
157
+ });
158
  }
159
  }
160
 
161
+ /** Whether at least one successful backend fetch has completed. */
162
+ export function hasFetchedModels(): boolean {
163
+ return _hasFetchedOnce;
164
+ }
165
+
166
  /**
167
  * Stop the background refresh timer.
168
  */