feat(llm): Dynamically fetch available models from providers

This commit is contained in:
2026-07-16 19:16:55 +05:30
parent 2b56c01e4c
commit 8ad94d3fc2
8 changed files with 1083 additions and 8 deletions

View File

@@ -450,6 +450,38 @@ The `buildLLMProvider()` and `buildEmbeddingProvider()` functions perform the fi
| `"deepseek"` | `DeepSeekProvider` | _(falls through to mock)_ |
| _(anything else)_ | `MockLLMProvider` | `MockEmbeddingProvider` |
## Model Listing and Discovery
The `ModelLister` class provides a unified interface to dynamically query available models from the remote provider APIs.
### Caching and TTL
All list requests are cached in-memory with a **5-minute TTL** (`300,000ms`) to prevent rapid, repetitive remote API requests and avoid rate limit exhaustion.
- **Cache Key**: Generated using `providerName` combined with either the `apiKey` or `endpointUrl`.
- **Invalidation**: Call `ModelLister.invalidateCache(providerName, apiKey, endpointUrl)` to clear cache for specific instances, or `ModelLister.clearCache()` to wipe all lists.
### Provider Integration Details
| Provider | Endpoint | Auth Header | Pagination |
| ----------------- | ---------------------------- | ----------------------- | --------------------------- |
| **Google Gemini** | `GET /v1beta/models?key=KEY` | Query Param | ✅ Loop via `nextPageToken` |
| **OpenAI** | `GET /v1/models` | `Authorization: Bearer` | ❌ |
| **Anthropic** | `GET /v1/models` | `x-api-key` | ✅ Loop via `after_id` |
| **Groq** | `GET /openai/v1/models` | `Authorization: Bearer` | ❌ |
| **DeepSeek** | `GET /models` | `Authorization: Bearer` | ❌ |
| **Ollama** | `GET /api/tags` | None (Local) | ❌ |
| **OpenRouter** | `GET /api/v1/models` | Optional Bearer | ❌ |
| **Mock** | Instant return (no fetch) | — | — |
### Methods
- `listModels(providerName: string, apiKey: string, endpointUrl?: string): Promise<ModelInfo[]>`
- `invalidateCache(providerName: string, apiKey: string, endpointUrl?: string): void`
- `clearCache(): void`
---
## Structured Output
All real providers use LangChain's `.withStructuredOutput(schema, { includeRaw: true })` pattern:
@@ -489,6 +521,7 @@ packages/llm/
│ ├── index.ts # Re-exports everything
│ ├── llm.ts # Interfaces, types, AVAILABLE_PROVIDERS
│ ├── config.ts # Env var parsing (Zod)
│ ├── model-lister.ts # ModelLister with cache and API fetching
│ ├── provider-manager.ts # ProviderManager (SQLite CRUD)
│ └── providers/
│ ├── google-genai.ts # GeminiProvider + GeminiEmbeddingProvider
@@ -502,6 +535,7 @@ packages/llm/
├── tests/
│ ├── mock.test.ts
│ ├── openrouter.test.ts
│ ├── model-lister.test.ts # ModelLister cache and fetch logic unit tests
│ └── provider-manager.test.ts
└── package.json
```

View File

@@ -1,5 +1,6 @@
export * from "./llm.js";
export * from "./config.js";
export * from "./model-lister.js";
export * from "./providers/google-genai.js";
export * from "./providers/mock.js";
export * from "./providers/ollama.js";

View File

@@ -0,0 +1,261 @@
/**
* ModelLister — fetches available models from each provider's REST API.
* Results are cached in-memory with a 5-minute TTL to avoid repeated calls.
*/
export interface ModelInfo {
id: string;
name: string;
ownedBy?: string;
}
interface CacheEntry {
models: ModelInfo[];
fetchedAt: number;
}
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const FETCH_TIMEOUT_MS = 10_000; // 10 seconds
// Cache key is "providerName:apiKey-or-endpoint" (we don't hash since it's in-process)
const modelCache = new Map<string, CacheEntry>();
function cacheKey(
providerName: string,
apiKey: string,
endpointUrl?: string,
): string {
return `${providerName}:${endpointUrl || apiKey}`;
}
async function fetchWithTimeout(
url: string,
init?: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function fetchGeminiModels(apiKey: string): Promise<ModelInfo[]> {
const models: ModelInfo[] = [];
let pageToken: string | undefined;
do {
const url = new URL(
"https://generativelanguage.googleapis.com/v1beta/models",
);
url.searchParams.set("key", apiKey);
url.searchParams.set("pageSize", "100");
if (pageToken) {
url.searchParams.set("pageToken", pageToken);
}
const res = await fetchWithTimeout(url.toString());
if (!res.ok) return models;
const json = (await res.json()) as {
models?: { name: string; displayName?: string }[];
nextPageToken?: string;
};
for (const m of json.models ?? []) {
// m.name is like "models/gemini-2.5-flash"; strip "models/" prefix
const id = m.name.replace(/^models\//, "");
models.push({ id, name: m.displayName || id });
}
pageToken = json.nextPageToken;
} while (pageToken);
return models;
}
async function fetchOpenAICompatibleModels(
baseUrl: string,
apiKey: string,
): Promise<ModelInfo[]> {
const res = await fetchWithTimeout(`${baseUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
if (!res.ok) return [];
const json = (await res.json()) as {
data?: { id: string; owned_by?: string; name?: string }[];
};
return (json.data ?? []).map((m) => ({
id: m.id,
name: m.name || m.id,
ownedBy: m.owned_by,
}));
}
async function fetchAnthropicModels(apiKey: string): Promise<ModelInfo[]> {
const models: ModelInfo[] = [];
let afterId: string | undefined;
do {
const url = new URL("https://api.anthropic.com/v1/models");
url.searchParams.set("limit", "1000");
if (afterId) {
url.searchParams.set("after_id", afterId);
}
const res = await fetchWithTimeout(url.toString(), {
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
Accept: "application/json",
},
});
if (!res.ok) return models;
const json = (await res.json()) as {
data?: { id: string; display_name?: string }[];
has_more?: boolean;
last_id?: string;
};
for (const m of json.data ?? []) {
models.push({ id: m.id, name: m.display_name || m.id });
}
afterId = json.has_more ? json.last_id : undefined;
} while (afterId);
return models;
}
async function fetchOllamaModels(endpointUrl: string): Promise<ModelInfo[]> {
const base = endpointUrl.replace(/\/$/, "");
const res = await fetchWithTimeout(`${base}/api/tags`);
if (!res.ok) return [];
const json = (await res.json()) as {
models?: { name: string; model?: string }[];
};
return (json.models ?? []).map((m) => ({
id: m.name,
name: m.name,
}));
}
async function fetchOpenRouterModels(apiKey: string): Promise<ModelInfo[]> {
const res = await fetchWithTimeout(
"https://openrouter.ai/api/v1/models",
apiKey
? {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
}
: { headers: { Accept: "application/json" } },
);
if (!res.ok) return [];
const json = (await res.json()) as {
data?: { id: string; name?: string; owned_by?: string }[];
};
return (json.data ?? []).map((m) => ({
id: m.id,
name: m.name || m.id,
ownedBy: m.owned_by,
}));
}
export class ModelLister {
/**
* List available models for a given provider. Results are cached for 5 minutes.
*
* @param providerName The provider ID (e.g. "openai", "google-genai")
* @param apiKey The API key for the provider (or "none" for Ollama)
* @param endpointUrl The endpoint URL (required for Ollama, ignored otherwise)
* @returns Array of ModelInfo objects, or [] on any error
*/
static async listModels(
providerName: string,
apiKey: string,
endpointUrl?: string,
): Promise<ModelInfo[]> {
if (providerName === "mock") {
return [{ id: "mock", name: "Mock Model" }];
}
const key = cacheKey(providerName, apiKey, endpointUrl);
const cached = modelCache.get(key);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.models;
}
let models: ModelInfo[] = [];
try {
switch (providerName) {
case "google-genai":
models = await fetchGeminiModels(apiKey);
break;
case "openai":
models = await fetchOpenAICompatibleModels(
"https://api.openai.com/v1",
apiKey,
);
break;
case "anthropic":
models = await fetchAnthropicModels(apiKey);
break;
case "groq":
models = await fetchOpenAICompatibleModels(
"https://api.groq.com/openai/v1",
apiKey,
);
break;
case "deepseek":
models = await fetchOpenAICompatibleModels(
"https://api.deepseek.com",
apiKey,
);
break;
case "ollama":
models = await fetchOllamaModels(
endpointUrl || "http://localhost:11434",
);
break;
case "openrouter":
models = await fetchOpenRouterModels(apiKey);
break;
default:
models = [];
}
} catch {
// Network error, invalid key, timeout — return empty array for graceful degradation
models = [];
}
modelCache.set(key, { models, fetchedAt: Date.now() });
return models;
}
/** Invalidate the cache entry for a specific provider+key combination. */
static invalidateCache(
providerName: string,
apiKey: string,
endpointUrl?: string,
): void {
modelCache.delete(cacheKey(providerName, apiKey, endpointUrl));
}
/** Clear the entire model cache. */
static clearCache(): void {
modelCache.clear();
}
}

View File

@@ -0,0 +1,120 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { ModelLister } from "@omnia/llm";
describe("ModelLister Unit Tests (Tier 1)", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
ModelLister.clearCache();
});
afterEach(() => {
vi.restoreAllMocks();
});
test("returns mock provider list instantly without fetch", async () => {
const models = await ModelLister.listModels("mock", "none");
expect(models).toEqual([{ id: "mock", name: "Mock Model" }]);
expect(fetch).not.toHaveBeenCalled();
});
test("fetches and caches OpenAI-compatible models", async () => {
const mockResponse = {
data: [
{ id: "gpt-4o", owned_by: "openai" },
{ id: "gpt-4o-mini", owned_by: "openai" },
],
};
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockResponse,
});
vi.stubGlobal("fetch", mockFetch);
// First call: Should fetch
const models = await ModelLister.listModels("openai", "test-key");
expect(models).toEqual([
{ id: "gpt-4o", name: "gpt-4o", ownedBy: "openai" },
{ id: "gpt-4o-mini", name: "gpt-4o-mini", ownedBy: "openai" },
]);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
"https://api.openai.com/v1/models",
expect.objectContaining({
headers: {
Authorization: "Bearer test-key",
Accept: "application/json",
},
}),
);
// Second call: Should read from cache
const cachedModels = await ModelLister.listModels("openai", "test-key");
expect(cachedModels).toEqual(models);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
test("respects cache invalidation", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: "model-1" }] }),
});
vi.stubGlobal("fetch", mockFetch);
await ModelLister.listModels("openai", "test-key");
expect(mockFetch).toHaveBeenCalledTimes(1);
// Invalidate
ModelLister.invalidateCache("openai", "test-key");
// Second call: Should fetch again
await ModelLister.listModels("openai", "test-key");
expect(mockFetch).toHaveBeenCalledTimes(2);
});
test("gracefully returns empty array on fetch failure", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
vi.stubGlobal("fetch", mockFetch);
const models = await ModelLister.listModels("openai", "bad-key");
expect(models).toEqual([]);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
test("handles Gemini pagination correctly", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
models: [
{
name: "models/gemini-2.5-flash",
displayName: "Gemini 2.5 Flash",
},
],
nextPageToken: "token-1",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
models: [
{ name: "models/gemini-2.5-pro", displayName: "Gemini 2.5 Pro" },
],
}),
});
vi.stubGlobal("fetch", mockFetch);
const models = await ModelLister.listModels("google-genai", "gemini-key");
expect(models).toEqual([
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
]);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});