File size: 5,855 Bytes
5f8456f
 
 
 
 
 
 
 
 
90c89b6
5f8456f
 
4ebb914
5f8456f
 
 
be3d093
56be298
 
5f8456f
 
 
 
4ebb914
56be298
5f8456f
 
90c89b6
56be298
5f8456f
 
 
 
4ebb914
56be298
 
5f8456f
90c89b6
 
 
56be298
5f8456f
 
90c89b6
5f8456f
56be298
90c89b6
 
 
 
 
 
 
 
 
56be298
90c89b6
 
 
 
 
 
 
 
 
 
 
 
 
5f8456f
 
56be298
 
5f8456f
 
 
 
 
56be298
5f8456f
 
 
 
 
4ebb914
5f8456f
 
 
4ebb914
56be298
5f8456f
 
56be298
 
5f8456f
 
b94940f
5f8456f
 
56be298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f8456f
 
 
 
 
 
 
4ebb914
5f8456f
 
 
 
 
 
 
56be298
5f8456f
 
 
 
56be298
 
 
 
 
 
 
 
5f8456f
 
 
56be298
 
 
 
 
5f8456f
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
/**
 * Model Fetcher — background model list refresh from Codex backend.
 *
 * - Probes known endpoints to discover the models list
 * - Normalizes and merges into the model store
 * - Non-fatal: all errors log warnings but never crash the server
 */

import { CodexApi } from "../proxy/codex-api.js";
import { applyBackendModelsForPlan } from "./model-store.js";
import type { AccountPool } from "../auth/account-pool.js";
import type { CookieJar } from "../proxy/cookie-jar.js";
import type { ProxyPool } from "../proxy/proxy-pool.js";
import { jitter } from "../utils/jitter.js";

const REFRESH_INTERVAL_HOURS = 1;
const INITIAL_DELAY_MS = 1_000; // 1s after startup (fast plan-map population for mixed-plan routing)
const RETRY_DELAY_MS = 10_000; // 10s retry when accounts aren't ready yet
const MAX_RETRIES = 12; // ~2 minutes of retries before falling back to hourly

let _refreshTimer: ReturnType<typeof setTimeout> | null = null;
let _accountPool: AccountPool | null = null;
let _cookieJar: CookieJar | null = null;
let _proxyPool: ProxyPool | null = null;
let _hasFetchedOnce = false;

/**
 * Fetch models from the Codex backend, one query per distinct plan type.
 * Returns true if at least one plan's models were fetched successfully.
 */
async function fetchModelsFromBackend(
  accountPool: AccountPool,
  cookieJar: CookieJar,
  proxyPool: ProxyPool | null,
): Promise<boolean> {
  if (!accountPool.isAuthenticated()) return false;

  const planAccounts = accountPool.getDistinctPlanAccounts();
  if (planAccounts.length === 0) {
    console.warn("[ModelFetcher] No available accounts — skipping model fetch");
    return false;
  }

  console.log(`[ModelFetcher] Fetching models for ${planAccounts.length} plan(s): ${planAccounts.map((p) => p.planType).join(", ")}`);

  let anySuccess = false;
  const results = await Promise.allSettled(
    planAccounts.map(async (pa) => {
      try {
        const proxyUrl = proxyPool?.resolveProxyUrl(pa.entryId);
        const api = new CodexApi(pa.token, pa.accountId, cookieJar, pa.entryId, proxyUrl);
        const models = await api.getModels();
        if (models && models.length > 0) {
          applyBackendModelsForPlan(pa.planType, models);
          console.log(`[ModelFetcher] Plan "${pa.planType}": ${models.length} models`);
          anySuccess = true;
        } else {
          console.log(`[ModelFetcher] Plan "${pa.planType}": empty model list — keeping existing`);
        }
      } finally {
        accountPool.release(pa.entryId);
      }
    }),
  );

  for (const r of results) {
    if (r.status === "rejected") {
      const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
      console.warn(`[ModelFetcher] Plan fetch failed: ${msg}`);
    }
  }

  return anySuccess;
}

/**
 * Start the background model refresh loop.
 * - First fetch after a short delay (auth must be ready)
 * - If accounts aren't ready, retry every 10s (up to ~2 min) before falling back to hourly
 * - Subsequent fetches every ~1 hour with jitter
 */
export function startModelRefresh(
  accountPool: AccountPool,
  cookieJar: CookieJar,
  proxyPool?: ProxyPool,
): void {
  _accountPool = accountPool;
  _cookieJar = cookieJar;
  _proxyPool = proxyPool ?? null;
  _hasFetchedOnce = false;

  // Initial fetch after short delay
  _refreshTimer = setTimeout(() => {
    attemptInitialFetch(accountPool, cookieJar, 0);
  }, INITIAL_DELAY_MS);

  console.log("[ModelFetcher] Scheduled initial model fetch in 1s");
}

/**
 * Attempt initial fetch with fast retry.
 * Accounts may still be refreshing tokens at startup (Electron race condition).
 * Retry every 10s until success or max retries, then fall back to hourly.
 */
function attemptInitialFetch(
  accountPool: AccountPool,
  cookieJar: CookieJar,
  attempt: number,
): void {
  fetchModelsFromBackend(accountPool, cookieJar, _proxyPool)
    .then((success) => {
      if (success) {
        _hasFetchedOnce = true;
        scheduleNext(accountPool, cookieJar);
      } else if (attempt < MAX_RETRIES) {
        console.log(`[ModelFetcher] Accounts not ready, retry ${attempt + 1}/${MAX_RETRIES} in ${RETRY_DELAY_MS / 1000}s`);
        _refreshTimer = setTimeout(() => {
          attemptInitialFetch(accountPool, cookieJar, attempt + 1);
        }, RETRY_DELAY_MS);
      } else {
        console.warn("[ModelFetcher] Max retries reached, falling back to hourly refresh");
        scheduleNext(accountPool, cookieJar);
      }
    })
    .catch(() => {
      scheduleNext(accountPool, cookieJar);
    });
}

function scheduleNext(
  accountPool: AccountPool,
  cookieJar: CookieJar,
): void {
  const intervalMs = jitter(REFRESH_INTERVAL_HOURS * 3600 * 1000, 0.15);
  _refreshTimer = setTimeout(async () => {
    try {
      await fetchModelsFromBackend(accountPool, cookieJar, _proxyPool);
    } finally {
      scheduleNext(accountPool, cookieJar);
    }
  }, intervalMs);
}

/**
 * Trigger an immediate model refresh (e.g. after hot-reload or account login).
 * No-op if startModelRefresh() hasn't been called yet.
 */
export function triggerImmediateRefresh(): void {
  if (_accountPool && _cookieJar) {
    fetchModelsFromBackend(_accountPool, _cookieJar, _proxyPool)
      .then((success) => {
        if (success) _hasFetchedOnce = true;
      })
      .catch((err) => {
        const msg = err instanceof Error ? err.message : String(err);
        console.warn(`[ModelFetcher] Immediate refresh failed: ${msg}`);
      });
  }
}

/** Whether at least one successful backend fetch has completed. */
export function hasFetchedModels(): boolean {
  return _hasFetchedOnce;
}

/**
 * Stop the background refresh timer.
 */
export function stopModelRefresh(): void {
  if (_refreshTimer) {
    clearTimeout(_refreshTimer);
    _refreshTimer = null;
    console.log("[ModelFetcher] Stopped model refresh");
  }
}