codex-proxy / src /models /model-store.ts
icebear0828
feat: add gpt-5.4 + codex-spark support, matrix model selector, extended reasoning efforts
7824fcd
raw
history blame
8.3 kB
/**
* 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" })),
};
}