diff --git a/src/__tests__/bugfixes.test.ts b/src/__tests__/bugfixes.test.ts index 8924fb00e5..293e9fae12 100644 --- a/src/__tests__/bugfixes.test.ts +++ b/src/__tests__/bugfixes.test.ts @@ -142,6 +142,75 @@ describe('Agent loop continuation nudge', () => { 'Continue with the task. If you were interrupted, resume your thought. Otherwise, use the appropriate tools to proceed to the next step.', ) }) + + test('post-tool stall detection catches empty and placeholder replies', async () => { + const { + conversationHasPendingToolFollowUp, + isStalledPostToolResponse, + isToolResultSemanticPlaceholderText, + shouldNudgePostToolTurn, + stripSemanticPlaceholderAssistants, + } = await import('../utils/continuation.js') + + expect(isStalledPostToolResponse('')).toBe(true) + expect(isToolResultSemanticPlaceholderText('[Tool results received]')).toBe(true) + expect(isToolResultSemanticPlaceholderText('● [Tool results received]')).toBe(true) + expect(isStalledPostToolResponse('[Tool results received]')).toBe(true) + expect(isStalledPostToolResponse('● [Tool results received]')).toBe(true) + expect(isStalledPostToolResponse('Task finished')).toBe(false) + + expect( + shouldNudgePostToolTurn({ + lastText: '', + hadEmptyAssistantResponse: true, + }), + ).toBe(true) + expect( + shouldNudgePostToolTurn({ + lastText: '[Tool results received]', + hadEmptyAssistantResponse: false, + }), + ).toBe(true) + expect( + shouldNudgePostToolTurn({ + lastText: 'All tests pass and the fix is complete.', + hadEmptyAssistantResponse: false, + }), + ).toBe(false) + + const toolRound = [ + { + type: 'assistant', + isApiErrorMessage: false, + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'call_1', name: 'Read', input: {} }], + }, + }, + { + type: 'user', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }], + }, + }, + ] as unknown as import('../types/message.js').Message[] + + expect(conversationHasPendingToolFollowUp(toolRound)).toBe(true) + expect( + stripSemanticPlaceholderAssistants([ + ...toolRound, + { + type: 'assistant', + isApiErrorMessage: false, + message: { + role: 'assistant', + content: [{ type: 'text', text: '[Tool results received]' }], + }, + } as unknown as import('../types/message.js').Message, + ]).length, + ).toBe(2) + }) }) // --------------------------------------------------------------------------- diff --git a/src/commands/ctx_viz/ctx_viz.test.ts b/src/commands/ctx_viz/ctx_viz.test.ts index 7318ec1803..1c655a4d8b 100644 --- a/src/commands/ctx_viz/ctx_viz.test.ts +++ b/src/commands/ctx_viz/ctx_viz.test.ts @@ -20,6 +20,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { stripVTControlCharacters as stripAnsi } from 'node:util' import { afterEach, @@ -208,20 +209,23 @@ describe('/ctx command surface (PR #1610)', () => { modelUsageMap: {}, }) + // Chalk may inject ANSI sequences when FORCE_COLOR is set (common in CI). + const plainOut = stripAnsi(out) + // Header line — confirms the model name is rendered. - expect(out).toContain('Context Window:') + expect(plainOut).toContain('Context Window:') // Window Capacity block (4 bullets). - expect(out).toContain('Window Capacity') - expect(out).toContain('Context window:') - expect(out).toContain('Effective context:') - expect(out).toContain('Max output:') + expect(plainOut).toContain('Window Capacity') + expect(plainOut).toContain('Context window:') + expect(plainOut).toContain('Effective context:') + expect(plainOut).toContain('Max output:') // Auto-compact line is rendered because the fixture sets // isAutoCompactEnabled: true. - expect(out).toContain('Auto-compact at:') + expect(plainOut).toContain('Auto-compact at:') // Current Context block + total. - expect(out).toContain('Current Context (what the model sees)') - expect(out).toContain('Total:') - expect(out).toMatch(/used\)/) + expect(plainOut).toContain('Current Context (what the model sees)') + expect(plainOut).toContain('Total:') + expect(plainOut).toMatch(/used\)/) // Each non-zero category in the fixture appears in the output. for (const cat of [ 'System prompt', @@ -229,7 +233,7 @@ describe('/ctx command surface (PR #1610)', () => { 'Memory files', 'Messages', ]) { - expect(out).toContain(cat) + expect(plainOut).toContain(cat) } // Bar characters — width 30, ratio = tokens / contextWindow (200k). // With the fixture: @@ -237,17 +241,17 @@ describe('/ctx command surface (PR #1610)', () => { // System prompt 7.8k / 200k → 1 filled // Memory files 956 / 200k → 0 filled // Messages 84 / 200k → 0 filled - expect(out).toContain('█'.repeat(2) + '░'.repeat(28)) - expect(out).toContain('█'.repeat(1) + '░'.repeat(29)) - expect(out).toMatch(/░{30}/) + expect(plainOut).toContain('█'.repeat(2) + '░'.repeat(28)) + expect(plainOut).toContain('█'.repeat(1) + '░'.repeat(29)) + expect(plainOut).toMatch(/░{30}/) // Footer cross-references the sibling commands. - expect(out).toContain('/context') - expect(out).toContain('/cost') - expect(out).toContain('/stats') + expect(plainOut).toContain('/context') + expect(plainOut).toContain('/cost') + expect(plainOut).toContain('/stats') // Capacity rows (Free space, Autocompact buffer, Compact buffer) should be filtered out. - expect(out).not.toContain('Free space') + expect(plainOut).not.toContain('Free space') // Deferred tool categories (MCP tools (deferred), System tools (deferred)) // should be filtered out since they aren't in the model-visible context. - expect(out).not.toContain('System tools (deferred)') + expect(plainOut).not.toContain('System tools (deferred)') }) }) diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index bb1fcdc5aa..15436c964a 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -101,6 +101,15 @@ async function waitForCondition( throw new Error('Timed out waiting for ProviderManager test condition') } +async function submitSelfHostedCompatStep( + mounted: { stdin: NodeJS.WriteStream; getOutput: () => string }, +): Promise { + await waitForFrameOutput(mounted.getOutput, frame => + frame.includes('Self-hosted compat'), + ) + mounted.stdin.write('\r') +} + // Provider list is sorted from generated preset metadata by description, with // Gitlawb Opengateway pinned first, Codex OAuth injected after DeepSeek, and // Custom always pinned last. Keep the target-by-label indirection here so @@ -640,6 +649,7 @@ test('ProviderManager shows API mode picker for custom OpenAI-compatible provide frame.includes('Default model'), ) mounted.stdin.write('\r') + await submitSelfHostedCompatStep(mounted) const output = await waitForFrameOutput(mounted.getOutput, frame => frame.includes('API mode') && frame.includes('Chat Completions'), @@ -1080,13 +1090,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') @@ -1156,25 +1166,26 @@ 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 submitSelfHostedCompatStep(mounted) 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') @@ -2007,49 +2018,55 @@ 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'), + frame.includes('Step 1 of 9'), + ) + + mounted.stdin.write('\r') + await waitForFrameOutput( + mounted.getOutput, + frame => frame.includes('Step 2 of 9'), ) mounted.stdin.write('\r') await waitForFrameOutput( mounted.getOutput, - frame => frame.includes('Step 2 of 8'), + frame => frame.includes('Step 3 of 9'), ) mounted.stdin.write('\r') await waitForFrameOutput( mounted.getOutput, - frame => frame.includes('Step 3 of 8'), + frame => frame.includes('Step 4 of 9'), ) mounted.stdin.write('\r') await waitForFrameOutput( mounted.getOutput, - frame => frame.includes('Step 4 of 8'), + frame => frame.includes('Step 5 of 9'), ) mounted.stdin.write('\r') await waitForFrameOutput( mounted.getOutput, - frame => frame.includes('Step 5 of 8'), + frame => frame.includes('Step 6 of 9'), ) mounted.stdin.write('\r') await waitForFrameOutput( mounted.getOutput, - frame => frame.includes('Step 6 of 8'), + frame => frame.includes('Step 7 of 9'), ) mounted.stdin.write('\r') await waitForFrameOutput( mounted.getOutput, - frame => frame.includes('Step 7 of 8'), + frame => frame.includes('Step 8 of 9'), ) mounted.stdin.write('\r') await waitForFrameOutput( mounted.getOutput, - frame => frame.includes('Step 8 of 8'), + frame => frame.includes('Step 9 of 9'), ) mounted.stdin.write('\r') diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 115c65fb39..62c8fdbe95 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -54,6 +54,7 @@ import { getActiveProviderProfile, getProviderPresetDefaults, getProviderProfiles, + providerProfileSupportsTextToolCallParsing, setActiveProviderProfile, type ProviderPreset, type ProviderProfileInput, @@ -127,6 +128,7 @@ type DraftField = | 'authHeader' | 'authHeaderValue' | 'customHeaders' + | 'parseTextToolCalls' type ProviderDraft = Record @@ -175,6 +177,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: 'parseTextToolCalls', + label: 'Self-hosted compat', + placeholder: 'enabled', + helpText: + 'Enable for self-hosted OpenAI-compatible servers (Ollama, llama-server, vLLM). Any host/port. Parses JSON tool calls from text, auto-continues after tools, and skips cloud-only request overhead (fast path).', + optional: true, + }, { key: 'apiFormat', label: 'API mode', @@ -231,6 +241,7 @@ function toDraft(profile: ProviderProfile): ProviderDraft { model: profile.model, apiKey: profile.apiKey ?? '', apiFormat: profile.apiFormat ?? 'chat_completions', + parseTextToolCalls: profile.parseTextToolCalls ? 'enabled' : 'disabled', authHeader: profile.authHeader ?? '', authHeaderValue: profile.authHeaderValue ?? '', customHeaders: serializeProfileCustomHeaders(profile.customHeaders) ?? '', @@ -266,6 +277,7 @@ function presetToDraft(preset: ProviderPreset): ProviderDraft { model: defaults.model, apiKey: defaults.apiKey ?? '', apiFormat: 'chat_completions', + parseTextToolCalls: defaults.parseTextToolCalls ? 'enabled' : 'disabled', authHeader: '', authHeaderValue: '', customHeaders: '', @@ -296,11 +308,16 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string { routeSupportsApiFormatSelection(routeId) ? ` · ${profile.apiFormat === 'responses_compat' ? 'responses (compat)' : profile.apiFormat === 'responses' ? 'responses' : 'chat/completions'}` : '' + const textToolCallInfo = + providerProfileSupportsTextToolCallParsing(profile.provider) && + profile.parseTextToolCalls + ? ' · text tool calls' + : '' const authInfo = routeSupportsAuthHeaders(routeId) && profile.authHeader ? ` · ${profile.authHeader} auth` : '' - return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${authInfo} · ${keyInfo}${activeSuffix}` + return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${textToolCallInfo}${authInfo} · ${keyInfo}${activeSuffix}` } function getGithubCredentialSourceFromEnv( @@ -788,6 +805,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 === 'parseTextToolCalls') { + return providerProfileSupportsTextToolCallParsing(draftProvider) + } if (step.key === 'apiFormat') { return routeSupportsApiFormatSelection(routeId) } @@ -1410,16 +1430,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: 'chat_completions', - authHeader: '', - authHeaderValue: '', - customHeaders: '', - } + const nextDraft = presetToDraft(preset) + nextDraft.apiKey = defaults.apiKey ?? '' setEditingProfileId(null) setDraftProvider(provider) setDraft(nextDraft) @@ -1514,6 +1526,11 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { Object.keys(parsedCustomHeaders.headers).length > 0 ? parsedCustomHeaders.headers : undefined, + parseTextToolCalls: + providerProfileSupportsTextToolCallParsing(provider) && + nextDraft.parseTextToolCalls === 'enabled' + ? true + : undefined, } const saved = profileId @@ -1946,7 +1963,28 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { Step {formStepIndex + 1} of {formSteps.length}: {currentStep.label} - {currentStepKey === 'apiFormat' ? ( + {currentStepKey === 'parseTextToolCalls' ? ( + content.type === 'tool_use', ) as ToolUseBlock[] - if (msgToolUseBlocks.length > 0) { - toolUseBlocks.push(...msgToolUseBlocks) - needsFollowUp = true + const assistantText = message.message.content + .filter( + (block): block is Extract => + block.type === 'text', + ) + .map(block => block.text) + .join(' ') + + withheldStallResponse = + shouldParseTextToolCalls() && + conversationHasPendingToolFollowUp(messagesForQuery) && + msgToolUseBlocks.length === 0 && + isStalledPostToolResponse(assistantText) + + if (!withheld && !withheldStallResponse) { + yield yieldMessage } - if ( - streamingToolExecutor && - !toolUseContext.abortController.signal.aborted - ) { - for (const toolBlock of msgToolUseBlocks) { - streamingToolExecutor.addTool(toolBlock, message) + if (!withheldStallResponse) { + assistantMessages.push(message) + + if (msgToolUseBlocks.length > 0) { + toolUseBlocks.push(...msgToolUseBlocks) + needsFollowUp = true + } + + if ( + streamingToolExecutor && + !toolUseContext.abortController.signal.aborted + ) { + for (const toolBlock of msgToolUseBlocks) { + streamingToolExecutor.addTool(toolBlock, message) + } } } + } else if (!withheld) { + yield yieldMessage } if ( @@ -1746,6 +1776,74 @@ async function* queryLoop( } } + // Post-tool stall recovery for local text-tool-call backends. After tool + // results these models often return empty text, echo "[Tool results + // received]", or stop without calling tools. + if ( + shouldParseTextToolCalls() && + conversationHasPendingToolFollowUp(messagesForQuery) && + turnCount < (maxTurns ?? Infinity) && + state.continuationNudgeCount < MAX_CONTINUATION_NUDGES + ) { + const lastAssistant = assistantMessages.at(-1) + const hasToolUse = + lastAssistant?.type === 'assistant' && + lastAssistant.message.content.some(block => block.type === 'tool_use') + const lastText = + lastAssistant?.type === 'assistant' + ? lastAssistant.message.content + .filter( + (block): block is Extract => + block.type === 'text', + ) + .map(block => block.text) + .join(' ') + : '' + + if ( + !hasToolUse && + shouldNudgePostToolTurn({ + lastText, + hadEmptyAssistantResponse: assistantMessages.length === 0, + }) + ) { + const cleanedHistory = stripSemanticPlaceholderAssistants( + messagesForQuery, + ) + const persistedAssistants = + lastAssistant && + isStalledPostToolResponse(lastText) && + assistantMessages.length > 0 + ? assistantMessages.slice(0, -1) + : assistantMessages + + logForDebugging( + `Post-tool continuation nudge triggered (${state.continuationNudgeCount + 1}/${MAX_CONTINUATION_NUDGES}) after tool results without tool calls`, + ) + const nudge = createUserMessage({ + content: LOCAL_TOOL_CONTINUATION_PROMPT, + isMeta: true, + }) + const next: State = { + messages: [...cleanedHistory, ...persistedAssistants, nudge], + toolUseContext, + autoCompactTracking: tracking, + maxOutputTokensRecoveryCount: 0, + hasAttemptedReactiveCompact: false, + hasAttemptedProviderFallback: false, + maxOutputTokensOverride: undefined, + providerMaxOutputTokensCap, + pendingToolUseSummary: undefined, + stopHookActive: undefined, + turnCount, + continuationNudgeCount: state.continuationNudgeCount + 1, + transition: { reason: 'continuation_nudge' }, + } + state = next + continue + } + } + // Continuation nudge: detect when the model signals intent to continue // (e.g., "so now I have to do it", "let me now...", "I'll need to...") // but returned no tool calls. This prevents premature task completion. diff --git a/src/services/api/openaiShim.compression.test.ts b/src/services/api/openaiShim.compression.test.ts index 95139c5594..6469ef1209 100644 --- a/src/services/api/openaiShim.compression.test.ts +++ b/src/services/api/openaiShim.compression.test.ts @@ -15,6 +15,7 @@ const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_MODEL: process.env.OPENAI_MODEL, + OPENAI_PARSE_TEXT_TOOL_CALLS: process.env.OPENAI_PARSE_TEXT_TOOL_CALLS, } const originalConfig = { @@ -133,6 +134,7 @@ beforeEach(async () => { process.env.OPENAI_BASE_URL = 'http://example.test/v1' process.env.OPENAI_API_KEY = 'test-key' delete process.env.OPENAI_MODEL + delete process.env.OPENAI_PARSE_TEXT_TOOL_CALLS }) afterEach(() => { diff --git a/src/services/api/openaiShim.diagnostics.test.ts b/src/services/api/openaiShim.diagnostics.test.ts index bfafc84b1a..8fc07d8b18 100644 --- a/src/services/api/openaiShim.diagnostics.test.ts +++ b/src/services/api/openaiShim.diagnostics.test.ts @@ -16,6 +16,7 @@ const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_MODEL: process.env.OPENAI_MODEL, + OPENAI_PARSE_TEXT_TOOL_CALLS: process.env.OPENAI_PARSE_TEXT_TOOL_CALLS, } let actualDebugModule: DebugModule | undefined @@ -37,6 +38,7 @@ afterEach(() => { restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL) restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY) restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) + restoreEnv('OPENAI_PARSE_TEXT_TOOL_CALLS', originalEnv.OPENAI_PARSE_TEXT_TOOL_CALLS) mock.restore() restoreDebugModule() } finally { @@ -212,8 +214,9 @@ test('logs self-heal toolless retry for local tool-call incompatibility', async const nonce = `${Date.now()}-${Math.random()}` const { createOpenAIShimClient } = await import(`./openaiShim.ts?ts=${nonce}`) - process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + process.env.OPENAI_BASE_URL = 'http://192.168.0.42:8081/v1' process.env.OPENAI_API_KEY = 'ollama' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' let callCount = 0 globalThis.fetch = mockFetch(async () => { diff --git a/src/services/api/openaiShim.ollamaTextToolCalls.test.ts b/src/services/api/openaiShim.ollamaTextToolCalls.test.ts index 252ce7c9a4..52ea3352bb 100644 --- a/src/services/api/openaiShim.ollamaTextToolCalls.test.ts +++ b/src/services/api/openaiShim.ollamaTextToolCalls.test.ts @@ -213,12 +213,14 @@ describe('Ollama streaming — think-tag filtering on text-tool fallback (P1)', process.env.OLLAMA_BASE_URL = 'http://localhost:11434' process.env.OPENAI_API_KEY = 'test-key' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' }) afterEach(() => { globalThis.fetch = originalFetch delete process.env.OLLAMA_BASE_URL delete process.env.OPENAI_API_KEY delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_PARSE_TEXT_TOOL_CALLS }) test(' content is NOT emitted as assistant text when text-tool fallback fires', async () => { @@ -267,12 +269,14 @@ describe('Ollama streaming — plain text response with no tool calls', () => { process.env.OLLAMA_BASE_URL = 'http://localhost:11434' process.env.OPENAI_API_KEY = 'test-key' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' }) afterEach(() => { globalThis.fetch = originalFetch delete process.env.OLLAMA_BASE_URL delete process.env.OPENAI_API_KEY delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_PARSE_TEXT_TOOL_CALLS }) test('plain text in two chunks (content then stop) is emitted as text_delta', async () => { @@ -374,12 +378,14 @@ describe('Ollama streaming — visible text before real structured tool_calls (P process.env.OLLAMA_BASE_URL = 'http://localhost:11434' process.env.OPENAI_API_KEY = 'test-key' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' }) afterEach(() => { globalThis.fetch = originalFetch delete process.env.OLLAMA_BASE_URL delete process.env.OPENAI_API_KEY delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_PARSE_TEXT_TOOL_CALLS }) test('visible assistant text is preserved when real delta.tool_calls follow it', async () => { @@ -431,12 +437,14 @@ describe('Ollama streaming — visible prose before text-based tool-call fallbac process.env.OLLAMA_BASE_URL = 'http://localhost:11434' process.env.OPENAI_API_KEY = 'test-key' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' }) afterEach(() => { globalThis.fetch = originalFetch delete process.env.OLLAMA_BASE_URL delete process.env.OPENAI_API_KEY delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_PARSE_TEXT_TOOL_CALLS }) test('visible prose before text-based JSON tool call is preserved in emitted text_delta', async () => { @@ -479,6 +487,68 @@ describe('Ollama streaming — visible prose before text-based tool-call fallbac }) }) +describe('local llama-server streaming — text-based tool-call fallback', () => { + let originalFetch: FetchType + beforeEach(() => { + originalFetch = globalThis.fetch + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_API_KEY = 'test-key' + process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8080/v1' + process.env.OPENAI_MODEL = 'qwen3.6:35b' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' + }) + afterEach(() => { + globalThis.fetch = originalFetch + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_MODEL + delete process.env.OPENAI_PARSE_TEXT_TOOL_CALLS + }) + + test('extracts JSON tool calls from llama-server style local endpoint', async () => { + globalThis.fetch = (async () => + makeSseResponse( + makeChunks([ + ollamaChunk('Checking the file.\n'), + ollamaChunk('{"name":"Read","arguments":{"file_path":"/tmp/foo.ts"}}'), + ollamaChunk('', 'stop'), + ]), + )) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages + .create({ + model: 'qwen3.6:35b', + messages: [{ role: 'user', content: 'read the file' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const events: Record[] = [] + for await (const event of result.data) events.push(event) + + const allText = events + .filter( + e => + e.type === 'content_block_delta' && + (e.delta as Record)?.type === 'text_delta', + ) + .map(e => (e.delta as Record).text) + .join('') + expect(allText).toContain('Checking the file.') + + const toolStarts = events.filter( + e => + e.type === 'content_block_start' && + (e.content_block as Record)?.type === 'tool_use', + ) + expect(toolStarts).toHaveLength(1) + expect((toolStarts[0].content_block as Record).name).toBe('Read') + }) +}) + describe('parseTextToolCalls — pretty-printed bare JSON detection', () => { test('detects bare JSON with whitespace/newline between { and "name"', () => { const text = '{\n "name": "Bash",\n "arguments": {"command": "ls"}\n}' @@ -503,12 +573,14 @@ describe('Ollama streaming — non-stop terminal finish reasons flush buffer', ( process.env.OLLAMA_BASE_URL = 'http://localhost:11434' process.env.OPENAI_API_KEY = 'test-key' process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' }) afterEach(() => { globalThis.fetch = originalFetch delete process.env.OLLAMA_BASE_URL delete process.env.OPENAI_API_KEY delete process.env.OPENAI_BASE_URL + delete process.env.OPENAI_PARSE_TEXT_TOOL_CALLS }) test('buffered text is flushed when finish_reason is "length"', async () => { diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 1359ff75cf..1796fcf12a 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -45,6 +45,7 @@ const originalEnv = { OPENGATEWAY_BASE_URL: process.env.OPENGATEWAY_BASE_URL, CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, + OPENAI_PARSE_TEXT_TOOL_CALLS: process.env.OPENAI_PARSE_TEXT_TOOL_CALLS, } const originalFetch = globalThis.fetch @@ -183,6 +184,7 @@ beforeEach(async () => { delete process.env.OPENGATEWAY_BASE_URL delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID + delete process.env.OPENAI_PARSE_TEXT_TOOL_CALLS }) afterEach(() => { @@ -223,6 +225,7 @@ afterEach(() => { restoreEnv('OPENGATEWAY_BASE_URL', originalEnv.OPENGATEWAY_BASE_URL) restoreEnv('CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED', originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED) restoreEnv('CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID', originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID) + restoreEnv('OPENAI_PARSE_TEXT_TOOL_CALLS', originalEnv.OPENAI_PARSE_TEXT_TOOL_CALLS) globalThis.fetch = originalFetch _clearRegistryForTesting() ensureIntegrationsLoaded() @@ -5113,6 +5116,7 @@ test('self-heals local endpoint_not_found by retrying with /v1 base URL', async test('self-heals tool-call incompatibility by retrying local Ollama requests without tools', async () => { process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' const requestBodies: Array> = [] globalThis.fetch = (async (_input, init) => { @@ -5409,6 +5413,186 @@ test('injects semantic assistant message when tool result is followed by user me expect(semanticMsg.content).not.toContain('user') }) +test('does not inject semantic assistant message for LAN llama-server backends', async () => { + process.env.OPENAI_BASE_URL = 'http://192.168.0.42:8081/v1' + process.env.OPENAI_API_KEY = 'local' + + let requestBody: Record | undefined + + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response(JSON.stringify({ + id: 'chatcmpl-local', + object: 'chat.completion', + created: 123456789, + model: 'qwen3.6:35b', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { headers: { 'Content-Type': 'application/json' } }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'qwen3.6:35b', + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'call_1', name: 'Read', input: {} }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'file contents' }], + }, + { role: 'user', content: 'Continue with the task.' }, + ], + max_tokens: 64, + stream: false, + }) + + const messages = requestBody?.messages as Array> + expect(messages.map(m => m.role)).toEqual(['assistant', 'tool', 'user']) + expect( + messages.some(m => m.role === 'assistant' && m.content === '[Tool results received]'), + ).toBe(false) +}) + +test('appends local tool continuation prompt after tool results when text parsing is enabled', async () => { + process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8081/v1' + process.env.OPENAI_API_KEY = 'local' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' + + let requestBody: Record | undefined + + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response(JSON.stringify({ + id: 'chatcmpl-local', + object: 'chat.completion', + created: 123456789, + model: 'qwen3.6:35b', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { headers: { 'Content-Type': 'application/json' } }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'qwen3.6:35b', + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'call_1', name: 'Read', input: {} }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'file contents' }], + }, + ], + max_tokens: 64, + stream: false, + }) + + const messages = requestBody?.messages as Array> + expect(messages.at(-1)).toMatchObject({ + role: 'user', + content: expect.stringContaining('Continue with the task'), + }) +}) + +test('appends local tool continuation prompt after tool results followed by user message', async () => { + process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8081/v1' + process.env.OPENAI_API_KEY = 'local' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' + + let requestBody: Record | undefined + + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response(JSON.stringify({ + id: 'chatcmpl-local', + object: 'chat.completion', + created: 123456789, + model: 'qwen3.6:35b', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { headers: { 'Content-Type': 'application/json' } }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'qwen3.6:35b', + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'call_1', name: 'Read', input: {} }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'file contents' }], + }, + { role: 'user', content: 'The user asked you to continue.' }, + ], + max_tokens: 64, + stream: false, + }) + + const messages = requestBody?.messages as Array> + expect(messages.at(-1)).toMatchObject({ + role: 'user', + content: expect.stringMatching( + /The user asked you to continue\..*Continue with the task/s, + ), + }) +}) + +test('drops stale semantic placeholder assistant messages for local backends', async () => { + process.env.OPENAI_BASE_URL = 'http://127.0.0.1:8081/v1' + process.env.OPENAI_API_KEY = 'local' + + let requestBody: Record | undefined + + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response(JSON.stringify({ + id: 'chatcmpl-local', + object: 'chat.completion', + created: 123456789, + model: 'qwen3.6:35b', + choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { headers: { 'Content-Type': 'application/json' } }) + }) as unknown as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + await client.beta.messages.create({ + model: 'qwen3.6:35b', + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'call_1', name: 'Read', input: {} }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'file contents' }], + }, + { role: 'assistant', content: '● [Tool results received]' }, + { role: 'user', content: 'Continue with the task.' }, + ], + max_tokens: 64, + stream: false, + }) + + const messages = requestBody?.messages as Array> + expect(messages.map(m => m.role)).toEqual(['assistant', 'tool', 'user']) + expect( + messages.some(m => m.role === 'assistant' && String(m.content).includes('[Tool results received]')), + ).toBe(false) +}) + test('Moonshot: uses max_tokens (not max_completion_tokens) and strips store', async () => { process.env.OPENAI_BASE_URL = 'https://api.moonshot.ai/v1' process.env.OPENAI_API_KEY = 'sk-moonshot-test' @@ -5478,6 +5662,7 @@ test('Cerebras: strips unsupported store on chat_completions (#1023)', async () test('Local provider (vLLM/Ollama/etc.): strips unsupported store on chat_completions (#672)', async () => { process.env.OPENAI_BASE_URL = 'http://localhost:8000/v1' process.env.OPENAI_API_KEY = 'sk-local' + process.env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' let requestBody: Record | undefined globalThis.fetch = (async (_input, init) => { diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 7b97b757dd..60c099a2a1 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -70,7 +70,10 @@ import { getLocalProviderRetryBaseUrls, getGithubEndpointType, isLikelyOllamaEndpoint, - isLocalProviderUrl, + LOCAL_TOOL_CONTINUATION_PROMPT, + shouldInjectToolResultSemanticBoundary, + shouldParseTextToolCalls, + TOOL_RESULT_SEMANTIC_PLACEHOLDER, resolveRuntimeCodexCredentials, resolveProviderRequest, shouldAttemptLocalToollessRetry, @@ -81,6 +84,7 @@ import { classifyOpenAIHttpFailure, classifyOpenAINetworkFailure, } from './openaiErrorClassification.js' +import { isToolResultSemanticPlaceholderText } from '../../utils/continuation.js' import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js' import { redactSecretValueForDisplay, type SecretValueSource } from '../../utils/providerProfile.js' import { shouldRedactUrlQueryParam } from '../../utils/urlRedaction.js' @@ -545,6 +549,66 @@ function hydrateOpenAIShimCompatibilityEnv( } } +function assistantHistoryContentToText(content: unknown): string { + if (typeof content === 'string') { + return content + } + + if (!Array.isArray(content)) { + return '' + } + + return content + .filter((block: { type?: string; text?: string }) => block.type === 'text') + .map((block: { text?: string }) => block.text ?? '') + .join(' ') +} + +function appendLocalToolContinuationPrompt( + openaiMessages: OpenAIMessage[], +): OpenAIMessage[] { + const last = openaiMessages.at(-1) + const prev = openaiMessages.at(-2) + if (!last) { + return openaiMessages + } + + if (last.role === 'tool') { + return [ + ...openaiMessages, + { role: 'user', content: LOCAL_TOOL_CONTINUATION_PROMPT }, + ] + } + + if (last.role === 'user' && prev?.role === 'tool') { + const promptSuffix = `\n\n${LOCAL_TOOL_CONTINUATION_PROMPT}` + if (typeof last.content === 'string') { + if (last.content.includes(LOCAL_TOOL_CONTINUATION_PROMPT)) { + return openaiMessages + } + return [ + ...openaiMessages.slice(0, -1), + { ...last, content: last.content + promptSuffix }, + ] + } + + const mergedText = joinTextContentParts( + typeof last.content === 'string' || !last.content + ? [] + : last.content, + ) + if (mergedText.includes(LOCAL_TOOL_CONTINUATION_PROMPT)) { + return openaiMessages + } + return [ + ...openaiMessages.slice(0, -1), + { ...last, content: mergedText + promptSuffix }, + ] + } + + return openaiMessages +} + function convertMessages( messages: Array<{ role: string @@ -556,6 +620,7 @@ function convertMessages( preserveReasoningContent?: boolean reasoningContentFallback?: '' | 'omit' preserveGeminiThoughtSignature?: boolean + injectToolResultSemanticBoundary?: boolean }, ): OpenAIMessage[] { const preserveReasoningContent = options?.preserveReasoningContent === true @@ -639,6 +704,15 @@ function convertMessages( }) } } else if (role === 'assistant') { + if ( + !options?.injectToolResultSemanticBoundary && + isToolResultSemanticPlaceholderText( + assistantHistoryContentToText(content), + ) + ) { + continue + } + // Check for tool_use blocks if (Array.isArray(content)) { const toolUses = content.filter( @@ -802,10 +876,15 @@ function convertMessages( // assistant boundary to satisfy the strict role sequence without implying // that the user interrupted or cancelled anything: // ... -> assistant (calls) -> tool (results) -> assistant (semantic) -> user (next) - if (prev && prev.role === 'tool' && msg.role === 'user') { + if ( + options?.injectToolResultSemanticBoundary && + prev && + prev.role === 'tool' && + msg.role === 'user' + ) { coalesced.push({ role: 'assistant', - content: '[Tool results received]', + content: TOOL_RESULT_SEMANTIC_PLACEHOLDER, }) } @@ -1242,6 +1321,50 @@ export function parseTextToolCalls(text: string): { return { calls: results, toolCallRanges: acceptedRanges } } +function appendAssistantTextContentBlocks( + content: Array>, + strippedContent: string, + enableTextToolCallFallback: boolean, +): void { + const rawToolCalls = parseRawToolCallsRequestedText(strippedContent) + if (rawToolCalls) { + for (const toolCall of rawToolCalls) { + content.push({ + type: 'tool_use', + id: toolCall.id, + name: toolCall.name, + input: JSON.parse(toolCall.argumentsJson), + }) + } + return + } + + if (enableTextToolCallFallback) { + const { calls: textToolCalls, toolCallRanges } = + parseTextToolCalls(strippedContent) + if (textToolCalls.length > 0) { + const visibleText = stripRanges(strippedContent, toolCallRanges).trim() + if (visibleText) { + content.push({ type: 'text', text: visibleText }) + } + for (const tc of textToolCalls) { + content.push({ + type: 'tool_use', + id: tc.id, + name: tc.name, + input: tc.arguments, + }) + } + return + } + } + + content.push({ + type: 'text', + text: strippedContent, + }) +} + /** * Async generator that transforms an OpenAI SSE stream into * Anthropic-format BetaRawMessageStreamEvent objects. @@ -1492,7 +1615,7 @@ async function* openaiStreamToAnthropic( response: Response, model: string, signal?: AbortSignal, - isOllama = false, + enableTextToolCallFallback = false, ): AsyncGenerator { const messageId = makeMessageId() let contentBlockIndex = 0 @@ -1513,12 +1636,12 @@ async function* openaiStreamToAnthropic( let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null let hasEmittedFinalUsage = false let hasProcessedFinishReason = false - // Accumulated text for Ollama text-based tool call fallback parsing (#1053) + // Accumulated text for local text-based tool call fallback parsing (#1053) let accumulatedText = '' // Use the resolved value threaded from the call site (resolveProviderRequest) // rather than re-reading env vars inside the generator. - const isOllamaStream = isOllama - // Buffer Ollama text deltas so raw tool-call JSON is never emitted as text_delta + const isLocalTextToolCallStream = enableTextToolCallFallback + // Buffer local text deltas so raw tool-call JSON is never emitted as text_delta // before extraction at finish_reason=stop (P2 fix for #1053). let ollamaTextBuffer = '' const streamState = createStreamState() @@ -1759,7 +1882,7 @@ async function* openaiStreamToAnthropic( } accumulatedText += delta.content - if (isOllamaStream) { + if (isLocalTextToolCallStream) { const visible = thinkFilter.feed(delta.content) if (visible) { ollamaTextBuffer += visible @@ -1809,7 +1932,7 @@ async function* openaiStreamToAnthropic( // Must run before hasEmittedContentStart check because for Ollama // streams the text block may not have been opened yet (we buffer // instead of emitting during the streaming phase). - if (isOllamaStream && ollamaTextBuffer) { + if (isLocalTextToolCallStream && ollamaTextBuffer) { if (!hasEmittedContentStart) { yield { type: 'content_block_start', @@ -1920,7 +2043,7 @@ async function* openaiStreamToAnthropic( const isTerminalOllamaFinish = OLLAMA_TERMINAL_REASONS.has(choice.finish_reason ?? '') && activeToolCalls.size === 0 && - isOllamaStream + isLocalTextToolCallStream const originalFinishReason = choice.finish_reason let ollamaClosedContentBlock = false if (isTerminalOllamaFinish) { @@ -2223,7 +2346,12 @@ class OpenAIShimMessages { ? anthropicSsePassthrough(response, request.resolvedModel, options?.signal) : isGeminiStream ? geminiSseToAnthropic(response, request.resolvedModel, options?.signal) - : openaiStreamToAnthropic(response, request.resolvedModel, options?.signal, isLikelyOllamaEndpoint(request.baseUrl)), + : openaiStreamToAnthropic( + response, + request.resolvedModel, + options?.signal, + shouldParseTextToolCalls(request.baseUrl), + ), ) } @@ -2256,7 +2384,9 @@ class OpenAIShimMessages { request.resolvedModel, ) } - return self._convertNonStreamingResponse(parsed, request.resolvedModel) + return self._convertNonStreamingResponse(parsed, request.resolvedModel, { + enableTextToolCallFallback: shouldParseTextToolCalls(request.baseUrl), + }) } } @@ -2281,7 +2411,9 @@ class OpenAIShimMessages { const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('application/json')) { const data = await response.json() - return self._convertNonStreamingResponse(data, request.resolvedModel) + return self._convertNonStreamingResponse(data, request.resolvedModel, { + enableTextToolCallFallback: shouldParseTextToolCalls(request.baseUrl), + }) } const textBody = await response.text().catch(() => '') @@ -2412,7 +2544,7 @@ class OpenAIShimMessages { processEnv: process.env, baseUrl: request.baseUrl, model: request.resolvedModel, - treatAsLocal: isLocalProviderUrl(request.baseUrl), + treatAsLocal: shouldParseTextToolCalls(request.baseUrl), }) const shimConfig = runtimeShimContext.openaiShimConfig // When endpointPath is overridden, the body format must match the target @@ -2427,14 +2559,21 @@ class OpenAIShimMessages { : shimConfig.endpointPath?.startsWith('/models/gemini-') ? 'gemini' : request.transport - const openaiMessages = convertMessages(compressedMessages, params.system, { + let openaiMessages = convertMessages(compressedMessages, params.system, { preserveReasoningContent: shimConfig.preserveReasoningContent, reasoningContentFallback: shimConfig.reasoningContentFallback, preserveGeminiThoughtSignature: shouldPreserveGeminiThoughtSignature( request.resolvedModel, request.baseUrl, ), + injectToolResultSemanticBoundary: shouldInjectToolResultSemanticBoundary({ + baseUrl: request.baseUrl, + model: request.resolvedModel, + }), }) + if (shouldParseTextToolCalls(request.baseUrl)) { + openaiMessages = appendLocalToolContinuationPrompt(openaiMessages) + } const body: Record = { model: request.resolvedModel, @@ -2465,12 +2604,13 @@ class OpenAIShimMessages { body.max_completion_tokens = maxCompletionTokensValue } - if (params.stream && !isLocalProviderUrl(request.baseUrl)) { + const selfHostedCompat = shouldParseTextToolCalls(request.baseUrl) + + if (params.stream && !selfHostedCompat) { body.stream_options = { include_usage: true } } const isGithub = isGithubModelsMode() - const isLocal = isLocalProviderUrl(request.baseUrl) const githubEndpointType = getGithubEndpointType(request.baseUrl) const isGithubCopilot = isGithub && (githubEndpointType === 'copilot' || githubEndpointType === 'ghe') @@ -2480,7 +2620,7 @@ class OpenAIShimMessages { isGeminiMode() || hasGeminiApiHost(request.baseUrl) || hasCerebrasApiHost(request.baseUrl) || - isLocal + selfHostedCompat if ( shimConfig.maxTokensField === 'max_tokens' && @@ -2967,9 +3107,8 @@ class OpenAIShimMessages { return `${baseUrl}/chat/completions` } - const localRetryBaseUrls = isLocal - ? getLocalProviderRetryBaseUrls(request.baseUrl) - : [] + const localRetryBaseUrls = getLocalProviderRetryBaseUrls(request.baseUrl) + const hasLocalRetryCandidates = localRetryBaseUrls.length > 0 const buildRequestUrl = (baseUrl: string): string => { if (shimConfig.endpointPath) { @@ -3060,10 +3199,17 @@ class OpenAIShimMessages { signal: options?.signal, }) - const maxSelfHealAttempts = isLocal + const maxSelfHealAttempts = hasLocalRetryCandidates ? localRetryBaseUrls.length + 1 : 0 - const maxAttempts = (isGithub ? GITHUB_429_MAX_RETRIES : 1) + maxSelfHealAttempts + const mayRetryWithoutTools = shouldAttemptLocalToollessRetry({ + baseUrl: request.baseUrl, + hasTools: Array.isArray(params.tools) && params.tools.length > 0, + }) + const maxAttempts = + (isGithub ? GITHUB_429_MAX_RETRIES : 1) + + maxSelfHealAttempts + + (mayRetryWithoutTools ? 1 : 0) const throwClassifiedTransportError = ( error: unknown, @@ -3142,7 +3288,7 @@ class OpenAIShimMessages { const provider = request.baseUrl.includes('nvidia') ? 'nvidia-nim' : request.baseUrl.includes('minimax') ? 'minimax' : request.baseUrl.includes('xiaomimimo') || request.baseUrl.includes('mimo-v2') ? 'xiaomi-mimo' - : request.baseUrl.includes('localhost:11434') || request.baseUrl.includes('localhost:11435') ? 'ollama' + : isLikelyOllamaEndpoint(request.baseUrl) ? 'ollama' : request.baseUrl.includes('anthropic') ? 'anthropic' : 'openai' const { correlationId, startTime } = logApiCallStart(provider, request.resolvedModel) @@ -3172,7 +3318,7 @@ class OpenAIShimMessages { }) if ( - isLocal && + hasLocalRetryCandidates && failure.category === 'localhost_resolution_failed' && promoteNextLocalBaseUrl('localhost_resolution_failed') ) { @@ -3272,7 +3418,7 @@ class OpenAIShimMessages { }) if ( - isLocal && + hasLocalRetryCandidates && failure.category === 'endpoint_not_found' && promoteNextLocalBaseUrl('endpoint_not_found') ) { @@ -3357,9 +3503,13 @@ class OpenAIShimMessages { } }, model: string, + options?: { enableTextToolCallFallback?: boolean }, ) { const choice = data.choices?.[0] const content: Array> = [] + const enableTextToolCallFallback = + options?.enableTextToolCallFallback === true && + !choice?.message?.tool_calls // Some reasoning models (e.g. GLM-5) put their chain-of-thought in // reasoning_content while content stays null. Preserve it as a thinking @@ -3373,25 +3523,11 @@ class OpenAIShimMessages { ? choice?.message?.content : null if (typeof rawContent === 'string' && rawContent) { - const strippedContent = stripThinkTags(rawContent) - const rawToolCalls = choice?.message?.tool_calls - ? null - : parseRawToolCallsRequestedText(strippedContent) - if (rawToolCalls) { - for (const toolCall of rawToolCalls) { - content.push({ - type: 'tool_use', - id: toolCall.id, - name: toolCall.name, - input: JSON.parse(toolCall.argumentsJson), - }) - } - } else { - content.push({ - type: 'text', - text: strippedContent, - }) - } + appendAssistantTextContentBlocks( + content, + stripThinkTags(rawContent), + enableTextToolCallFallback, + ) } else if (Array.isArray(rawContent) && rawContent.length > 0) { const parts: string[] = [] for (const part of rawContent) { @@ -3406,25 +3542,11 @@ class OpenAIShimMessages { } const joined = parts.join('\n') if (joined) { - const strippedContent = stripThinkTags(joined) - const rawToolCalls = choice?.message?.tool_calls - ? null - : parseRawToolCallsRequestedText(strippedContent) - if (rawToolCalls) { - for (const toolCall of rawToolCalls) { - content.push({ - type: 'tool_use', - id: toolCall.id, - name: toolCall.name, - input: JSON.parse(toolCall.argumentsJson), - }) - } - } else { - content.push({ - type: 'text', - text: strippedContent, - }) - } + appendAssistantTextContentBlocks( + content, + stripThinkTags(joined), + enableTextToolCallFallback, + ) } } diff --git a/src/services/api/providerConfig.local.test.ts b/src/services/api/providerConfig.local.test.ts index 6493b242bd..c58a6885f4 100644 --- a/src/services/api/providerConfig.local.test.ts +++ b/src/services/api/providerConfig.local.test.ts @@ -3,10 +3,13 @@ import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test import { getAdditionalModelOptionsCacheScope, + getLocalFastPathConfig, getLocalProviderRetryBaseUrls, isLocalProviderUrl, resolveProviderRequest, shouldAttemptLocalToollessRetry, + shouldInjectToolResultSemanticBoundary, + shouldParseTextToolCalls, } from './providerConfig.js' const originalEnv = { @@ -19,6 +22,7 @@ const originalEnv = { ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS, OPENAI_MODEL: process.env.OPENAI_MODEL, OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT, + OPENAI_PARSE_TEXT_TOOL_CALLS: process.env.OPENAI_PARSE_TEXT_TOOL_CALLS, } function restoreEnv(key: string, value: string | undefined): void { @@ -44,6 +48,7 @@ afterEach(() => { restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS) restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL) restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT) + restoreEnv('OPENAI_PARSE_TEXT_TOOL_CALLS', originalEnv.OPENAI_PARSE_TEXT_TOOL_CALLS) } finally { releaseSharedMutationLock() } @@ -217,11 +222,19 @@ test('does not derive local retry base URLs for remote providers', () => { expect(getLocalProviderRetryBaseUrls('https://api.openai.com/v1')).toEqual([]) }) -test('enables local toolless retry for likely Ollama endpoints with tools', () => { +test('enables toolless retry when text tool parsing is enabled on any host', () => { expect( shouldAttemptLocalToollessRetry({ - baseUrl: 'http://localhost:11434/v1', + baseUrl: 'http://192.168.0.42:8081/v1', + hasTools: true, + processEnv: { OPENAI_PARSE_TEXT_TOOL_CALLS: '1' }, + }), + ).toBe(true) + expect( + shouldAttemptLocalToollessRetry({ + baseUrl: 'http://gpu-box.internal:8081/v1', hasTools: true, + processEnv: { OPENAI_PARSE_TEXT_TOOL_CALLS: '1' }, }), ).toBe(true) }) @@ -235,11 +248,71 @@ test('disables local toolless retry when no tools are present', () => { ).toBe(false) }) -test('disables local toolless retry for non-Ollama local endpoints', () => { +test('disables toolless retry when text tool parsing is disabled', () => { expect( shouldAttemptLocalToollessRetry({ - baseUrl: 'http://localhost:1234/v1', + baseUrl: 'http://192.168.0.42:8081/v1', hasTools: true, }), ).toBe(false) + expect( + shouldAttemptLocalToollessRetry({ + baseUrl: 'https://api.openai.com/v1', + hasTools: true, + }), + ).toBe(false) +}) + +test('enables fast path for any host when self-hosted compat is enabled', () => { + const env = { OPENAI_PARSE_TEXT_TOOL_CALLS: '1' } + expect(getLocalFastPathConfig('http://172.16.5.1:8081/v1', env).enabled).toBe(true) + expect(getLocalFastPathConfig('http://192.168.0.42:8081/v1', env).enabled).toBe(true) + expect(getLocalFastPathConfig('http://gpu-box.internal:8081/v1', env).enabled).toBe(true) + expect(getLocalFastPathConfig('https://llm.example.com/v1', env).enabled).toBe(true) + expect(getLocalFastPathConfig('http://172.16.5.1:8081/v1').enabled).toBe(false) +}) + +test('enables text tool-call parsing only when env flag is set', () => { + expect(shouldParseTextToolCalls('http://127.0.0.1:8080/v1', {})).toBe(false) + expect( + shouldParseTextToolCalls('http://127.0.0.1:8080/v1', { + OPENAI_PARSE_TEXT_TOOL_CALLS: '1', + }), + ).toBe(true) + expect( + shouldParseTextToolCalls('https://api.openai.com/v1', { + OPENAI_PARSE_TEXT_TOOL_CALLS: 'true', + }), + ).toBe(true) +}) + +test('injects tool-result semantic boundary only for Mistral/Devstral backends', () => { + expect( + shouldInjectToolResultSemanticBoundary({ + baseUrl: 'http://127.0.0.1:8080/v1', + model: 'qwen3.6:35b', + }), + ).toBe(false) + + expect( + shouldInjectToolResultSemanticBoundary({ + baseUrl: 'https://api.mistral.ai/v1', + model: 'mistral-large-latest', + }), + ).toBe(true) + + expect( + shouldInjectToolResultSemanticBoundary({ + baseUrl: 'http://127.0.0.1:8080/v1', + model: 'devstral-small', + }), + ).toBe(true) + + expect( + shouldInjectToolResultSemanticBoundary({ + baseUrl: 'http://127.0.0.1:8080/v1', + model: 'qwen3.6:35b', + processEnv: { CLAUDE_CODE_USE_MISTRAL: '1' }, + }), + ).toBe(true) }) diff --git a/src/services/api/providerConfig.localFastPath.test.ts b/src/services/api/providerConfig.localFastPath.test.ts index 1dd627a797..70d64875eb 100644 --- a/src/services/api/providerConfig.localFastPath.test.ts +++ b/src/services/api/providerConfig.localFastPath.test.ts @@ -4,11 +4,14 @@ import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test import { getLocalFastPathConfig } from './providerConfig.js' const ENV_VAR = 'OPENCLAUDE_LOCAL_FAST_PATH' +const PARSE_ENV = 'OPENAI_PARSE_TEXT_TOOL_CALLS' const originalEnv = process.env[ENV_VAR] +const originalParseEnv = process.env[PARSE_ENV] beforeEach(async () => { await acquireSharedMutationLock('providerConfig.localFastPath.test.ts') delete process.env[ENV_VAR] + delete process.env[PARSE_ENV] }) afterEach(() => { @@ -18,31 +21,43 @@ afterEach(() => { } else { process.env[ENV_VAR] = originalEnv } + if (originalParseEnv === undefined) { + delete process.env[PARSE_ENV] + } else { + process.env[PARSE_ENV] = originalParseEnv + } } finally { releaseSharedMutationLock() } }) -describe('getLocalFastPathConfig — auto-detect from baseUrl', () => { - test('engages on loopback', () => { - const cfg = getLocalFastPathConfig('http://localhost:11434/v1') +const selfHostedEnv = { [PARSE_ENV]: '1' } as NodeJS.ProcessEnv + +describe('getLocalFastPathConfig — profile option (no host/IP detection)', () => { + test('does not engage from loopback or RFC1918 addresses alone', () => { + expect(getLocalFastPathConfig('http://localhost:11434/v1').enabled).toBe(false) + expect(getLocalFastPathConfig('http://192.168.1.10:8000/v1').enabled).toBe(false) + expect(getLocalFastPathConfig('http://10.0.0.5:8000/v1').enabled).toBe(false) + expect(getLocalFastPathConfig('http://172.16.5.1:8081/v1').enabled).toBe(false) + expect(getLocalFastPathConfig('http://gpu-rig.local:11434/v1').enabled).toBe(false) + }) + + test('engages on any base URL when self-hosted compat is enabled', () => { + const cfg = getLocalFastPathConfig('http://172.16.5.1:8081/v1', selfHostedEnv) expect(cfg.enabled).toBe(true) expect(cfg.skipStableStringify).toBe(true) expect(cfg.skipStrictTools).toBe(true) expect(cfg.skipToolHistoryCompression).toBe(true) - }) - - test('engages on private IPv4', () => { - expect(getLocalFastPathConfig('http://192.168.1.10:8000/v1').enabled).toBe(true) - expect(getLocalFastPathConfig('http://10.0.0.5:8000/v1').enabled).toBe(true) - expect(getLocalFastPathConfig('http://172.16.5.1:8000/v1').enabled).toBe(true) - }) - test('engages on .local hostnames', () => { - expect(getLocalFastPathConfig('http://gpu-rig.local:11434/v1').enabled).toBe(true) + expect( + getLocalFastPathConfig('http://gpu-box.internal:8081/v1', selfHostedEnv).enabled, + ).toBe(true) + expect( + getLocalFastPathConfig('https://llm.example.com/v1', selfHostedEnv).enabled, + ).toBe(true) }) - test('does not engage on public hosts', () => { + test('does not engage on public hosts without the profile option', () => { const cfg = getLocalFastPathConfig('https://api.openai.com/v1') expect(cfg.enabled).toBe(false) expect(cfg.skipStableStringify).toBe(false) @@ -63,9 +78,10 @@ describe('getLocalFastPathConfig — explicit env override', () => { expect(cfg.skipStableStringify).toBe(true) }) - test('OPENCLAUDE_LOCAL_FAST_PATH=0 forces off against localhost', () => { + test('OPENCLAUDE_LOCAL_FAST_PATH=0 forces off even when self-hosted compat is enabled', () => { process.env[ENV_VAR] = '0' - const cfg = getLocalFastPathConfig('http://localhost:11434/v1') + process.env[PARSE_ENV] = '1' + const cfg = getLocalFastPathConfig('http://172.16.5.1:8081/v1') expect(cfg.enabled).toBe(false) expect(cfg.skipStrictTools).toBe(false) }) @@ -78,25 +94,36 @@ describe('getLocalFastPathConfig — explicit env override', () => { }) test('accepts falsy aliases (false / off / no)', () => { + process.env[PARSE_ENV] = '1' for (const v of ['false', 'off', 'no', 'FALSE', 'Off']) { process.env[ENV_VAR] = v - expect(getLocalFastPathConfig('http://localhost:11434/v1').enabled).toBe(false) + expect(getLocalFastPathConfig('http://172.16.5.1:8081/v1').enabled).toBe(false) } }) - test('"auto" / empty string fall through to baseUrl detection', () => { + test('"auto" / empty string fall through to profile option', () => { + process.env[PARSE_ENV] = '1' process.env[ENV_VAR] = 'auto' - expect(getLocalFastPathConfig('http://localhost:11434/v1').enabled).toBe(true) - expect(getLocalFastPathConfig('https://api.openai.com/v1').enabled).toBe(false) + expect(getLocalFastPathConfig('http://172.16.5.1:8081/v1').enabled).toBe(true) + expect( + getLocalFastPathConfig('https://api.openai.com/v1', { + [ENV_VAR]: 'auto', + } as NodeJS.ProcessEnv).enabled, + ).toBe(false) process.env[ENV_VAR] = '' - expect(getLocalFastPathConfig('http://localhost:11434/v1').enabled).toBe(true) + expect(getLocalFastPathConfig('http://172.16.5.1:8081/v1').enabled).toBe(true) }) - test('garbage values fall through to auto-detect', () => { + test('garbage values fall through to profile option', () => { + process.env[PARSE_ENV] = '1' process.env[ENV_VAR] = 'maybe' - expect(getLocalFastPathConfig('http://localhost:11434/v1').enabled).toBe(true) - expect(getLocalFastPathConfig('https://api.openai.com/v1').enabled).toBe(false) + expect(getLocalFastPathConfig('http://172.16.5.1:8081/v1').enabled).toBe(true) + expect( + getLocalFastPathConfig('https://api.openai.com/v1', { + [ENV_VAR]: 'maybe', + } as NodeJS.ProcessEnv).enabled, + ).toBe(false) }) test('explicit env arg takes precedence over process.env', () => { @@ -106,4 +133,4 @@ describe('getLocalFastPathConfig — explicit env override', () => { } as NodeJS.ProcessEnv) expect(cfg.enabled).toBe(true) }) -}) +}) \ No newline at end of file diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index 94ee943786..bf31876976 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -408,9 +408,10 @@ export function isLocalProviderUrl(baseUrl: string | undefined): boolean { } } -// Fast-path opt-outs that are safe (and beneficial) when the provider is a -// local OpenAI-compatible endpoint. These features are designed for cloud -// behaviours that do not exist on local backends: +// Fast-path opt-outs that are safe (and beneficial) for self-hosted +// OpenAI-compatible endpoints (llama-server, Ollama, vLLM, etc.). These +// features are designed for cloud behaviours that do not exist on self-hosted +// backends: // - byte-stable serialization (`stableStringify`) targets implicit prefix // caching on OpenAI/Kimi/DeepSeek/Codex; local backends do not hash // request prefixes, so the deep key-sort is pure CPU overhead. @@ -426,11 +427,10 @@ export function isLocalProviderUrl(baseUrl: string | undefined): boolean { // API the layers are invisible, but against multi-second local round-trips // they multiply per-call. // -// Set `OPENCLAUDE_LOCAL_FAST_PATH=1` to force it on, `=0` to force off, or -// leave it unset to let `isLocalProviderUrl` decide. The opt-out is intended -// to be conservative: if the env var is set explicitly, callers can audit -// regressions; if not, behaviour only changes for hosts already classified -// as local by the existing detector (loopback, RFC1918, .local, ULA/LL). +// Enabled when the provider profile turns on text-tool-call compat +// (`parseTextToolCalls` / `OPENAI_PARSE_TEXT_TOOL_CALLS=1`). No host, port, +// or RFC1918 detection is involved. Set `OPENCLAUDE_LOCAL_FAST_PATH=1` to +// force on or `=0` to force off regardless of the profile option. const LOCAL_FAST_PATH_ENV = 'OPENCLAUDE_LOCAL_FAST_PATH' export type LocalFastPathConfig = { @@ -468,8 +468,13 @@ export function getLocalFastPathConfig( env: NodeJS.ProcessEnv = process.env, ): LocalFastPathConfig { const override = parseLocalFastPathOverride(env[LOCAL_FAST_PATH_ENV]) - const enabled = override ?? isLocalProviderUrl(baseUrl) - return enabled ? LOCAL_FAST_PATH_ON : LOCAL_FAST_PATH_OFF + if (override !== undefined) { + return override ? LOCAL_FAST_PATH_ON : LOCAL_FAST_PATH_OFF + } + + return shouldParseTextToolCalls(baseUrl, env) + ? LOCAL_FAST_PATH_ON + : LOCAL_FAST_PATH_OFF } function trimTrailingSlash(value: string): string { @@ -509,6 +514,66 @@ export function isLikelyOllamaEndpoint(baseUrl: string | undefined): boolean { } } +export const OPENAI_PARSE_TEXT_TOOL_CALLS_ENV = 'OPENAI_PARSE_TEXT_TOOL_CALLS' + +export const TOOL_RESULT_SEMANTIC_PLACEHOLDER = '[Tool results received]' + +export const LOCAL_TOOL_CONTINUATION_PROMPT = + 'Continue with the task. Review the tool results above and use tools to proceed to the next step.' + +/** + * Whether to use text-tool-call compat mode for an OpenAI-compatible provider. + * + * This is the sole gate for: parsing JSON tool calls from assistant text, + * post-tool continuation prompts, post-tool stall recovery, and toolless + * self-heal retries. It does not depend on host, port, or LAN/loopback + * detection — only the profile option or `OPENAI_PARSE_TEXT_TOOL_CALLS=1`. + */ +export function shouldParseTextToolCalls( + _baseUrl?: string, + processEnv: NodeJS.ProcessEnv = process.env, +): boolean { + return isEnvTruthy(processEnv[OPENAI_PARSE_TEXT_TOOL_CALLS_ENV]) +} + +/** Alias used at call sites that gate self-hosted text-tool-call behaviour. */ +export const shouldUseTextToolCallCompatMode = shouldParseTextToolCalls + +/** + * Mistral/Devstral Jinja templates require tool → assistant → user ordering. + * Other OpenAI-compatible backends (llama-server, Ollama, vLLM) must not get + * the synthetic assistant boundary — it confuses local models and surfaces as + * literal "[Tool results received]" output. + */ +export function shouldInjectToolResultSemanticBoundary(options?: { + baseUrl?: string + model?: string + processEnv?: NodeJS.ProcessEnv +}): boolean { + const processEnv = options?.processEnv ?? process.env + if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_MISTRAL)) { + return true + } + + const baseUrl = ( + options?.baseUrl ?? + processEnv.MISTRAL_BASE_URL ?? + processEnv.OPENAI_BASE_URL ?? + '' + ).toLowerCase() + if (baseUrl.includes('mistral.ai')) { + return true + } + + const model = ( + options?.model ?? + processEnv.MISTRAL_MODEL ?? + processEnv.OPENAI_MODEL ?? + '' + ).toLowerCase() + return /\b(devstral|mistral|ministral)\b/.test(model) +} + export function getLocalProviderRetryBaseUrls(baseUrl: string): string[] { if (!isLocalProviderUrl(baseUrl)) { return [] @@ -553,19 +618,20 @@ export function getLocalProviderRetryBaseUrls(baseUrl: string): string[] { } } +/** @deprecated Name is historical; behaviour is gated only by text-tool-call compat mode. */ export function shouldAttemptLocalToollessRetry(options: { baseUrl: string hasTools: boolean + processEnv?: NodeJS.ProcessEnv }): boolean { if (!options.hasTools) { return false } - if (!isLocalProviderUrl(options.baseUrl)) { - return false - } - - return isLikelyOllamaEndpoint(options.baseUrl) + return shouldParseTextToolCalls( + options.baseUrl, + options.processEnv ?? process.env, + ) } export function isCodexBaseUrl(baseUrl: string | undefined): boolean { diff --git a/src/utils/config.ts b/src/utils/config.ts index a50996ea2b..c062ace8ad 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -229,6 +229,14 @@ export type ProviderProfile = { * Applied to OpenAI-compatible providers when resolving runtime limits. */ maxContextLength?: number + /** + * Self-hosted OpenAI-compatible compat mode. When true: + * - parse tool calls embedded as JSON text in model output + * - auto-continue after tool results + * - skip cloud-oriented request overhead (fast path) + * Works on any base URL (LAN, hostname, public IP) — not tied to 192.168/172.16. + */ + parseTextToolCalls?: boolean } export type GlobalConfig = { diff --git a/src/utils/continuation.ts b/src/utils/continuation.ts index bee357bc36..2960839b24 100644 --- a/src/utils/continuation.ts +++ b/src/utils/continuation.ts @@ -1,4 +1,4 @@ -import { tokenCountWithEstimation } from './tokens.js' +import type { Message } from '../types/message.js' /** * Heuristics to detect if the agent intends to continue its task @@ -30,6 +30,109 @@ export type ContinuationResult = { reason?: 'possible_truncation' | 'continuation_signal' } +const TOOL_RESULT_SEMANTIC_PLACEHOLDER = /^\[tool results received\]$/i + +/** + * Detects empty or placeholder-only assistant replies after tool results. + * Local OpenAI-compatible backends often echo the Mistral semantic boundary + * or stop without emitting tool calls. + */ +export function isToolResultSemanticPlaceholderText(text: string): boolean { + const trimmed = text.trim() + if (!trimmed) { + return false + } + + const normalized = trimmed.replace(/^[●•\s]+/, '').trim() + return TOOL_RESULT_SEMANTIC_PLACEHOLDER.test(normalized) +} + +export function isStalledPostToolResponse(text: string): boolean { + const trimmed = text.trim() + if (!trimmed) { + return true + } + + return isToolResultSemanticPlaceholderText(trimmed) +} + +function assistantMessageText(message: Message): string | null { + if (message.type !== 'assistant' || message.isApiErrorMessage) { + return null + } + + return message.message.content + .filter( + (block): block is Extract => + block.type === 'text', + ) + .map(block => block.text) + .join(' ') +} + +/** + * True when the transcript has delivered tool results that still need a + * follow-up assistant turn (e.g. after /continue or a stalled local model). + */ +export function conversationHasPendingToolFollowUp(messages: Message[]): boolean { + let lastToolUseMessageIndex = -1 + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg.type !== 'assistant' || msg.isApiErrorMessage) { + continue + } + + const hasToolUse = msg.message.content.some(block => block.type === 'tool_use') + if (hasToolUse) { + lastToolUseMessageIndex = i + break + } + } + + if (lastToolUseMessageIndex === -1) { + return false + } + + for (let i = lastToolUseMessageIndex + 1; i < messages.length; i++) { + const msg = messages[i] + if (msg.type !== 'user') { + continue + } + + const content = msg.message.content + if ( + Array.isArray(content) && + content.some(block => block.type === 'tool_result') + ) { + return true + } + } + + return false +} + +export function stripSemanticPlaceholderAssistants(messages: Message[]): Message[] { + return messages.filter(message => { + const text = assistantMessageText(message) + return text === null || !isToolResultSemanticPlaceholderText(text) + }) +} + +export function shouldNudgePostToolTurn(options: { + lastText: string + hadEmptyAssistantResponse: boolean +}): boolean { + if (options.hadEmptyAssistantResponse) { + return true + } + + if (isStalledPostToolResponse(options.lastText)) { + return true + } + + return analyzeContinuationIntent(options.lastText).shouldNudge +} + export const UNFINISHED_SENTIMENT_SIGNALS = [ // English trailing connectors /\b(and|with|the|to|of|for|at|by|in|on|a|an|is|are|was|were|my|your|his|her|its|our|their|if|as|but|or|so|which|that)\s*$/i, diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 7e314671c5..0c2794e70a 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -75,6 +75,7 @@ const PROFILE_ENV_KEYS = [ 'GITHUB_COPILOT_KEY', 'GITHUB_ENTERPRISE_URL', 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS', + 'OPENAI_PARSE_TEXT_TOOL_CALLS', 'CODEX_API_KEY', 'CODEX_CREDENTIAL_SOURCE', 'CHATGPT_ACCOUNT_ID', @@ -182,6 +183,7 @@ export type ProfileEnv = { FIREWORKS_API_KEY?: string OPENCODE_API_KEY?: string CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS?: string + OPENAI_PARSE_TEXT_TOOL_CALLS?: string } export type ProfileFile = { @@ -348,14 +350,26 @@ export function buildOllamaProfileEnv( options: { baseUrl?: string | null getOllamaChatBaseUrl: (baseUrl?: string) => string + parseTextToolCalls?: boolean | null }, ): ProfileEnv { return { OPENAI_BASE_URL: options.getOllamaChatBaseUrl(options.baseUrl ?? undefined), OPENAI_MODEL: model, + ...(options.parseTextToolCalls ? { OPENAI_PARSE_TEXT_TOOL_CALLS: '1' } : {}), } } +export function appendParseTextToolCallsEnv( + env: ProfileEnv, + parseTextToolCalls?: boolean, +): ProfileEnv { + if (parseTextToolCalls) { + env.OPENAI_PARSE_TEXT_TOOL_CALLS = '1' + } + return env +} + export function buildAtomicChatProfileEnv( model: string, options: { @@ -666,6 +680,7 @@ export function buildOpenAIProfileEnv(options: { authScheme?: 'bearer' | 'raw' | null authHeaderValue?: string | null maxContextLength?: number | null + parseTextToolCalls?: boolean | null processEnv?: NodeJS.ProcessEnv }): ProfileEnv | null { const processEnv = options.processEnv ?? process.env @@ -724,6 +739,7 @@ export function buildOpenAIProfileEnv(options: { }), } : {}), + ...(options.parseTextToolCalls ? { OPENAI_PARSE_TEXT_TOOL_CALLS: '1' } : {}), } } diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 90abe16624..a2bdca43e2 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -66,6 +66,7 @@ const RESTORED_KEYS = [ 'ATLAS_CLOUD_API_KEY', 'HICAP_API_KEY', 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS', + 'OPENAI_PARSE_TEXT_TOOL_CALLS', ] as const type MockConfigState = { @@ -1000,6 +1001,32 @@ describe('applyProviderProfileToProcessEnv', () => { ).not.toBe(1_000_000) }) + test('openai-compatible profile applies parseTextToolCalls env override', async () => { + const { applyProviderProfileToProcessEnv } = + await importFreshProviderProfileModules() + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'custom', + baseUrl: 'http://127.0.0.1:8080/v1', + model: 'qwen3.6:35b', + parseTextToolCalls: true, + }), + ) + + expect(process.env.OPENAI_PARSE_TEXT_TOOL_CALLS).toBe('1') + + applyProviderProfileToProcessEnv( + buildProfile({ + provider: 'custom', + baseUrl: 'http://127.0.0.1:8080/v1', + model: 'qwen3.6:35b', + }), + ) + + expect(process.env.OPENAI_PARSE_TEXT_TOOL_CALLS).toBeUndefined() + }) + test('non-openai-compatible profile ignores maxContextLength override', async () => { const { applyProviderProfileToProcessEnv } = await importFreshProviderProfileModules() @@ -1564,6 +1591,7 @@ describe('getProviderPresetDefaults', () => { expect(defaults.baseUrl).toBe('http://localhost:11434/v1') expect(defaults.model).toBe('llama3.1:8b') + expect(defaults.parseTextToolCalls).toBe(true) }) test('atomic-chat preset defaults to a local Atomic Chat endpoint', async () => { diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index e28aedb8dd..cc4a1404ce 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -21,6 +21,7 @@ import { buildMiniMaxProfileEnv, buildMistralProfileEnv, buildNvidiaNimProfileEnv, + appendParseTextToolCallsEnv, buildOpenAIProfileEnv, buildVeniceProfileEnv, buildXaiOAuthProfileEnv, @@ -65,6 +66,7 @@ export type ProviderProfileInput = { authHeaderValue?: ProviderProfile['authHeaderValue'] customHeaders?: ProviderProfile['customHeaders'] maxContextLength?: ProviderProfile['maxContextLength'] + parseTextToolCalls?: ProviderProfile['parseTextToolCalls'] } export type ProviderPresetDefaults = Omit & { @@ -255,6 +257,9 @@ function sanitizeProfile(profile: ProviderProfile): ProviderProfile | null { if (maxContextLength !== undefined) { sanitized.maxContextLength = maxContextLength } + if (profile.parseTextToolCalls === true) { + sanitized.parseTextToolCalls = true + } return sanitized } @@ -295,9 +300,16 @@ function toProfile( authHeaderValue: input.authHeaderValue, customHeaders: input.customHeaders, maxContextLength: input.maxContextLength, + parseTextToolCalls: input.parseTextToolCalls, }) } +export function providerProfileSupportsTextToolCallParsing( + provider: ProviderProfile['provider'], +): boolean { + return resolveProfileCompatibility(provider).compatibilityMode === 'openai' +} + function getSupportedProfileCustomHeadersEnv( profile: ProviderProfile, ): string | undefined { @@ -370,6 +382,7 @@ export function getProviderPresetDefaults( model: routeDefaults.model, apiKey: metadata.apiKey, requiresApiKey: metadata.requiresApiKey, + ...(preset === 'ollama' ? { parseTextToolCalls: true } : {}), } } @@ -615,6 +628,10 @@ function isProcessEnvAlignedWithProfile( processEnv.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS, expectedContextWindows, ) && + sameOptionalEnvValue( + processEnv.OPENAI_PARSE_TEXT_TOOL_CALLS, + profile.parseTextToolCalls ? '1' : undefined, + ) && (!includeApiKey || sameOptionalEnvValue(processEnv.OPENAI_API_KEY, profile.apiKey)) && (profile.baseUrl?.toLowerCase().includes('bankr') @@ -803,6 +820,7 @@ export function applyProviderProfileToProcessEnv(profile: ProviderProfile): void [primaryModel]: profile.maxContextLength, }) } + appendParseTextToolCallsEnv(openAIProfileEnv, profile.parseTextToolCalls) profileEnv = openAIProfileEnv } @@ -1036,6 +1054,7 @@ function buildOpenAICompatibleStartupEnv( authScheme: activeProfile.authScheme, authHeaderValue: activeProfile.authHeaderValue, maxContextLength: activeProfile.maxContextLength, + parseTextToolCalls: activeProfile.parseTextToolCalls, processEnv: {}, }) if (strictEnv) { @@ -1070,6 +1089,7 @@ function buildOpenAICompatibleStartupEnv( } : {}), } + appendParseTextToolCallsEnv(env, activeProfile.parseTextToolCalls) if (activeProfile.apiKey) { env.OPENAI_API_KEY = activeProfile.apiKey