feat: add model-discovery service fetching /v1/models from 9router

This commit is contained in:
Zac Gaetano 2026-05-29 01:03:15 -04:00
parent 0532fd19a6
commit 2eb6530be3

View file

@ -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<Array<{value: string, label: string}> | 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<Array<{value: string, label: string}>>}
*/
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;
}