mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
feat(llm): Dynamically fetch available models from providers
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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";
|
||||
|
||||
261
packages/llm/src/model-lister.ts
Normal file
261
packages/llm/src/model-lister.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
120
packages/llm/tests/model-lister.test.ts
Normal file
120
packages/llm/tests/model-lister.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user