File size: 14,891 Bytes
5f8456f
 
 
 
 
 
 
 
 
 
 
50720ce
5f8456f
 
 
50720ce
5f8456f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b94940f
 
 
 
5f8456f
 
 
 
 
 
 
 
4a940a5
5f8456f
 
 
 
 
b94940f
 
5f8456f
50720ce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f8456f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597668f
5f8456f
 
 
597668f
 
 
 
 
5f8456f
 
 
 
 
597668f
 
 
 
 
5f8456f
 
 
 
 
 
 
 
 
 
 
 
 
597668f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5f8456f
 
 
 
 
 
 
 
 
597668f
5f8456f
 
 
 
 
 
 
 
 
 
 
 
1e54caa
 
5f8456f
 
 
 
 
 
1e54caa
eea9e24
5f8456f
 
 
 
eea9e24
5f8456f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e54caa
5f8456f
94a5578
 
 
 
 
 
50720ce
 
 
 
94a5578
 
50720ce
 
94a5578
 
 
 
 
 
50720ce
94a5578
50720ce
 
94a5578
50720ce
94a5578
 
 
 
 
 
 
 
50720ce
 
 
 
 
 
 
94a5578
50720ce
94a5578
50720ce
94a5578
 
5f8456f
 
90c89b6
 
 
 
 
 
 
 
b94940f
 
90c89b6
 
1e54caa
b94940f
 
 
 
 
 
 
 
90c89b6
 
b94940f
90c89b6
b94940f
90c89b6
 
 
b94940f
90c89b6
 
 
 
 
 
 
b94940f
90c89b6
 
7516302
 
 
 
 
 
 
 
5416ffb
 
 
 
 
 
 
 
 
bc98e69
5416ffb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc98e69
 
 
 
 
 
 
 
5f8456f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90c89b6
5f8456f
 
90c89b6
b94940f
 
90c89b6
5f8456f
 
 
 
 
 
 
90c89b6
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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
/**
 * Model Store β€” mutable singleton for model catalog + aliases.
 *
 * Data flow:
 *   1. loadStaticModels() β€” load from config/models.yaml (fallback baseline)
 *   2. applyBackendModels() β€” merge backend-fetched models (backend wins for shared IDs)
 *   3. getters β€” runtime reads from mutable state
 *
 * Aliases always come from YAML (user-customizable), never from backend.
 */

import { readFileSync, writeFile, existsSync, mkdirSync } from "fs";
import { resolve } from "path";
import yaml from "js-yaml";
import { getConfig } from "../config.js";
import { getConfigDir, getDataDir } from "../paths.js";

export interface CodexModelInfo {
  id: string;
  displayName: string;
  description: string;
  isDefault: boolean;
  supportedReasoningEfforts: { reasoningEffort: string; description: string }[];
  defaultReasoningEffort: string;
  inputModalities: string[];
  supportsPersonality: boolean;
  upgrade: string | null;
  /** Where this model entry came from */
  source?: "static" | "backend";
}

interface ModelsConfig {
  models: CodexModelInfo[];
  aliases: Record<string, string>;
}

// ── Mutable state ──────────────────────────────────────────────────

let _catalog: CodexModelInfo[] = [];
let _aliases: Record<string, string> = {};
let _lastFetchTime: string | null = null;
/** planType β†’ Set<modelId> β€” write path: bulk replace per plan */
let _planModelMap: Map<string, Set<string>> = new Map();
/** modelId β†’ Set<planType> β€” read path: O(1) lookup for routing */
let _modelPlanIndex: Map<string, Set<string>> = new Map();

// ── Static loading ─────────────────────────────────────────────────

/**
 * Load models from config/models.yaml (synchronous).
 * Called at startup and on hot-reload.
 */
export function loadStaticModels(configDir?: string): void {
  const dir = configDir ?? getConfigDir();
  const configPath = resolve(dir, "models.yaml");
  const raw = yaml.load(readFileSync(configPath, "utf-8")) as ModelsConfig;

  _catalog = (raw.models ?? []).map((m) => ({ ...m, source: "static" as const }));
  _aliases = raw.aliases ?? {};
  _planModelMap = new Map(); // Reset plan maps on reload
  _modelPlanIndex = new Map();
  console.log(`[ModelStore] Loaded ${_catalog.length} static models, ${Object.keys(_aliases).length} aliases`);

  // Overlay cached backend models from data/ (cold-start fallback)
  try {
    const cachePath = resolve(getDataDir(), "models-cache.yaml");
    if (existsSync(cachePath)) {
      const cached = yaml.load(readFileSync(cachePath, "utf-8")) as ModelsConfig;
      const cachedModels = cached.models ?? [];
      if (cachedModels.length > 0) {
        const staticIds = new Set(_catalog.map((m) => m.id));
        let added = 0;
        for (const m of cachedModels) {
          if (!staticIds.has(m.id)) {
            _catalog.push({ ...m, source: "backend" as const });
            added++;
          }
        }
        if (added > 0) {
          console.log(`[ModelStore] Overlaid ${added} cached backend models from data/models-cache.yaml`);
        }
      }
    }
  } catch {
    // Cache missing or corrupt β€” safe to ignore, backend fetch will repopulate
  }
}

// ── Backend merge ──────────────────────────────────────────────────

/**
 * Raw model entry from backend (fields are optional β€” format may vary).
 */
export interface BackendModelEntry {
  slug?: string;
  id?: string;
  name?: string;
  display_name?: string;
  description?: string;
  is_default?: boolean;
  default_reasoning_effort?: string;
  default_reasoning_level?: string;
  supported_reasoning_efforts?: Array<{
    reasoning_effort?: string;
    reasoningEffort?: string;
    effort?: string;
    description?: string;
  }>;
  supported_reasoning_levels?: Array<{
    effort?: string;
    description?: string;
  }>;
  input_modalities?: string[];
  supports_personality?: boolean;
  upgrade?: string | null;
  prefer_websockets?: boolean;
  context_window?: number;
  available_in_plans?: string[];
  priority?: number;
  visibility?: string;
}

/** Intermediate type with explicit efforts flag for merge logic. */
interface NormalizedModelWithMeta extends CodexModelInfo {
  _hasExplicitEfforts: boolean;
}

/**
 * Normalize a backend model entry to our CodexModelInfo format.
 */
function normalizeBackendModel(raw: BackendModelEntry): NormalizedModelWithMeta {
  const id = raw.slug ?? raw.id ?? raw.name ?? "unknown";

  // Accept both old (supported_reasoning_efforts) and new (supported_reasoning_levels) field names
  const rawEfforts = raw.supported_reasoning_efforts ?? [];
  const rawLevels = raw.supported_reasoning_levels ?? [];
  const hasExplicitEfforts = rawEfforts.length > 0 || rawLevels.length > 0;

  // Normalize reasoning efforts β€” accept effort, reasoning_effort, reasoningEffort keys
  const efforts = rawEfforts.length > 0
    ? rawEfforts.map((e) => ({
        reasoningEffort: e.reasoningEffort ?? e.reasoning_effort ?? e.effort ?? "medium",
        description: e.description ?? "",
      }))
    : rawLevels.map((e) => ({
        reasoningEffort: e.effort ?? "medium",
        description: e.description ?? "",
      }));

  return {
    id,
    displayName: raw.display_name ?? raw.name ?? id,
    description: raw.description ?? "",
    isDefault: raw.is_default ?? false,
    supportedReasoningEfforts: efforts.length > 0
      ? efforts
      : [{ reasoningEffort: "medium", description: "Default" }],
    defaultReasoningEffort: raw.default_reasoning_effort ?? raw.default_reasoning_level ?? "medium",
    inputModalities: raw.input_modalities ?? ["text"],
    supportsPersonality: raw.supports_personality ?? false,
    upgrade: raw.upgrade ?? null,
    source: "backend",
    _hasExplicitEfforts: hasExplicitEfforts,
  };
}

/**
 * Merge backend models into the catalog.
 *
 * Strategy:
 *   - Trust backend: all models returned by the backend are accepted
 *     (primary endpoint /codex/models only returns Codex-compatible models)
 *   - Backend models overwrite static models with the same ID
 *     (but YAML fields fill in missing backend fields)
 *   - Static-only models are preserved (YAML may know about models the backend doesn't list)
 *   - Aliases are never touched (always from YAML)
 */
export function applyBackendModels(backendModels: BackendModelEntry[]): void {
  const filtered = backendModels;

  const staticMap = new Map(_catalog.map((m) => [m.id, m]));
  const merged: CodexModelInfo[] = [];
  const seenIds = new Set<string>();

  for (const raw of filtered) {
    const normalized = normalizeBackendModel(raw);
    seenIds.add(normalized.id);

    const existing = staticMap.get(normalized.id);
    // Strip internal meta field before storing
    const { _hasExplicitEfforts, ...model } = normalized;
    if (existing) {
      // Backend wins, but YAML fills gaps
      merged.push({
        ...existing,
        ...model,
        // Preserve YAML fields if backend is empty
        description: model.description || existing.description,
        displayName: model.displayName || existing.displayName,
        supportedReasoningEfforts: _hasExplicitEfforts
          ? model.supportedReasoningEfforts
          : existing.supportedReasoningEfforts,
        source: "backend",
      });
    } else {
      merged.push(model);
    }
  }

  // Preserve static-only models (not in backend)
  for (const m of _catalog) {
    if (!seenIds.has(m.id)) {
      merged.push({ ...m, source: "static" });
    }
  }

  _catalog = merged;
  _lastFetchTime = new Date().toISOString();
  console.log(
    `[ModelStore] Merged ${filtered.length} backend + ${merged.length - filtered.length} static-only = ${merged.length} total models`,
  );

  // Auto-sync merged catalog back to models.yaml
  syncStaticModels();
}

/**
 * Write the current merged catalog to data/models-cache.yaml so it serves
 * as a fallback for future cold starts.  Fire-and-forget.
 *
 * config/models.yaml stays read-only (git-tracked baseline).
 */
function syncStaticModels(): void {
  const dataDir = getDataDir();
  const cachePath = resolve(dataDir, "models-cache.yaml");
  const today = new Date().toISOString().slice(0, 10);

  // Strip internal `source` field before serializing
  const models = _catalog.map(({ source: _s, ...rest }) => rest);

  const header = [
    "# Codex model cache",
    "#",
    "# Auto-synced by model-store from backend fetch results.",
    "# This is a runtime cache β€” do NOT commit to git.",
    "#",
    `# Last updated: ${today}`,
    "",
  ].join("\n");

  const body = yaml.dump(
    { models, aliases: _aliases },
    { lineWidth: 120, noRefs: true, sortKeys: false },
  );

  try {
    mkdirSync(dataDir, { recursive: true });
  } catch {
    // already exists
  }

  writeFile(cachePath, header + body, "utf-8", (err) => {
    if (err) {
      console.warn(`[ModelStore] Failed to sync models cache: ${err.message}`);
    } else {
      console.log(`[ModelStore] Synced ${models.length} models to data/models-cache.yaml`);
    }
  });
}

/**
 * Merge backend models for a specific plan type.
 * Clears old records for this planType, applies merge, then records plan→model mappings.
 */
export function applyBackendModelsForPlan(planType: string, backendModels: BackendModelEntry[]): void {
  // Merge into catalog (existing logic)
  applyBackendModels(backendModels);

  // Build new model set for this plan and replace atomically
  const admittedIds = new Set<string>();
  for (const raw of backendModels) {
    const id = raw.slug ?? raw.id ?? raw.name ?? "";
    if (id) admittedIds.add(id);
  }
  _planModelMap.set(planType, admittedIds);

  // Rebuild reverse index from scratch (plan types are few, this is cheap)
  _modelPlanIndex = new Map();
  for (const [plan, modelIds] of _planModelMap) {
    for (const id of modelIds) {
      let plans = _modelPlanIndex.get(id);
      if (!plans) {
        plans = new Set();
        _modelPlanIndex.set(id, plans);
      }
      plans.add(plan);
    }
  }

  console.log(`[ModelStore] Plan "${planType}": ${admittedIds.size} admitted models, ${_planModelMap.size} plans tracked`);
}

/**
 * Get which plan types are known to support a given model.
 * Empty array means unknown (static-only or not yet fetched).
 */
export function getModelPlanTypes(modelId: string): string[] {
  return [...(_modelPlanIndex.get(modelId) ?? [])];
}

/**
 * Check if models have ever been successfully fetched for a given plan type.
 * Returns false when the plan's model list is unknown (fetch failed or never attempted).
 */
export function isPlanFetched(planType: string): boolean {
  return _planModelMap.has(planType);
}

// ── Model name suffix parsing ───────────────────────────────────────

export interface ParsedModelName {
  modelId: string;
  serviceTier: string | null;
  reasoningEffort: string | null;
}

const SERVICE_TIER_SUFFIXES = new Set(["fast", "flex"]);
const EFFORT_SUFFIXES = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]);

/**
 * Parse a model name that may contain embedded suffixes for service_tier and reasoning_effort.
 *
 * Resolution:
 *   1. If full name is a known model ID or alias β†’ use as-is
 *   2. Otherwise, strip known suffixes from right:
 *      - `-fast`, `-flex` β†’ service_tier
 *      - `-minimal`, `-low`, `-medium`, `-high`, `-xhigh` β†’ reasoning_effort
 *   3. Resolve remaining name as model ID/alias
 */
export function parseModelName(input: string): ParsedModelName {
  const trimmed = input.trim();

  // 1. Known model or alias? Use as-is
  if (_aliases[trimmed] || _catalog.some((m) => m.id === trimmed)) {
    return { modelId: resolveModelId(trimmed), serviceTier: null, reasoningEffort: null };
  }

  // 2. Try stripping suffixes from right
  let remaining = trimmed;
  let serviceTier: string | null = null;
  let reasoningEffort: string | null = null;

  // Strip -fast/-flex (rightmost)
  for (const tier of SERVICE_TIER_SUFFIXES) {
    if (remaining.endsWith(`-${tier}`)) {
      serviceTier = tier;
      remaining = remaining.slice(0, -(tier.length + 1));
      break;
    }
  }

  // Strip -high/-low/etc (next from right)
  for (const effort of EFFORT_SUFFIXES) {
    if (remaining.endsWith(`-${effort}`)) {
      reasoningEffort = effort;
      remaining = remaining.slice(0, -(effort.length + 1));
      break;
    }
  }

  // 3. Resolve remaining as model
  const modelId = resolveModelId(remaining);
  return { modelId, serviceTier, reasoningEffort };
}

/** Reconstruct display model name: resolved modelId + any parsed suffixes. */
export function buildDisplayModelName(parsed: ParsedModelName): string {
  let name = parsed.modelId;
  if (parsed.reasoningEffort) name += `-${parsed.reasoningEffort}`;
  if (parsed.serviceTier) name += `-${parsed.serviceTier}`;
  return name;
}

// ── Getters ────────────────────────────────────────────────────────

/**
 * Resolve a model name (may be an alias) to a canonical model ID.
 */
export function resolveModelId(input: string): string {
  const trimmed = input.trim();
  if (_aliases[trimmed]) return _aliases[trimmed];
  if (_catalog.some((m) => m.id === trimmed)) return trimmed;
  return getConfig().model.default;
}

/**
 * Get model info by ID.
 */
export function getModelInfo(modelId: string): CodexModelInfo | undefined {
  return _catalog.find((m) => m.id === modelId);
}

/**
 * Get the full model catalog.
 */
export function getModelCatalog(): CodexModelInfo[] {
  return [..._catalog];
}

/**
 * Get the alias map.
 */
export function getModelAliases(): Record<string, string> {
  return { ..._aliases };
}

/**
 * Debug info for /debug/models endpoint.
 */
export function getModelStoreDebug(): {
  totalModels: number;
  backendModels: number;
  staticOnlyModels: number;
  aliasCount: number;
  lastFetchTime: string | null;
  models: Array<{ id: string; source: string }>;
  planMap: Record<string, string[]>;
} {
  const backendCount = _catalog.filter((m) => m.source === "backend").length;
  const planMap: Record<string, string[]> = {};
  for (const [planType, modelIds] of _planModelMap) {
    planMap[planType] = [...modelIds];
  }
  return {
    totalModels: _catalog.length,
    backendModels: backendCount,
    staticOnlyModels: _catalog.length - backendCount,
    aliasCount: Object.keys(_aliases).length,
    lastFetchTime: _lastFetchTime,
    models: _catalog.map((m) => ({ id: m.id, source: m.source ?? "static" })),
    planMap,
  };
}