diff --git a/server/services/model-discovery.js b/server/services/model-discovery.js new file mode 100644 index 0000000..c7d6628 --- /dev/null +++ b/server/services/model-discovery.js @@ -0,0 +1,120 @@ +/** + * Model Discovery Service + * + * Fetches available models from MODELS_API_BASE_URL/v1/models (9router or + * any OpenAI-compatible endpoint). Falls back to hardcoded constants when + * the endpoint is unavailable or not configured. + * + * Cache TTL: 5 minutes. Re-fetched on first request after expiry. + */ + +import { CLAUDE_MODELS } from '../../shared/modelConstants.js'; + +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +let cachedModels = null; +let cacheExpiry = 0; + +/** + * Maps a raw /v1/models entry to the { value, label } shape used by the UI. + * Filters to models that are relevant for Claude routing (id contains 'claude' + * or starts with 'cc/'). + * + * @param {Object} entry - Raw model object from /v1/models + * @returns {{ value: string, label: string } | null} + */ +function mapModelEntry(entry) { + const id = typeof entry?.id === 'string' ? entry.id.trim() : null; + if (!id) return null; + + // Only surface Claude-family and cc/* (9router) models for the Claude provider. + // Extend this filter if you want to surface all models. + const isClaude = id.toLowerCase().includes('claude') || id.startsWith('cc/'); + if (!isClaude) return null; + + // Build a human-readable label from the id. + // e.g. "cc/claude-sonnet-4-6" → "claude-sonnet-4-6 (9router)" + // "claude-3-5-sonnet-20241022" → "claude-3-5-sonnet-20241022" + let label = entry.name ?? id; + if (id.startsWith('cc/')) { + label = `${id.slice(3)} (9router)`; + } + + return { value: id, label }; +} + +/** + * Fetches models from the configured MODELS_API_BASE_URL/v1/models endpoint. + * Returns null on failure (caller uses fallback). + * + * @returns {Promise | null>} + */ +async function fetchModelsFromApi() { + const baseUrl = process.env.MODELS_API_BASE_URL || process.env.ANTHROPIC_BASE_URL; + if (!baseUrl) return null; + + // Strip trailing /v1 if present so we can append /v1/models cleanly. + const cleanBase = baseUrl.replace(/\/v1\/?$/, ''); + const url = `${cleanBase}/v1/models`; + + try { + const apiKey = process.env.ANTHROPIC_API_KEY || ''; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(8000), + }); + + if (!res.ok) { + console.warn(`[model-discovery] GET ${url} returned ${res.status}`); + return null; + } + + const json = await res.json(); + // OpenAI-compatible: { data: [...] } or { models: [...] } or plain array + const raw = Array.isArray(json) ? json : (json.data ?? json.models ?? []); + if (!Array.isArray(raw)) return null; + + const mapped = raw.map(mapModelEntry).filter(Boolean); + if (mapped.length === 0) return null; + + console.log(`[model-discovery] Loaded ${mapped.length} Claude models from ${url}`); + return mapped; + } catch (err) { + console.warn(`[model-discovery] Failed to fetch ${url}:`, err?.message ?? err); + return null; + } +} + +/** + * Returns the list of available Claude model options. + * Uses cached results when fresh; falls back to CLAUDE_MODELS.OPTIONS. + * + * @returns {Promise>} + */ +export async function getAvailableClaudeModels() { + const now = Date.now(); + if (cachedModels && now < cacheExpiry) { + return cachedModels; + } + + const discovered = await fetchModelsFromApi(); + if (discovered && discovered.length > 0) { + cachedModels = discovered; + cacheExpiry = now + CACHE_TTL_MS; + return cachedModels; + } + + // Fallback: hardcoded list from shared constants + return CLAUDE_MODELS.OPTIONS; +} + +/** + * Invalidates the model cache, forcing re-fetch on next call. + */ +export function invalidateModelCache() { + cachedModels = null; + cacheExpiry = 0; +}