Spaces:
Paused
Paused
icebear0828
feat: add gpt-5.4 + codex-spark support, matrix model selector, extended reasoning efforts
7824fcd | /** | |
| * 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 } from "fs"; | |
| import { resolve } from "path"; | |
| import yaml from "js-yaml"; | |
| import { getConfig } from "../config.js"; | |
| import { getConfigDir } 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; | |
| // ββ 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 ?? {}; | |
| console.log(`[ModelStore] Loaded ${_catalog.length} static models, ${Object.keys(_aliases).length} aliases`); | |
| } | |
| // ββ 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; | |
| supported_reasoning_efforts?: Array<{ | |
| reasoning_effort?: string; | |
| reasoningEffort?: string; | |
| description?: string; | |
| }>; | |
| input_modalities?: string[]; | |
| supports_personality?: boolean; | |
| upgrade?: string | null; | |
| } | |
| /** 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"; | |
| const hasExplicitEfforts = Array.isArray(raw.supported_reasoning_efforts) && raw.supported_reasoning_efforts.length > 0; | |
| // Normalize reasoning efforts β accept both snake_case and camelCase | |
| const efforts = (raw.supported_reasoning_efforts ?? []).map((e) => ({ | |
| reasoningEffort: e.reasoningEffort ?? e.reasoning_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 ?? "medium", | |
| inputModalities: raw.input_modalities ?? ["text"], | |
| supportsPersonality: raw.supports_personality ?? false, | |
| upgrade: raw.upgrade ?? null, | |
| source: "backend", | |
| _hasExplicitEfforts: hasExplicitEfforts, | |
| }; | |
| } | |
| /** Check if a model ID is Codex-compatible (gpt-X.Y-codex-* or bare gpt-X.Y). */ | |
| function isCodexCompatibleId(id: string): boolean { | |
| if (/^gpt-\d+(\.\d+)?-codex/.test(id)) return true; | |
| if (/^gpt-\d+(\.\d+)?$/.test(id)) return true; | |
| return false; | |
| } | |
| /** | |
| * Merge backend models into the catalog. | |
| * | |
| * Strategy: | |
| * - 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) | |
| * - New Codex models from backend are auto-admitted (prevents missing new releases) | |
| * - Aliases are never touched (always from YAML) | |
| */ | |
| export function applyBackendModels(backendModels: BackendModelEntry[]): void { | |
| // Keep models that either exist in static catalog OR are Codex models. | |
| // This prevents ChatGPT-only slugs (gpt-5-2, research, etc.) from | |
| // entering the catalog, while auto-admitting new Codex releases. | |
| const staticIds = new Set(_catalog.map((m) => m.id)); | |
| const filtered = backendModels.filter((raw) => { | |
| const id = raw.slug ?? raw.id ?? raw.name ?? ""; | |
| return staticIds.has(id) || isCodexCompatibleId(id); | |
| }); | |
| 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(); | |
| const skipped = backendModels.length - filtered.length; | |
| console.log( | |
| `[ModelStore] Merged ${filtered.length} backend (${skipped} non-codex skipped) + ${merged.length - filtered.length} static-only = ${merged.length} total models`, | |
| ); | |
| } | |
| // ββ 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 }>; | |
| } { | |
| const backendCount = _catalog.filter((m) => m.source === "backend").length; | |
| 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" })), | |
| }; | |
| } | |