Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion web-app/src/constants/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -144,4 +223,4 @@ export const providerModels = {
supportsToolCalls: true,
supportsN: true,
},
} as const
} as const
23 changes: 23 additions & 0 deletions web-app/src/constants/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down
13 changes: 13 additions & 0 deletions web-app/src/lib/__tests__/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
51 changes: 50 additions & 1 deletion web-app/src/lib/__tests__/remoteModelCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,21 @@ function mkGeminiProvider(extra: Record<string, unknown> = {}) {
} as any
}

function mkTokenLabProvider(extra: Record<string, unknown> = {}) {
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)
})
Expand All @@ -55,6 +65,45 @@ describe('supportsRemoteCatalog', () => {
})
})

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(
Expand Down
6 changes: 6 additions & 0 deletions web-app/src/lib/providerCaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
Expand Down Expand Up @@ -155,6 +160,7 @@ const BUILTIN_CAPS: Record<string, ProviderCaps> = {
mistral: MISTRAL,
groq: GROQ,
openrouter: OPENROUTER,
tokenlab: TOKENLAB,
xai: XAI,
huggingface: HUGGINGFACE,
nvidia: NVIDIA,
Expand Down
47 changes: 44 additions & 3 deletions web-app/src/lib/remoteModelCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function ensureAnthropicHeaders(
setDefaultHeader(headers, ANTHROPIC_BROWSER_ACCESS_HEADER, 'true')
}

type CatalogKind = 'openai' | 'anthropic' | 'gemini'
type CatalogKind = 'openai' | 'anthropic' | 'gemini' | 'tokenlab'

/// Resolve which catalog shape a provider speaks. `api_type` is authoritative
/// (a custom-named Anthropic gateway still lists Claude models), falling back
Expand All @@ -75,7 +75,12 @@ function resolveCatalogKind(
const name = typeof provider === 'string' ? provider : provider.provider
const apiType = typeof provider === 'string' ? undefined : provider.api_type
if (apiType === 'anthropic') return 'anthropic'
if (name === 'openai' || name === 'anthropic' || name === 'gemini') {
if (
name === 'openai' ||
name === 'anthropic' ||
name === 'gemini' ||
name === 'tokenlab'
) {
return name
}
return null
Expand Down Expand Up @@ -160,6 +165,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<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (key) {
Expand Down Expand Up @@ -237,7 +276,9 @@ function normalizeCatalog(kind: CatalogKind, rows: unknown[]): RemoteCatalogMode
? inferOpenAICapabilities
: kind === 'gemini'
? inferGeminiCapabilities
: inferAnthropicCapabilities
: kind === 'tokenlab'
? inferTokenLabCapabilities
: inferAnthropicCapabilities

const parsed: RemoteCatalogModel[] = []
for (const raw of rows) {
Expand Down
2 changes: 2 additions & 0 deletions web-app/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down