From b4a59f6254aa051eae270cd18bbc2515e6590a9f Mon Sep 17 00:00:00 2001 From: AKKO89 Date: Thu, 9 Jul 2026 04:30:34 +0800 Subject: [PATCH] feat: add TokenLab provider --- web-app/src/constants/models.ts | 81 ++++++++++++++++++- web-app/src/constants/providers.ts | 23 ++++++ web-app/src/lib/__tests__/models.test.ts | 13 +++ .../lib/__tests__/remoteModelCatalog.test.ts | 51 +++++++++++- web-app/src/lib/providerCaps.ts | 6 ++ web-app/src/lib/remoteModelCatalog.ts | 41 +++++++++- web-app/src/lib/utils.ts | 2 + 7 files changed, 213 insertions(+), 4 deletions(-) diff --git a/web-app/src/constants/models.ts b/web-app/src/constants/models.ts index c6dbdfbafe..0f83b8b53d 100644 --- a/web-app/src/constants/models.ts +++ b/web-app/src/constants/models.ts @@ -126,6 +126,85 @@ export const providerModels = { supportsToolCalls: true, supportsN: true, }, + tokenlab: { + models: [ + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'claude-opus-4-8', + 'claude-sonnet-5', + 'claude-fable-5', + 'gemini-3.5-flash', + 'gemini-3.1-flash-lite', + 'grok-4.3', + 'grok-4-fast', + 'qwen3.7-max', + 'deepseek-v4-pro', + 'deepseek-v4-flash', + 'glm-5.2', + 'minimax-m3', + 'kimi-k2.7-code', + ], + supportsCompletion: true, + supportsStreaming: [ + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'claude-opus-4-8', + 'claude-sonnet-5', + 'claude-fable-5', + 'gemini-3.5-flash', + 'gemini-3.1-flash-lite', + 'grok-4.3', + 'grok-4-fast', + 'qwen3.7-max', + 'deepseek-v4-pro', + 'deepseek-v4-flash', + 'glm-5.2', + 'minimax-m3', + 'kimi-k2.7-code', + ], + supportsJSON: [ + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gemini-3.5-flash', + 'gemini-3.1-flash-lite', + 'grok-4.3', + 'grok-4-fast', + ], + supportsImages: [ + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'claude-opus-4-8', + 'claude-sonnet-5', + 'claude-fable-5', + 'gemini-3.5-flash', + 'gemini-3.1-flash-lite', + 'grok-4.3', + 'grok-4-fast', + ], + supportsToolCalls: [ + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'claude-opus-4-8', + 'claude-sonnet-5', + 'claude-fable-5', + 'gemini-3.5-flash', + 'gemini-3.1-flash-lite', + 'grok-4.3', + 'grok-4-fast', + 'qwen3.7-max', + 'deepseek-v4-pro', + 'deepseek-v4-flash', + 'glm-5.2', + 'minimax-m3', + 'kimi-k2.7-code', + ], + supportsN: true, + }, nvidia: { models: ['moonshotai/kimi-k2.5', 'minimaxai/minimax-m2.5', 'z-ai/glm5'], supportsCompletion: true, @@ -144,4 +223,4 @@ export const providerModels = { supportsToolCalls: true, supportsN: true, }, -} as const \ No newline at end of file +} as const diff --git a/web-app/src/constants/providers.ts b/web-app/src/constants/providers.ts index c9b9a67eaf..17260a6129 100644 --- a/web-app/src/constants/providers.ts +++ b/web-app/src/constants/providers.ts @@ -171,6 +171,29 @@ export const predefinedProviders = [ }, ], }, + { + active: true, + api_key: '', + base_url: 'https://api.tokenlab.sh/v1', + explore_models_url: 'https://docs.tokenlab.sh/', + provider: 'tokenlab', + settings: [ + { + key: 'api-key', + title: 'API Key', + description: + "The TokenLab API uses API keys for authentication. Visit your [TokenLab dashboard](https://tokenlab.sh/dashboard) to create the API key you'll use in your requests.", + controller_type: 'input', + controller_props: { + placeholder: 'Insert API Key', + value: '', + type: 'password', + input_actions: ['unobscure', 'copy'], + }, + }, + ], + models: [], + }, { active: true, api_key: '', diff --git a/web-app/src/lib/__tests__/models.test.ts b/web-app/src/lib/__tests__/models.test.ts index d5047c5827..ff1367e8e6 100644 --- a/web-app/src/lib/__tests__/models.test.ts +++ b/web-app/src/lib/__tests__/models.test.ts @@ -47,6 +47,7 @@ describe('defaultModel', () => { it('returns first model for known providers', () => { expect(defaultModel('anthropic')).toBe('claude-sonnet-4-5') expect(defaultModel('mistral')).toBe('mistral-large-2411') + expect(defaultModel('tokenlab')).toBe('gpt-5.5') }) it('handles empty string provider', () => { @@ -381,4 +382,16 @@ describe('getModelCapabilities', () => { expect(capabilities).toContain(ModelCapabilities.TOOLS) expect(capabilities).not.toContain(ModelCapabilities.VISION) }) + + it('detects TokenLab tool and vision capabilities', () => { + const multimodal = getModelCapabilities('tokenlab', 'gpt-5.4-mini') + expect(multimodal).toContain(ModelCapabilities.COMPLETION) + expect(multimodal).toContain(ModelCapabilities.TOOLS) + expect(multimodal).toContain(ModelCapabilities.VISION) + + const textOnly = getModelCapabilities('tokenlab', 'deepseek-v4-pro') + expect(textOnly).toContain(ModelCapabilities.COMPLETION) + expect(textOnly).toContain(ModelCapabilities.TOOLS) + expect(textOnly).not.toContain(ModelCapabilities.VISION) + }) }) diff --git a/web-app/src/lib/__tests__/remoteModelCatalog.test.ts b/web-app/src/lib/__tests__/remoteModelCatalog.test.ts index 43f3f32e05..6eb3192120 100644 --- a/web-app/src/lib/__tests__/remoteModelCatalog.test.ts +++ b/web-app/src/lib/__tests__/remoteModelCatalog.test.ts @@ -38,16 +38,65 @@ function mkGeminiProvider(extra: Record = {}) { } as any } +function mkTokenLabProvider(extra: Record = {}) { + return { + provider: 'tokenlab', + base_url: 'https://api.tokenlab.sh/v1', + api_key: 'tl-test', + ...extra, + } as any +} + describe('supportsRemoteCatalog', () => { - it('supports openai, anthropic and gemini', () => { + it('supports openai, anthropic, gemini and tokenlab', () => { expect(supportsRemoteCatalog('openai')).toBe(true) expect(supportsRemoteCatalog('anthropic')).toBe(true) expect(supportsRemoteCatalog('gemini')).toBe(true) + expect(supportsRemoteCatalog('tokenlab')).toBe(true) expect(supportsRemoteCatalog('groq')).toBe(false) expect(supportsRemoteCatalog('mistral')).toBe(false) }) }) +describe('fetchTopRemoteModels tokenlab', () => { + it('keeps TokenLab chat models and filters non-chat models', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + mkResponse({ + data: [ + { id: 'gpt-5.4-mini', created: 10 }, + { id: 'claude-sonnet-5', created: 9 }, + { id: 'deepseek-v4-pro', created: 8 }, + { id: 'qwen3.7-max', created: 7 }, + { id: 'gemini-3.5-flash', created: 6 }, + { id: 'text-embedding-3-small', created: 5 }, + { id: 'unknown-model', created: 4 }, + ], + }) + ) + + const result = await fetchTopRemoteModels(mkTokenLabProvider(), fetchImpl) + const ids = result.map((m) => m.id) + + expect(ids).toContain('gpt-5.4-mini') + expect(ids).toContain('claude-sonnet-5') + expect(ids).toContain('deepseek-v4-pro') + expect(ids).toContain('qwen3.7-max') + expect(ids).toContain('gemini-3.5-flash') + expect(ids).not.toContain('text-embedding-3-small') + expect(ids).not.toContain('unknown-model') + + expect(result.find((m) => m.id === 'gpt-5.4-mini')?.capabilities).toEqual([ + 'completion', + 'tools', + 'vision', + ]) + expect(result.find((m) => m.id === 'deepseek-v4-pro')?.capabilities).toEqual([ + 'completion', + 'tools', + ]) + }) +}) + describe('fetchTopRemoteModels gemini', () => { it('sorts by version desc, infers vision+tools, includes gemma, hides embeddings/imagen/veo/aqa', async () => { const fetchImpl = vi.fn().mockResolvedValue( diff --git a/web-app/src/lib/providerCaps.ts b/web-app/src/lib/providerCaps.ts index afb46f2598..625faef1bb 100644 --- a/web-app/src/lib/providerCaps.ts +++ b/web-app/src/lib/providerCaps.ts @@ -61,6 +61,11 @@ const OPENROUTER: ProviderCaps = { maybe: new Set(['typical_p']), } +const TOKENLAB: ProviderCaps = { + supported: set('penalties', 'json_schema'), + maybe: new Set(['top_k', 'min_p', 'repetition']), +} + const XAI: ProviderCaps = { supported: set(), maybe: new Set(['penalties']), @@ -149,6 +154,7 @@ const BUILTIN_CAPS: Record = { mistral: MISTRAL, groq: GROQ, openrouter: OPENROUTER, + tokenlab: TOKENLAB, xai: XAI, huggingface: HUGGINGFACE, nvidia: NVIDIA, diff --git a/web-app/src/lib/remoteModelCatalog.ts b/web-app/src/lib/remoteModelCatalog.ts index d6ceda4f26..99c341d370 100644 --- a/web-app/src/lib/remoteModelCatalog.ts +++ b/web-app/src/lib/remoteModelCatalog.ts @@ -22,7 +22,8 @@ export function supportsRemoteCatalog(providerName: string): boolean { return ( providerName === 'openai' || providerName === 'anthropic' || - providerName === 'gemini' + providerName === 'gemini' || + providerName === 'tokenlab' ) } @@ -99,6 +100,40 @@ function inferAnthropicCapabilities(id: string): string[] | null { return ['completion', 'tools', 'vision'] } +function inferTokenLabCapabilities(id: string): string[] | null { + if ( + id.startsWith('text-embedding-') || + id.startsWith('embedding-') || + id.startsWith('whisper-') || + id.startsWith('tts-') || + id.startsWith('dall-e-') || + id.startsWith('gpt-image-') + ) { + return null + } + + if ( + id.startsWith('gpt-') || + id.startsWith('claude-') || + id.startsWith('gemini-') || + id.startsWith('grok-') + ) { + return ['completion', 'tools', 'vision'] + } + + if ( + id.startsWith('deepseek-') || + id.startsWith('glm-') || + id.startsWith('qwen') || + id.startsWith('kimi-') || + id.startsWith('minimax-') + ) { + return ['completion', 'tools'] + } + + return null +} + function buildHeaders(p: ProviderLike, key: string | undefined): Record { const headers: Record = { 'Content-Type': 'application/json' } if (key) { @@ -174,7 +209,9 @@ function normalizeCatalog(providerName: string, rows: unknown[]): RemoteCatalogM ? inferOpenAICapabilities : providerName === 'gemini' ? inferGeminiCapabilities - : inferAnthropicCapabilities + : providerName === 'tokenlab' + ? inferTokenLabCapabilities + : inferAnthropicCapabilities const parsed: RemoteCatalogModel[] = [] for (const raw of rows) { diff --git a/web-app/src/lib/utils.ts b/web-app/src/lib/utils.ts index 453ed20d40..49550567c5 100644 --- a/web-app/src/lib/utils.ts +++ b/web-app/src/lib/utils.ts @@ -176,6 +176,8 @@ export const getProviderTitle = (provider: string) => { return 'OpenAI' case 'openrouter': return 'OpenRouter' + case 'tokenlab': + return 'TokenLab' case 'gemini': return 'Gemini' case 'huggingface':