Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e7c8dca
fix(provider): stop Mistral tool boundary on self-hosted OpenAI backends
Jul 15, 2026
8f8fed8
fix(openai-shim): gate non-stream JSON text-tool recovery like streaming
Jul 16, 2026
39d2d9c
fix(provider): preserve self-hosted tools flags in buildLaunchEnv
Jul 17, 2026
cc9c76e
fix(provider): harden self-hosted tool recovery from review findings
Jul 18, 2026
270b47a
fix(provider): tighten XML allowlist, args decode, shell override pro…
Jul 18, 2026
c6ad3bd
fix(test): unbreak typecheck after delete on OPENAI_SELF_HOSTED_TOOLS
Jul 18, 2026
048817a
fix(provider): harden parseAndAdd, XML ranges, empty shell overrides
Jul 18, 2026
7485096
fix(provider): address remaining self-hosted review findings
Jul 18, 2026
9209e77
fix(provider): require XML addCall range; prefer shell flags by undef…
Jul 18, 2026
978b189
fix(openai-shim): strip all XML blocks while keeping unique call rang…
Jul 18, 2026
7867ef0
fix(provider): isolate override recovery and honor Disabled
Jul 19, 2026
e654a1a
fix(provider): tri-state self-hosted tools and shell alignment
Jul 19, 2026
e93bee4
fix(provider): share self-hosted env mapping; mock supports helper
Jul 19, 2026
6111ff1
fix(test): restore self-hosted env keys between provider tests
Jul 20, 2026
ee3582d
Merge main into fix_llama_compat to sync before PR
Jul 24, 2026
4513f65
fix: address review findings for llama compatibility
Jul 24, 2026
74a8688
refactor: extract allowedToolNames normalization helper + XML duplica…
Jul 24, 2026
3bda055
refactor(parseXmlToolCalls): add stripRangeOwnerIndex for direct owne…
Jul 24, 2026
f21bd30
fix(test): restore global state and use Anthropic tool shape in XML test
Jul 25, 2026
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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


Comment thread
iHardRock marked this conversation as resolved.
# -----------------------------------------------------------------------------
# Option 7: AWS Bedrock
# -----------------------------------------------------------------------------
Expand Down
88 changes: 39 additions & 49 deletions src/components/ProviderManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}))
Expand Down Expand Up @@ -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')
Comment thread
iHardRock marked this conversation as resolved.

const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API mode') && frame.includes('Automatic'),
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')

Expand Down
91 changes: 78 additions & 13 deletions src/components/ProviderManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
getActiveProviderProfile,
getProviderPresetDefaults,
getProviderProfiles,
providerProfileSupportsSelfHostedTools,
Comment thread
iHardRock marked this conversation as resolved.
setActiveProviderProfile,
type ProviderPreset,
type ProviderProfileInput,
Expand Down Expand Up @@ -145,6 +146,7 @@ type DraftField =
| 'model'
| 'apiKey'
| 'apiFormat'
| 'selfHostedTools'
| 'authHeader'
| 'authHeaderValue'
| 'customHeaders'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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) ?? '',
Expand Down Expand Up @@ -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: '',
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const saved = profileId
Expand Down Expand Up @@ -2207,7 +2236,43 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
<Text dimColor>
Step {formStepIndex + 1} of {formSteps.length}: {displayStep.label}
</Text>
{currentStepKey === 'apiFormat' ? (
{currentStepKey === 'selfHostedTools' ? (
<Select
options={[
{
value: 'auto',
label: 'Automatic',
description:
'Auto-detect for local/Ollama URLs; no forced on/off on this profile',
},
{
value: 'disabled',
label: 'Disabled',
description:
'Cloud-style: structured API tool_calls only (OpenAI, OpenRouter, …)',
},
{
value: 'enabled',
label: 'Enabled',
description:
'Self-hosted for this profile: JSON-in-text tools (llama-server, vLLM, Ollama)',
},
]}
defaultValue={
currentValue === 'enabled' || currentValue === 'disabled'
? currentValue
: 'auto'
}
defaultFocusValue={
currentValue === 'enabled' || currentValue === 'disabled'
? currentValue
: 'auto'
}
onChange={(value: string) => handleFormSubmit(value)}
onCancel={handleBackFromForm}
visibleOptionCount={3}
/>
) : currentStepKey === 'apiFormat' ? (
<Select
options={[
{
Expand Down
29 changes: 29 additions & 0 deletions src/services/api/agentRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,35 @@ describe('applyAgentProviderOverrideToEnv', () => {
expect(env.GEMINI_API_KEY).toBe('gemini-key')
expect(env.ANTHROPIC_API_KEY).toBe('anthropic-key')
})

test('clears parent self-hosted recovery flags for the override route', () => {
const env: Record<string, string | undefined> = {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'https://llama.example.com:8443/v1',
OPENAI_MODEL: 'qwen-parent',
OPENAI_SELF_HOSTED_TOOLS: '1',
OPENAI_PARSE_TEXT_TOOL_CALLS: '1',
OPENCLAUDE_STARTUP_SELF_HOSTED_TOOLS: '1',
OPENCLAUDE_STARTUP_PARSE_TEXT_TOOL_CALLS: '1',
}

applyAgentProviderOverrideToEnv(
{
model: 'gpt-4o',
baseURL: 'https://api.openai.com/v1',
apiKey: 'sk-oai',
},
env,
)

expect(env.CLAUDE_CODE_USE_OPENAI).toBe('1')
expect(env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
expect(env.OPENAI_MODEL).toBe('gpt-4o')
expect(env.OPENAI_SELF_HOSTED_TOOLS).toBeUndefined()
expect(env.OPENAI_PARSE_TEXT_TOOL_CALLS).toBeUndefined()
expect(env.OPENCLAUDE_STARTUP_SELF_HOSTED_TOOLS).toBeUndefined()
expect(env.OPENCLAUDE_STARTUP_PARSE_TEXT_TOOL_CALLS).toBeUndefined()
})
})

describe('shouldEnforceModelAllowlist', () => {
Expand Down
5 changes: 5 additions & 0 deletions src/services/api/agentRouting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ const PROVIDER_ENV_VARS_TO_CLEAR_FOR_OVERRIDE = [
'OPENAI_AUTH_HEADER',
'OPENAI_AUTH_SCHEME',
'OPENAI_AUTH_HEADER_VALUE',
// Parent profile self-hosted recovery must not apply to the override route.
'OPENAI_SELF_HOSTED_TOOLS',
'OPENAI_PARSE_TEXT_TOOL_CALLS',
'OPENCLAUDE_STARTUP_SELF_HOSTED_TOOLS',
'OPENCLAUDE_STARTUP_PARSE_TEXT_TOOL_CALLS',
] as const

/**
Expand Down
Loading
Loading