feat: add model-discovery service fetching /v1/models from 9router
This commit is contained in:
parent
0532fd19a6
commit
2eb6530be3
1 changed files with 120 additions and 0 deletions
120
server/services/model-discovery.js
Normal file
120
server/services/model-discovery.js
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue