diff --git a/.env.example b/.env.example
index 5d5aad5970..90ebea1f72 100644
--- a/.env.example
+++ b/.env.example
@@ -304,6 +304,23 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# OPENAI_MODEL=your-model-id-here
+# -----------------------------------------------------------------------------
+# Option 6b: llama-server (llama.cpp) / self-hosted OpenAI-compat
+# -----------------------------------------------------------------------------
+# Prefer a provider profile (/provider) with "Self-hosted tools" enabled —
+# no shell env required. The flag is stored on that profile only.
+#
+# Prefer structured tools on the server when possible, e.g.:
+# llama-server -m model.gguf --jinja --port 8080
+# (use the tool-call parser appropriate for your model, e.g. Qwen)
+#
+# CLAUDE_CODE_USE_OPENAI=1
+# OPENAI_BASE_URL=http://127.0.0.1:8080/v1
+# OPENAI_API_KEY=none
+# OPENAI_MODEL=qwen3.6:35b
+# OPENAI_SELF_HOSTED_TOOLS=1 # optional shell override; prefer profile flag
+
+
# -----------------------------------------------------------------------------
# Option 7: AWS Bedrock
# -----------------------------------------------------------------------------
diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx
index 6d1fd96b75..90cb533d72 100644
--- a/src/components/ProviderManager.test.tsx
+++ b/src/components/ProviderManager.test.tsx
@@ -308,6 +308,20 @@ function mockProviderProfilesModule(options?: {
}
},
getProviderProfiles: options?.getProviderProfiles ?? (() => []),
+ // Matches ProviderManager formSteps / persistDraft / profileSummary import.
+ // Real helper is openai-compatibility-mode; mirror that for test presets.
+ providerProfileSupportsSelfHostedTools: (provider: string) =>
+ ![
+ 'anthropic',
+ 'custom-anthropic',
+ 'gemini',
+ 'mistral',
+ 'github',
+ 'github-enterprise',
+ 'bedrock',
+ 'vertex',
+ 'minimax',
+ ].includes(provider),
setActiveProviderProfile: options?.setActiveProviderProfile ?? (() => null),
updateProviderProfile: options?.updateProviderProfile ?? (() => null),
}))
@@ -697,6 +711,10 @@ test('ProviderManager shows API mode picker for custom OpenAI-compatible provide
frame.includes('Default model'),
)
mounted.stdin.write('\r')
+ await waitForFrameOutput(mounted.getOutput, frame =>
+ frame.includes('Self-hosted tools'),
+ )
+ mounted.stdin.write('\r')
const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API mode') && frame.includes('Automatic'),
@@ -1367,13 +1385,13 @@ test('ProviderManager clears hidden Hicap auth fields when editing', async () =>
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider profile') &&
- frame.includes('Step 1 of 6'),
+ frame.includes('Step 1 of 7'),
)
- for (let step = 2; step <= 6; step++) {
+ for (let step = 2; step <= 7; step++) {
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
- frame.includes(`Step ${step} of 6`),
+ frame.includes(`Step ${step} of 7`),
)
}
mounted.stdin.write('\r')
@@ -1443,25 +1461,31 @@ test('ProviderManager skips advanced fields for legacy Kimi Code profiles', asyn
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider profile') &&
frame.includes('Provider name') &&
- frame.includes('Step 1 of 4'),
+ frame.includes('Step 1 of 5'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Base URL') &&
- frame.includes('Step 2 of 4'),
+ frame.includes('Step 2 of 5'),
)
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Default model') &&
- frame.includes('Step 3 of 4'),
+ frame.includes('Step 3 of 5'),
+ )
+
+ mounted.stdin.write('\r')
+ await waitForFrameOutput(mounted.getOutput, frame =>
+ frame.includes('Self-hosted tools') &&
+ frame.includes('Step 4 of 5'),
)
mounted.stdin.write('\r')
const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API key') &&
- frame.includes('Step 4 of 4'),
+ frame.includes('Step 5 of 5'),
)
expect(output).not.toContain('API mode')
@@ -2431,50 +2455,16 @@ test('ProviderManager editing an active multi-model provider keeps app state on
mounted.getOutput,
frame =>
frame.includes('Edit provider profile') &&
- frame.includes('Step 1 of 8'),
- )
-
- mounted.stdin.write('\r')
- await waitForFrameOutput(
- mounted.getOutput,
- frame => frame.includes('Step 2 of 8'),
- )
-
- mounted.stdin.write('\r')
- await waitForFrameOutput(
- mounted.getOutput,
- frame => frame.includes('Step 3 of 8'),
- )
-
- mounted.stdin.write('\r')
- await waitForFrameOutput(
- mounted.getOutput,
- frame => frame.includes('Step 4 of 8'),
- )
-
- mounted.stdin.write('\r')
- await waitForFrameOutput(
- mounted.getOutput,
- frame => frame.includes('Step 5 of 8'),
- )
-
- mounted.stdin.write('\r')
- await waitForFrameOutput(
- mounted.getOutput,
- frame => frame.includes('Step 6 of 8'),
+ frame.includes('Step 1 of 9'),
)
- mounted.stdin.write('\r')
- await waitForFrameOutput(
- mounted.getOutput,
- frame => frame.includes('Step 7 of 8'),
- )
-
- mounted.stdin.write('\r')
- await waitForFrameOutput(
- mounted.getOutput,
- frame => frame.includes('Step 8 of 8'),
- )
+ for (let step = 2; step <= 9; step++) {
+ mounted.stdin.write('\r')
+ await waitForFrameOutput(
+ mounted.getOutput,
+ frame => frame.includes(`Step ${step} of 9`),
+ )
+ }
mounted.stdin.write('\r')
diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx
index d17b0fbfb3..9e22441342 100644
--- a/src/components/ProviderManager.tsx
+++ b/src/components/ProviderManager.tsx
@@ -67,6 +67,7 @@ import {
getActiveProviderProfile,
getProviderPresetDefaults,
getProviderProfiles,
+ providerProfileSupportsSelfHostedTools,
setActiveProviderProfile,
type ProviderPreset,
type ProviderProfileInput,
@@ -145,6 +146,7 @@ type DraftField =
| 'model'
| 'apiKey'
| 'apiFormat'
+ | 'selfHostedTools'
| 'authHeader'
| 'authHeaderValue'
| 'customHeaders'
@@ -196,6 +198,14 @@ const FORM_STEPS: Array<{
placeholder: 'e.g. llama3.1:8b or glm-4.7; glm-4.7-flash',
helpText: 'Model name(s) to use. Separate multiple with ";" or ","; first is default.',
},
+ {
+ key: 'selfHostedTools',
+ label: 'Self-hosted tools',
+ placeholder: 'auto',
+ helpText:
+ 'For llama-server / vLLM / Ollama on this profile only. Automatic = local auto-detect; Enabled/Disabled force recovery on or off. No shell env.',
+ optional: true,
+ },
{
key: 'apiFormat',
label: 'API mode',
@@ -252,6 +262,12 @@ function toDraft(profile: ProviderProfile): ProviderDraft {
model: profile.model,
apiKey: profile.apiKey ?? '',
apiFormat: profile.apiFormat ?? 'auto',
+ selfHostedTools:
+ profile.selfHostedTools === true
+ ? 'enabled'
+ : profile.selfHostedTools === false
+ ? 'disabled'
+ : 'auto',
authHeader: profile.authHeader ?? '',
authHeaderValue: profile.authHeaderValue ?? '',
customHeaders: serializeProfileCustomHeaders(profile.customHeaders) ?? '',
@@ -286,7 +302,13 @@ function presetToDraft(preset: ProviderPreset): ProviderDraft {
baseUrl: defaults.baseUrl,
model: defaults.model,
apiKey: defaults.apiKey ?? '',
- apiFormat: 'chat_completions',
+ apiFormat: preset === 'custom' ? 'auto' : 'chat_completions',
+ selfHostedTools:
+ defaults.selfHostedTools === true
+ ? 'enabled'
+ : defaults.selfHostedTools === false
+ ? 'disabled'
+ : 'auto',
authHeader: '',
authHeaderValue: '',
customHeaders: '',
@@ -321,11 +343,16 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
routeSupportsApiFormatSelection(routeId)
? ` · ${profile.apiFormat === 'responses_compat' ? 'responses (compat)' : profile.apiFormat === 'responses' ? 'responses' : profile.apiFormat === 'chat_completions' ? 'chat/completions' : 'automatic'}`
: ''
+ const selfHostedInfo =
+ providerProfileSupportsSelfHostedTools(profile.provider) &&
+ profile.selfHostedTools
+ ? ' · self-hosted tools'
+ : ''
const authInfo =
routeSupportsAuthHeaders(routeId) && profile.authHeader
? ` · ${profile.authHeader} auth`
: ''
- return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${authInfo} · ${keyInfo}${activeSuffix}`
+ return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${selfHostedInfo}${authInfo} · ${keyInfo}${activeSuffix}`
}
function getGithubCredentialSourceFromEnv(
@@ -890,6 +917,9 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const showsAuthHeaderValue = routeShowsAuthHeaderValue(routeId)
const showsCustomHeaders = routeShowsCustomHeaders(routeId)
return FORM_STEPS.filter(step => {
+ if (step.key === 'selfHostedTools') {
+ return providerProfileSupportsSelfHostedTools(draftProvider)
+ }
if (step.key === 'apiFormat') {
return routeSupportsApiFormatSelection(routeId)
}
@@ -1576,16 +1606,8 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
function startCreateFromPreset(preset: ProviderPreset): void {
const defaults = getProviderPresetDefaults(preset)
const provider = defaults.provider ?? 'openai'
- const nextDraft = {
- name: defaults.name,
- baseUrl: defaults.baseUrl,
- model: defaults.model,
- apiKey: defaults.apiKey ?? '',
- apiFormat: preset === 'custom' ? 'auto' : 'chat_completions',
- authHeader: '',
- authHeaderValue: '',
- customHeaders: '',
- }
+ const nextDraft = presetToDraft(preset)
+ nextDraft.apiKey = defaults.apiKey ?? ''
setEditingProfileId(null)
setDraftProvider(provider)
setDraft(nextDraft)
@@ -1706,6 +1728,13 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
Object.keys(parsedCustomHeaders.headers).length > 0
? parsedCustomHeaders.headers
: undefined,
+ selfHostedTools: providerProfileSupportsSelfHostedTools(provider)
+ ? nextDraft.selfHostedTools === 'enabled'
+ ? true
+ : nextDraft.selfHostedTools === 'disabled'
+ ? false
+ : undefined
+ : undefined,
}
const saved = profileId
@@ -2207,7 +2236,43 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
Step {formStepIndex + 1} of {formSteps.length}: {displayStep.label}
- {currentStepKey === 'apiFormat' ? (
+ {currentStepKey === 'selfHostedTools' ? (
+