From bdac1a558f432c74a5a3cfbe28659237043d569a Mon Sep 17 00:00:00 2001 From: David Babel Date: Thu, 3 Sep 2026 18:33:13 +0200 Subject: [PATCH] add unsloth, fix buttons in provider pick --- src/cli/config.ts | 1 + src/server/index.ts | 2 +- src/server/llm/backend.test.ts | 12 ++++- src/server/llm/backend.ts | 21 +++++++- src/server/provider-manager.ts | 5 +- src/shared/types.ts | 11 +++- .../settings/ProviderSelector.test.tsx | 1 + .../components/shared/ProviderModal.test.tsx | 52 +++++++++++++++++++ web/src/components/shared/ProviderModal.tsx | 20 ++++--- web/src/stores/config.ts | 13 ++++- 10 files changed, 123 insertions(+), 15 deletions(-) diff --git a/src/cli/config.ts b/src/cli/config.ts index 518a94a8..b7e4f638 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -26,6 +26,7 @@ const backendSchema = z.enum([ 'ollama', 'llamacpp', 'lmstudio', + 'unsloth', 'openai', 'anthropic', 'opencode-go', diff --git a/src/server/index.ts b/src/server/index.ts index 4bb164dc..13a73ab9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2117,7 +2117,7 @@ export async function createServerHandle(config: Config): Promise const models = await fetchModelsWithContext( url, apiKey, - backend as 'ollama' | 'vllm' | 'sglang' | 'llamacpp' | 'lmstudio' | 'unknown' | undefined, + backend as 'ollama' | 'vllm' | 'sglang' | 'llamacpp' | 'lmstudio' | 'unsloth' | 'unknown' | undefined, ) if (models.length === 0) { return res.status(404).json({ error: `No models found at ${buildModelsUrl(url)}`, url }) diff --git a/src/server/llm/backend.test.ts b/src/server/llm/backend.test.ts index ff7291cf..847abb39 100644 --- a/src/server/llm/backend.test.ts +++ b/src/server/llm/backend.test.ts @@ -103,7 +103,16 @@ describe('backend', () => { }) it('does not route effort via chat_template_kwargs for other backends', () => { - for (const backend of ['vllm', 'sglang', 'openai', 'anthropic', 'ollama', 'lmstudio', 'opencode-go'] as const) { + for (const backend of [ + 'vllm', + 'sglang', + 'openai', + 'anthropic', + 'ollama', + 'lmstudio', + 'unsloth', + 'opencode-go', + ] as const) { expect(getBackendCapabilities(backend).routesEffortViaChatTemplateKwargs).toBe(false) } }) @@ -117,6 +126,7 @@ describe('backend', () => { ollama: 'Ollama', llamacpp: 'llama.cpp', lmstudio: 'LM Studio', + unsloth: 'Unsloth Studio', 'opencode-go': 'OpenCode Go', openai: 'OpenAI', anthropic: 'Anthropic', diff --git a/src/server/llm/backend.ts b/src/server/llm/backend.ts index 8adc5e4a..8b41d57d 100644 --- a/src/server/llm/backend.ts +++ b/src/server/llm/backend.ts @@ -4,7 +4,16 @@ */ export type Backend = - 'vllm' | 'sglang' | 'ollama' | 'llamacpp' | 'lmstudio' | 'opencode-go' | 'openai' | 'anthropic' | 'unknown' + | 'vllm' + | 'sglang' + | 'ollama' + | 'llamacpp' + | 'lmstudio' + | 'unsloth' + | 'opencode-go' + | 'openai' + | 'anthropic' + | 'unknown' export interface BackendCapabilities { /** Whether chat_template_kwargs with enable_thinking works (vLLM/SGLang) */ @@ -76,6 +85,14 @@ const BACKEND_CAPABILITIES: Record = { routesEffortViaChatTemplateKwargs: false, usesMaxCompletionTokens: false, }, + // Unsloth Studio serves an OpenAI-compatible API on a local port. + unsloth: { + supportsChatTemplateKwargs: false, + supportsTopK: true, + supportsNumCtx: false, + routesEffortViaChatTemplateKwargs: false, + usesMaxCompletionTokens: false, + }, 'opencode-go': { supportsChatTemplateKwargs: false, supportsTopK: true, @@ -158,6 +175,8 @@ export function getBackendDisplayName(backend: Backend): string { return 'llama.cpp' case 'lmstudio': return 'LM Studio' + case 'unsloth': + return 'Unsloth Studio' case 'opencode-go': return 'OpenCode Go' case 'openai': diff --git a/src/server/provider-manager.ts b/src/server/provider-manager.ts index 3d6d188a..0289dcf8 100644 --- a/src/server/provider-manager.ts +++ b/src/server/provider-manager.ts @@ -642,7 +642,8 @@ export function createProviderManager(config: Config, options: ProviderManagerOp clearModelCache(cacheUrl) // Refetch models from backend when switching providers - const backend = provider.backend as 'ollama' | 'vllm' | 'sglang' | 'llamacpp' | 'lmstudio' | 'unknown' + const backend = provider.backend as + 'ollama' | 'vllm' | 'sglang' | 'llamacpp' | 'lmstudio' | 'unsloth' | 'unknown' logger.info('activateProvider fetching models', { providerId, providerName: provider.name, @@ -1022,7 +1023,7 @@ export function createProviderManager(config: Config, options: ProviderManagerOp return { success: false, error: 'Provider not found' } } - const backend = provider.backend as 'ollama' | 'vllm' | 'sglang' | 'llamacpp' | 'lmstudio' | 'unknown' + const backend = provider.backend as 'ollama' | 'vllm' | 'sglang' | 'llamacpp' | 'lmstudio' | 'unsloth' | 'unknown' logger.info('refreshProviderModels fetching models', { providerId, providerName: provider.name, diff --git a/src/shared/types.ts b/src/shared/types.ts index 9b111c3a..d42339a9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -641,7 +641,16 @@ export interface Diagnostic { /** Supported LLM inference backends */ export type LlmBackend = - 'vllm' | 'sglang' | 'ollama' | 'llamacpp' | 'lmstudio' | 'opencode-go' | 'openai' | 'anthropic' | 'unknown' + | 'vllm' + | 'sglang' + | 'ollama' + | 'llamacpp' + | 'lmstudio' + | 'unsloth' + | 'opencode-go' + | 'openai' + | 'anthropic' + | 'unknown' /** Extended backend type including cloud providers */ export type ProviderBackend = LlmBackend diff --git a/web/src/components/settings/ProviderSelector.test.tsx b/web/src/components/settings/ProviderSelector.test.tsx index 8d6f6daa..c3a261e5 100644 --- a/web/src/components/settings/ProviderSelector.test.tsx +++ b/web/src/components/settings/ProviderSelector.test.tsx @@ -66,6 +66,7 @@ vi.mock('../../stores/config', () => ({ ollama: 'Ollama', llamacpp: 'llama.cpp', lmstudio: 'LM Studio', + unsloth: 'Unsloth Studio', openai: 'OpenAI', anthropic: 'Anthropic', 'opencode-go': 'OpenCode Go', diff --git a/web/src/components/shared/ProviderModal.test.tsx b/web/src/components/shared/ProviderModal.test.tsx index df05aabe..84f4e2e4 100644 --- a/web/src/components/shared/ProviderModal.test.tsx +++ b/web/src/components/shared/ProviderModal.test.tsx @@ -59,6 +59,8 @@ function clickSave() { const savedProvider = (): ProviderFormData => onSaveMock.mock.calls[0]![0]! +const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }) + const buttonByText = (text: string) => Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.trim() === text) as HTMLButtonElement | undefined @@ -436,6 +438,56 @@ describe('ProviderModal - thinkingLevel persistence', () => { expect(savedData.transportAdapter).toBeUndefined() }) + it('re-applies URL and name when switching between engine cards', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/api/provider-presets')) return jsonResponse({ presets: [] }) + return jsonResponse({ models: [] }) + }), + ) + + // Step 1 only: the engine picker lives there. + await renderProviderModal({ initialStep: undefined }, 100) + + buttonByText('Ollama')?.click() + await tick(0) + + const urlInput = document.body.querySelector('[data-testid="provider-modal-url"]') as HTMLInputElement + const nameInput = document.body.querySelector('input[placeholder="My LLM Server"]') as HTMLInputElement + expect(urlInput.value).toBe('http://localhost:11434') + expect(nameInput.value).toBe('Ollama') + + // Regression: clicking a second card must overwrite the pre-filled values. + buttonByText('LM Studio')?.click() + await tick(0) + + expect(urlInput.value).toBe('http://localhost:1234') + expect(nameInput.value).toBe('LM Studio') + }) + + it('prefills Unsloth URL and name when its card is clicked', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/api/provider-presets')) return jsonResponse({ presets: [] }) + return jsonResponse({ models: [] }) + }), + ) + + await renderProviderModal({ initialStep: undefined }, 100) + + buttonByText('Unsloth')?.click() + await tick(0) + + const urlInput = document.body.querySelector('[data-testid="provider-modal-url"]') as HTMLInputElement + const nameInput = document.body.querySelector('input[placeholder="My LLM Server"]') as HTMLInputElement + expect(urlInput.value).toBe('http://localhost:8888') + expect(nameInput.value).toBe('Unsloth') + }) + it('disables reasoning messages when auto-config detects a rejected history field', async () => { vi.stubGlobal( 'fetch', diff --git a/web/src/components/shared/ProviderModal.tsx b/web/src/components/shared/ProviderModal.tsx index 6025ca62..5da5b2b7 100644 --- a/web/src/components/shared/ProviderModal.tsx +++ b/web/src/components/shared/ProviderModal.tsx @@ -14,7 +14,7 @@ import { REASONING_EFFORT_VALUES } from '../../lib/model-value' import { isSmallContext } from '../../lib/context-warning' import { groupModeFamilies, MODE_SUFFIXES, splitModeSuffix } from '@shared/reasoning-effort.js' -const COMMON_PORTS = [8080, 11434, 8000, 1234] +const COMMON_PORTS = [8080, 11434, 8000, 1234, 8888] interface ProviderPreset { id: string @@ -1458,7 +1458,7 @@ export function ProviderModal({ ? t({ en: 'Edit Provider', fr: 'Modifier le fournisseur' }) : t({ en: 'Add Provider', fr: 'Ajouter un fournisseur' }) } - size="lg" + size="xl" footer={footer} closeOnBackdropClick={false} closeOnEscape={!showDefaults && !rawModalData} @@ -1482,7 +1482,9 @@ export function ProviderModal({ -
+ {/* Engine cards share one row on wide screens and wrap into equal-width + rows when the viewport narrows, so labels never truncate. */} +
{providerPresets.map((preset) => (