feat(ui): replace hardcoded model lists with dynamic tokenlens API

Add GET /llm/model-suggestions endpoint that maps ProviderType to models.dev
provider keys and returns chat model IDs from the tokenlens catalog. Lazy-loads
catalog on first request to avoid empty results when engine hasn't started.

Frontend ModelCombobox now fetches suggestions via useQuery with 30min cache
instead of reading from hardcoded MODEL_SUGGESTIONS constant.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
jeffusion
2026-03-05 22:03:31 +08:00
committed by 路遥知码力
parent ec2029a942
commit 71bd310459
4 changed files with 63 additions and 6 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { useQuery } from '@tanstack/react-query';
import { fetchModels, MODEL_SUGGESTIONS } from '@/services/llmProviderService';
import { fetchModels, fetchModelSuggestions } from '@/services/llmProviderService';
import type { ProviderType } from '@/services/llmProviderService';
interface ModelComboboxProps {
@@ -42,9 +42,16 @@ export function ModelCombobox({
staleTime: 5 * 60 * 1000,
});
// Fetch dynamic model suggestions from backend (powered by models.dev)
const { data: suggestions = {} } = useQuery({
queryKey: ['llm-model-suggestions'],
queryFn: fetchModelSuggestions,
staleTime: 30 * 60 * 1000, // 30 min cache
});
// Build tagged model list: API > suggestions > custom input
const useApiFetched = fetchedModels.length > 0;
const suggestionModels = providerType ? MODEL_SUGGESTIONS[providerType] || [] : [];
const suggestionModels = providerType ? suggestions[providerType] || [] : [];
type TaggedModel = { name: string; tag: 'API' | '推荐' | '自定义' };

View File

@@ -11,6 +11,12 @@ vi.mock('@/services/llmProviderService', async () => {
return {
...actual,
fetchModels: vi.fn(),
fetchModelSuggestions: vi.fn().mockResolvedValue({
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'deepseek-chat'],
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
}),
};
});

View File

@@ -31,13 +31,23 @@ export interface TestResult {
error?: string;
}
export const MODEL_SUGGESTIONS: Record<ProviderType, string[]> = {
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'deepseek-chat', 'qwen-plus'],
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o3-mini'],
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022', 'claude-opus-4-20250514'],
/** Fallback suggestions when API is unavailable (e.g. catalog not loaded yet). */
const FALLBACK_SUGGESTIONS: Record<ProviderType, string[]> = {
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'deepseek-chat'],
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
};
export const fetchModelSuggestions = async (): Promise<Record<string, string[]>> => {
try {
const response = await api.get<Record<string, string[]>>('/llm/model-suggestions');
return response.data;
} catch {
return FALLBACK_SUGGESTIONS;
}
};
export const fetchProviders = async (): Promise<ProviderDto[]> => {
const response = await api.get<ProviderDto[]>('/llm/providers');
return response.data;

View File

@@ -14,6 +14,7 @@ import { secretRepo } from '../db/repositories/secret-repo';
import { settingsRepo } from '../db/repositories/settings-repo';
import { llmGateway } from '../llm/gateway';
import { MODEL_ROLES } from '../llm/types';
import { tokenCounter } from '../review/context/token-counter';
export const llmConfigRouter = new Hono();
@@ -289,6 +290,39 @@ llmConfigRouter.post('/providers/:id/test', async (c) => {
}
});
// ── Model Suggestions (from models.dev via tokenlens) ───────────────────
/**
* Map our ProviderType to models.dev provider keys.
* openai_compatible is special: it aggregates multiple providers since
* users often point compatible endpoints at DeepSeek, Qwen, etc.
*/
const PROVIDER_TYPE_TO_CATALOG_KEYS: Record<string, string[]> = {
openai_compatible: ['openai', 'deepseek', 'qwen'],
openai_responses: ['openai'],
anthropic: ['anthropic'],
gemini: ['google'],
};
llmConfigRouter.get('/model-suggestions', async (c) => {
// Ensure catalog is loaded (lazy init on first request)
if (!tokenCounter.hasCatalog) {
await tokenCounter.refreshCatalog();
}
const result: Record<string, string[]> = {};
for (const [providerType, catalogKeys] of Object.entries(PROVIDER_TYPE_TO_CATALOG_KEYS)) {
const models: string[] = [];
for (const key of catalogKeys) {
models.push(...tokenCounter.getModelSuggestions(key));
}
result[providerType] = models;
}
return c.json(result);
});
// ── System Settings ─────────────────────────────────────────────────────
llmConfigRouter.get('/settings', (c) => {