icebear0828 commited on
Commit
b94940f
Β·
1 Parent(s): c424a99

fix: model store dual index, desktop UI fallback, dynamic port support

Browse files

- Model store: replace O(n) plan map with dual index (planType→models + modelId→plans), both O(1)
- Desktop UI: fallback to web UI when desktop build missing, add startup path diagnostics
- Server: resolve actual bound port from server.address() to support port=0
- Model fetcher: fix log message (said 5s, actually 1s)
- Update Codex Desktop fingerprint to v26.311.21342 (build 993)

config/default.yaml CHANGED
@@ -3,8 +3,8 @@ api:
3
  timeout_seconds: 60
4
  client:
5
  originator: Codex Desktop
6
- app_version: 26.309.31024
7
- build_number: "962"
8
  platform: darwin
9
  arch: arm64
10
  chromium_version: "144"
 
3
  timeout_seconds: 60
4
  client:
5
  originator: Codex Desktop
6
+ app_version: 26.311.21342
7
+ build_number: "993"
8
  platform: darwin
9
  arch: arm64
10
  chromium_version: "144"
config/prompts/automation-response.md CHANGED
@@ -48,4 +48,5 @@ Response MUST end with a remark-directive block.
48
  - Waiting on user decision:
49
  - \`::inbox-item{title="Choose API shape for filters" summary="Two options drafted; pick A vs B"}\`
50
  - Status update with next step:
51
- - \`::inbox-item{title="PR comments addressed" summary="Ready for re-review; focus on auth edge case"}\
 
 
48
  - Waiting on user decision:
49
  - \`::inbox-item{title="Choose API shape for filters" summary="Two options drafted; pick A vs B"}\`
50
  - Status update with next step:
51
+ - \`::inbox-item{title="PR comments addressed" summary="Ready for re-review; focus on auth edge case"}\`
52
+ `;
package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
  "name": "codex-proxy",
3
- "version": "1.0.28",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "codex-proxy",
9
- "version": "1.0.28",
10
  "hasInstallScript": true,
11
  "dependencies": {
12
  "@hono/node-server": "^1.0.0",
 
1
  {
2
  "name": "codex-proxy",
3
+ "version": "1.0.39",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "codex-proxy",
9
+ "version": "1.0.39",
10
  "hasInstallScript": true,
11
  "dependencies": {
12
  "@hono/node-server": "^1.0.0",
scripts/extract-fingerprint.ts CHANGED
@@ -123,7 +123,6 @@ function extractFromMainJs(
123
  ): {
124
  apiBaseUrl: string | null;
125
  originator: string | null;
126
- models: string[];
127
  whamEndpoints: string[];
128
  userAgentContains: string;
129
  } {
@@ -157,17 +156,6 @@ function extractFromMainJs(
157
  throw new Error("Failed to extract critical field: originator");
158
  }
159
 
160
- // Models β€” deduplicate, use capture group if specified
161
- const models: Set<string> = new Set();
162
- const modelPattern = patterns.models;
163
- if (modelPattern?.pattern) {
164
- const re = new RegExp(modelPattern.pattern, "g");
165
- const groupIdx = modelPattern.group ?? 0;
166
- for (const m of content.matchAll(re)) {
167
- models.add(m[groupIdx] ?? m[0]);
168
- }
169
- }
170
-
171
  // WHAM endpoints β€” deduplicate, use capture group if specified
172
  const endpoints: Set<string> = new Set();
173
  const epPattern = patterns.wham_endpoints;
@@ -182,7 +170,6 @@ function extractFromMainJs(
182
  return {
183
  apiBaseUrl,
184
  originator,
185
- models: [...models].sort(),
186
  whamEndpoints: [...endpoints].sort(),
187
  userAgentContains: "Codex Desktop/",
188
  };
@@ -440,7 +427,6 @@ async function main() {
440
  let mainJsResults = {
441
  apiBaseUrl: null as string | null,
442
  originator: null as string | null,
443
- models: [] as string[],
444
  whamEndpoints: [] as string[],
445
  userAgentContains: "Codex Desktop/",
446
  };
@@ -484,21 +470,10 @@ async function main() {
484
  const m = mainJs.match(new RegExp(apiPattern.pattern));
485
  if (m) mainJsResults.apiBaseUrl = m[0];
486
  }
487
- const modelPattern = patterns.main_js.models;
488
- if (modelPattern?.pattern) {
489
- const re = new RegExp(modelPattern.pattern, "g");
490
- const groupIdx = modelPattern.group ?? 0;
491
- const modelSet = new Set<string>();
492
- for (const m of mainJs.matchAll(re)) {
493
- modelSet.add(m[groupIdx] ?? m[0]);
494
- }
495
- mainJsResults.models = [...modelSet].sort();
496
- }
497
  }
498
 
499
  console.log(` API base URL: ${mainJsResults.apiBaseUrl}`);
500
  console.log(` originator: ${mainJsResults.originator}`);
501
- console.log(` models: ${mainJsResults.models.join(", ")}`);
502
  console.log(` WHAM endpoints: ${mainJsResults.whamEndpoints.length} found`);
503
 
504
  // Extract system prompts
@@ -510,31 +485,6 @@ async function main() {
510
  console.log(` automation-response: ${promptResults.automationResponse ? "found" : "NOT FOUND"}`);
511
  }
512
 
513
- // Scan webview assets for additional model IDs
514
- const webviewAssetsDir = join(asarRoot, "webview/assets");
515
- if (existsSync(webviewAssetsDir)) {
516
- console.log("[extract] Scanning webview assets for additional models...");
517
- const modelPattern = patterns.main_js.models;
518
- if (modelPattern?.pattern) {
519
- const webviewFiles = readdirSync(webviewAssetsDir).filter((f) => f.endsWith(".js"));
520
- const webviewModels = new Set<string>();
521
- for (const file of webviewFiles) {
522
- const content = readFileSync(join(webviewAssetsDir, file), "utf-8");
523
- const re = new RegExp(modelPattern.pattern, "g");
524
- const groupIdx = modelPattern.group ?? 0;
525
- for (const m of content.matchAll(re)) {
526
- webviewModels.add(m[groupIdx] ?? m[0]);
527
- }
528
- }
529
- const existingModels = new Set(mainJsResults.models);
530
- const newFromWebview = [...webviewModels].filter((m) => !existingModels.has(m));
531
- if (newFromWebview.length > 0) {
532
- console.log(`[extract] Webview: ${newFromWebview.length} additional models: ${newFromWebview.join(", ")}`);
533
- mainJsResults.models = [...mainJsResults.models, ...newFromWebview].sort();
534
- }
535
- }
536
- }
537
-
538
  // Save extracted prompts
539
  const dc = savePrompt("desktop-context", promptResults.desktopContext);
540
  const tg = savePrompt("title-generation", promptResults.titleGeneration);
@@ -549,7 +499,6 @@ async function main() {
549
  chromium_version: chromiumVersion,
550
  api_base_url: mainJsResults.apiBaseUrl,
551
  originator: mainJsResults.originator,
552
- models: mainJsResults.models,
553
  wham_endpoints: mainJsResults.whamEndpoints,
554
  user_agent_contains: mainJsResults.userAgentContains,
555
  sparkle_feed_url: sparkleFeedUrl,
 
123
  ): {
124
  apiBaseUrl: string | null;
125
  originator: string | null;
 
126
  whamEndpoints: string[];
127
  userAgentContains: string;
128
  } {
 
156
  throw new Error("Failed to extract critical field: originator");
157
  }
158
 
 
 
 
 
 
 
 
 
 
 
 
159
  // WHAM endpoints β€” deduplicate, use capture group if specified
160
  const endpoints: Set<string> = new Set();
161
  const epPattern = patterns.wham_endpoints;
 
170
  return {
171
  apiBaseUrl,
172
  originator,
 
173
  whamEndpoints: [...endpoints].sort(),
174
  userAgentContains: "Codex Desktop/",
175
  };
 
427
  let mainJsResults = {
428
  apiBaseUrl: null as string | null,
429
  originator: null as string | null,
 
430
  whamEndpoints: [] as string[],
431
  userAgentContains: "Codex Desktop/",
432
  };
 
470
  const m = mainJs.match(new RegExp(apiPattern.pattern));
471
  if (m) mainJsResults.apiBaseUrl = m[0];
472
  }
 
 
 
 
 
 
 
 
 
 
473
  }
474
 
475
  console.log(` API base URL: ${mainJsResults.apiBaseUrl}`);
476
  console.log(` originator: ${mainJsResults.originator}`);
 
477
  console.log(` WHAM endpoints: ${mainJsResults.whamEndpoints.length} found`);
478
 
479
  // Extract system prompts
 
485
  console.log(` automation-response: ${promptResults.automationResponse ? "found" : "NOT FOUND"}`);
486
  }
487
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  // Save extracted prompts
489
  const dc = savePrompt("desktop-context", promptResults.desktopContext);
490
  const tg = savePrompt("title-generation", promptResults.titleGeneration);
 
499
  chromium_version: chromiumVersion,
500
  api_base_url: mainJsResults.apiBaseUrl,
501
  originator: mainJsResults.originator,
 
502
  wham_endpoints: mainJsResults.whamEndpoints,
503
  user_agent_contains: mainJsResults.userAgentContains,
504
  sparkle_feed_url: sparkleFeedUrl,
src/index.ts CHANGED
@@ -132,6 +132,10 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
132
  port,
133
  });
134
 
 
 
 
 
135
  const close = (): Promise<void> => {
136
  return new Promise((resolve) => {
137
  server.close(() => {
@@ -150,7 +154,7 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
150
  // Register close handler so self-update can attempt graceful shutdown before restart
151
  setCloseHandler(close);
152
 
153
- return { close, port };
154
  }
155
 
156
  // ── CLI entry point ──────────────────────────────────────────────────
 
132
  port,
133
  });
134
 
135
+ // Resolve actual port (may differ from requested when port=0)
136
+ const addr = server.address();
137
+ const actualPort = (addr && typeof addr === "object") ? addr.port : port;
138
+
139
  const close = (): Promise<void> => {
140
  return new Promise((resolve) => {
141
  server.close(() => {
 
154
  // Register close handler so self-update can attempt graceful shutdown before restart
155
  setCloseHandler(close);
156
 
157
+ return { close, port: actualPort };
158
  }
159
 
160
  // ── CLI entry point ──────────────────────────────────────────────────
src/models/model-fetcher.ts CHANGED
@@ -89,7 +89,7 @@ export function startModelRefresh(
89
  }
90
  }, INITIAL_DELAY_MS);
91
 
92
- console.log("[ModelFetcher] Scheduled initial model fetch in 5s");
93
  }
94
 
95
  function scheduleNext(
 
89
  }
90
  }, INITIAL_DELAY_MS);
91
 
92
+ console.log("[ModelFetcher] Scheduled initial model fetch in 1s");
93
  }
94
 
95
  function scheduleNext(
src/models/model-store.ts CHANGED
@@ -39,8 +39,10 @@ interface ModelsConfig {
39
  let _catalog: CodexModelInfo[] = [];
40
  let _aliases: Record<string, string> = {};
41
  let _lastFetchTime: string | null = null;
42
- /** modelId β†’ Set<planType> β€” tracks which plans can access each model */
43
- let _modelPlanMap: Map<string, Set<string>> = new Map();
 
 
44
 
45
  // ── Static loading ─────────────────────────────────────────────────
46
 
@@ -55,7 +57,8 @@ export function loadStaticModels(configDir?: string): void {
55
 
56
  _catalog = (raw.models ?? []).map((m) => ({ ...m, source: "static" as const }));
57
  _aliases = raw.aliases ?? {};
58
- _modelPlanMap = new Map(); // Reset plan map on reload
 
59
  console.log(`[ModelStore] Loaded ${_catalog.length} static models, ${Object.keys(_aliases).length} aliases`);
60
  }
61
 
@@ -214,30 +217,34 @@ export function applyBackendModels(backendModels: BackendModelEntry[]): void {
214
  * Clears old records for this planType, applies merge, then records planβ†’model mappings.
215
  */
216
  export function applyBackendModelsForPlan(planType: string, backendModels: BackendModelEntry[]): void {
217
- // Clear old planType records
218
- for (const [modelId, plans] of _modelPlanMap) {
219
- plans.delete(planType);
220
- if (plans.size === 0) _modelPlanMap.delete(modelId);
221
- }
222
-
223
  // Merge into catalog (existing logic)
224
  applyBackendModels(backendModels);
225
 
226
- // Record which models this plan can access (only admitted models)
227
- const staticIds = new Set(_catalog.map((m) => m.id));
 
228
  for (const raw of backendModels) {
229
  const id = raw.slug ?? raw.id ?? raw.name ?? "";
230
- if (staticIds.has(id) || isCodexCompatibleId(id)) {
231
- let plans = _modelPlanMap.get(id);
 
 
 
 
 
 
 
 
 
232
  if (!plans) {
233
  plans = new Set();
234
- _modelPlanMap.set(id, plans);
235
  }
236
- plans.add(planType);
237
  }
238
  }
239
 
240
- console.log(`[ModelStore] Plan "${planType}" has ${backendModels.length} backend models, ${_modelPlanMap.size} models tracked across plans`);
241
  }
242
 
243
  /**
@@ -245,7 +252,7 @@ export function applyBackendModelsForPlan(planType: string, backendModels: Backe
245
  * Empty array means unknown (static-only or not yet fetched).
246
  */
247
  export function getModelPlanTypes(modelId: string): string[] {
248
- return [...(_modelPlanMap.get(modelId) ?? [])];
249
  }
250
 
251
  // ── Model name suffix parsing ───────────────────────────────────────
@@ -360,8 +367,8 @@ export function getModelStoreDebug(): {
360
  } {
361
  const backendCount = _catalog.filter((m) => m.source === "backend").length;
362
  const planMap: Record<string, string[]> = {};
363
- for (const [modelId, plans] of _modelPlanMap) {
364
- planMap[modelId] = [...plans];
365
  }
366
  return {
367
  totalModels: _catalog.length,
 
39
  let _catalog: CodexModelInfo[] = [];
40
  let _aliases: Record<string, string> = {};
41
  let _lastFetchTime: string | null = null;
42
+ /** planType β†’ Set<modelId> β€” write path: bulk replace per plan */
43
+ let _planModelMap: Map<string, Set<string>> = new Map();
44
+ /** modelId β†’ Set<planType> β€” read path: O(1) lookup for routing */
45
+ let _modelPlanIndex: Map<string, Set<string>> = new Map();
46
 
47
  // ── Static loading ─────────────────────────────────────────────────
48
 
 
57
 
58
  _catalog = (raw.models ?? []).map((m) => ({ ...m, source: "static" as const }));
59
  _aliases = raw.aliases ?? {};
60
+ _planModelMap = new Map(); // Reset plan maps on reload
61
+ _modelPlanIndex = new Map();
62
  console.log(`[ModelStore] Loaded ${_catalog.length} static models, ${Object.keys(_aliases).length} aliases`);
63
  }
64
 
 
217
  * Clears old records for this planType, applies merge, then records planβ†’model mappings.
218
  */
219
  export function applyBackendModelsForPlan(planType: string, backendModels: BackendModelEntry[]): void {
 
 
 
 
 
 
220
  // Merge into catalog (existing logic)
221
  applyBackendModels(backendModels);
222
 
223
+ // Build new model set for this plan and replace atomically
224
+ const admittedIds = new Set<string>();
225
+ const catalogIds = new Set(_catalog.map((m) => m.id));
226
  for (const raw of backendModels) {
227
  const id = raw.slug ?? raw.id ?? raw.name ?? "";
228
+ if (catalogIds.has(id) || isCodexCompatibleId(id)) {
229
+ admittedIds.add(id);
230
+ }
231
+ }
232
+ _planModelMap.set(planType, admittedIds);
233
+
234
+ // Rebuild reverse index from scratch (plan types are few, this is cheap)
235
+ _modelPlanIndex = new Map();
236
+ for (const [plan, modelIds] of _planModelMap) {
237
+ for (const id of modelIds) {
238
+ let plans = _modelPlanIndex.get(id);
239
  if (!plans) {
240
  plans = new Set();
241
+ _modelPlanIndex.set(id, plans);
242
  }
243
+ plans.add(plan);
244
  }
245
  }
246
 
247
+ console.log(`[ModelStore] Plan "${planType}": ${admittedIds.size} admitted models, ${_planModelMap.size} plans tracked`);
248
  }
249
 
250
  /**
 
252
  * Empty array means unknown (static-only or not yet fetched).
253
  */
254
  export function getModelPlanTypes(modelId: string): string[] {
255
+ return [...(_modelPlanIndex.get(modelId) ?? [])];
256
  }
257
 
258
  // ── Model name suffix parsing ───────────────────────────────────────
 
367
  } {
368
  const backendCount = _catalog.filter((m) => m.source === "backend").length;
369
  const planMap: Record<string, string[]> = {};
370
+ for (const [planType, modelIds] of _planModelMap) {
371
+ planMap[planType] = [...modelIds];
372
  }
373
  return {
374
  totalModels: _catalog.length,
src/routes/web.ts CHANGED
@@ -19,38 +19,46 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
19
  const publicDir = getPublicDir();
20
  const desktopPublicDir = getDesktopPublicDir();
21
 
 
 
 
 
 
 
 
 
22
  // Serve Vite build assets (web)
23
  app.use("/assets/*", serveStatic({ root: publicDir }));
24
 
25
  app.get("/", (c) => {
26
  try {
27
- const html = readFileSync(resolve(publicDir, "index.html"), "utf-8");
28
  return c.html(html);
29
  } catch (err) {
30
  const msg = err instanceof Error ? err.message : String(err);
31
  console.error(`[Web] Failed to read HTML file: ${msg}`);
32
- return c.html("<h1>Codex Proxy</h1><p>UI files not found. Run 'npm/pnpm/bun run build:web' first. The API is still available at /v1/chat/completions</p>");
33
  }
34
  });
35
 
36
  // Desktop UI β€” served at /desktop for Electron
37
- // Vite builds with base: "/desktop/" so request paths are /desktop/assets/...
38
- // but files live at public-desktop/assets/..., so strip the /desktop prefix
39
- app.use("/desktop/assets/*", serveStatic({
40
- root: desktopPublicDir,
41
- rewriteRequestPath: (path) => path.replace(/^\/desktop/, ""),
42
- }));
43
-
44
- app.get("/desktop", (c) => {
45
- try {
46
- const html = readFileSync(resolve(desktopPublicDir, "index.html"), "utf-8");
47
  return c.html(html);
48
- } catch (err) {
49
- const msg = err instanceof Error ? err.message : String(err);
50
- console.error(`[Web] Failed to read desktop HTML: ${msg}`);
51
- return c.html("<h1>Codex Proxy</h1><p>Desktop UI files not found. Run 'npm run build:desktop' first.</p>");
52
- }
53
- });
 
 
54
 
55
  app.get("/health", async (c) => {
56
  const authenticated = accountPool.isAuthenticated();
 
19
  const publicDir = getPublicDir();
20
  const desktopPublicDir = getDesktopPublicDir();
21
 
22
+ const desktopIndexPath = resolve(desktopPublicDir, "index.html");
23
+ const webIndexPath = resolve(publicDir, "index.html");
24
+ const hasDesktopUI = existsSync(desktopIndexPath);
25
+ const hasWebUI = existsSync(webIndexPath);
26
+
27
+ console.log(`[Web] publicDir: ${publicDir} (exists: ${hasWebUI})`);
28
+ console.log(`[Web] desktopPublicDir: ${desktopPublicDir} (exists: ${hasDesktopUI})`);
29
+
30
  // Serve Vite build assets (web)
31
  app.use("/assets/*", serveStatic({ root: publicDir }));
32
 
33
  app.get("/", (c) => {
34
  try {
35
+ const html = readFileSync(webIndexPath, "utf-8");
36
  return c.html(html);
37
  } catch (err) {
38
  const msg = err instanceof Error ? err.message : String(err);
39
  console.error(`[Web] Failed to read HTML file: ${msg}`);
40
+ return c.html("<h1>Codex Proxy</h1><p>UI files not found. Run 'npm run build:web' first. The API is still available at /v1/chat/completions</p>");
41
  }
42
  });
43
 
44
  // Desktop UI β€” served at /desktop for Electron
45
+ if (hasDesktopUI) {
46
+ app.use("/desktop/assets/*", serveStatic({
47
+ root: desktopPublicDir,
48
+ rewriteRequestPath: (path) => path.replace(/^\/desktop/, ""),
49
+ }));
50
+
51
+ app.get("/desktop", (c) => {
52
+ const html = readFileSync(desktopIndexPath, "utf-8");
 
 
53
  return c.html(html);
54
+ });
55
+ } else {
56
+ // Fallback: redirect /desktop to web UI so the app is still usable
57
+ app.get("/desktop", (c) => {
58
+ console.warn(`[Web] Desktop UI not found at ${desktopIndexPath}, falling back to web UI`);
59
+ return c.redirect("/");
60
+ });
61
+ }
62
 
63
  app.get("/health", async (c) => {
64
  const authenticated = accountPool.isAuthenticated();