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
1 change: 1 addition & 0 deletions src/cli/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const backendSchema = z.enum([
'ollama',
'llamacpp',
'lmstudio',
'unsloth',
'openai',
'anthropic',
'opencode-go',
Expand Down
2 changes: 1 addition & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2117,7 +2117,7 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
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 })
Expand Down
12 changes: 11 additions & 1 deletion src/server/llm/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand All @@ -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',
Expand Down
21 changes: 20 additions & 1 deletion src/server/llm/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -76,6 +85,14 @@ const BACKEND_CAPABILITIES: Record<Backend, BackendCapabilities> = {
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,
Expand Down Expand Up @@ -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':
Expand Down
5 changes: 3 additions & 2 deletions src/server/provider-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions web/src/components/settings/ProviderSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
52 changes: 52 additions & 0 deletions web/src/components/shared/ProviderModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
20 changes: 12 additions & 8 deletions web/src/components/shared/ProviderModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -1482,7 +1482,9 @@ export function ProviderModal({
<label className="block text-sm text-text-secondary mb-2">
{t({ en: 'Inference engine', fr: 'Moteur d’inférence' })}
</label>
<div className="grid grid-cols-5 gap-2">
{/* Engine cards share one row on wide screens and wrap into equal-width
rows when the viewport narrows, so labels never truncate. */}
<div className="flex flex-wrap gap-2">
{providerPresets.map((preset) => (
<button
key={preset.id}
Expand All @@ -1498,7 +1500,7 @@ export function ProviderModal({
setFetchError(null)
resetStep2()
}}
className={`p-2 rounded border text-center text-sm transition-colors ${
className={`flex-1 min-w-fit px-2 py-2 whitespace-nowrap rounded border text-center text-sm transition-colors ${
formTransportAdapter && formTransportAdapter === preset.transportAdapter
? 'border-accent-primary bg-accent-primary/10 text-accent-primary'
: 'border-border hover:border-text-muted text-text-secondary'
Expand All @@ -1518,7 +1520,7 @@ export function ProviderModal({
setFetchError(null)
resetStep2()
}}
className={`p-2 rounded border text-center text-sm transition-colors ${
className={`flex-1 min-w-fit px-2 py-2 whitespace-nowrap rounded border text-center text-sm transition-colors ${
formBackend === 'unknown'
? 'border-accent-primary bg-accent-primary/10 text-accent-primary'
: 'border-border hover:border-text-muted text-text-secondary'
Expand All @@ -1532,28 +1534,30 @@ export function ProviderModal({
11434: 'ollama',
8080: 'llamacpp',
1234: 'lmstudio',
8888: 'unsloth',
}
const nameMap: Record<number, string> = {
8000: 'vLLM',
11434: 'Ollama',
8080: 'llama.cpp',
1234: 'LM Studio',
8888: 'Unsloth',
}
return (
<button
key={port}
type="button"
onClick={() => {
setFormName((prev) => prev || (nameMap[port] ?? ''))
setFormUrl((prev) => prev || `http://localhost:${port}`)
setFormName(nameMap[port] ?? '')
setFormUrl(`http://localhost:${port}`)
setFormBackend(backendMap[port] ?? '')
setFormIsLocal(true)
setFormAuthAdapter(undefined)
setFormTransportAdapter(undefined)
setFetchError(null)
resetStep2()
}}
className={`p-2 rounded border text-center text-sm transition-colors ${
className={`flex-1 min-w-fit px-2 py-2 whitespace-nowrap rounded border text-center text-sm transition-colors ${
formBackend === backendMap[port]
? 'border-accent-primary bg-accent-primary/10 text-accent-primary'
: 'border-border hover:border-text-muted text-text-secondary'
Expand Down
13 changes: 12 additions & 1 deletion web/src/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ import type { ModelConfig } from '@shared/types.js'
type LlmStatus = 'connected' | 'disconnected' | 'unknown'

type Backend =
'vllm' | 'sglang' | 'ollama' | 'llamacpp' | 'lmstudio' | 'openai' | 'anthropic' | 'opencode-go' | 'unknown'
| 'vllm'
| 'sglang'
| 'ollama'
| 'llamacpp'
| 'lmstudio'
| 'unsloth'
| 'openai'
| 'anthropic'
| 'opencode-go'
| 'unknown'
type ProviderStatus = 'connected' | 'disconnected' | 'unknown'

interface Provider {
Expand Down Expand Up @@ -46,6 +55,8 @@ function getBackendDisplayName(backend: Backend): string {
return 'llama.cpp'
case 'lmstudio':
return 'LM Studio'
case 'unsloth':
return 'Unsloth Studio'
case 'openai':
return 'OpenAI'
case 'anthropic':
Expand Down