/** * 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; }