diff --git a/src/__tests__/bugfixes.test.ts b/src/__tests__/bugfixes.test.ts index d899715e3a..809a0fea68 100644 --- a/src/__tests__/bugfixes.test.ts +++ b/src/__tests__/bugfixes.test.ts @@ -88,24 +88,6 @@ describe('Session timeout fix', () => { }) // --------------------------------------------------------------------------- -// Fix 2b: Ollama context history preservation -// --------------------------------------------------------------------------- -describe('Ollama context history fix', () => { - test('openaiShim uses native Ollama chat with request-level num_ctx', async () => { - const content = await file('services/api/openaiShim.ts').text() - - expect(content).toContain('buildOllamaChatUrl') - expect(content).toContain('/api/chat') - expect(content).toContain('useNativeOllamaChat') - expect(content).toContain('num_ctx: getOllamaNumCtx()') - expect(content).toContain('normalizeOllamaNativeMessages(body.messages)') - expect(content).toContain('convertOllamaStreamingResponse') - expect(content).toContain('convertOllamaNonStreamingResponse') - }) -}) - -// --------------------------------------------------------------------------- -// Fix 3: Agent loop continuation nudge // --------------------------------------------------------------------------- describe('Agent loop continuation nudge', () => { test('continuation logic has been moved to utility', async () => { diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index b1ae67a6f7..356a6f5766 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -138,7 +138,6 @@ import { hasInvalidCredentialPlaceholder, parseCredentialList, } from './credentialPool.js' -import { MIN_RECOMMENDED_OLLAMA_CONTEXT_TOKENS } from '../../utils/ollamaContext.js' import { filterAnthropicHeaders, geminiThoughtSignatureFromExtraContent, @@ -153,6 +152,13 @@ import { } from './openaiShim/providerCompatibility.js' export { hasMistralApiHost } +import { + buildOllamaChatUrl, + convertOllamaNonStreamingResponse, + convertOllamaStreamingResponse, + getOllamaNumCtx, + normalizeOllamaNativeMessages, +} from './openaiShim/ollamaAdapter.js' const GITHUB_429_MAX_RETRIES = 3 const GITHUB_429_BASE_DELAY_SEC = 1 @@ -611,399 +617,6 @@ interface OpenAITool { } } -type OllamaChatResponse = { - model?: string - message?: { - role?: string - content?: string - tool_calls?: Array<{ - function?: { - name?: string - arguments?: unknown - } - }> - } - done?: boolean - done_reason?: string - prompt_eval_count?: number - eval_count?: number -} - -type OllamaChatMessage = Omit & { - content?: string - images?: string[] - tool_calls?: Array<{ - function: { - name: string - arguments: Record - } - }> -} - -function parsePositiveIntegerEnv(value: string | undefined): number | null { - if (!value?.trim()) { - return null - } - const parsed = Number(value.trim()) - if (!Number.isInteger(parsed) || parsed <= 0) { - return null - } - return parsed -} - -function getOllamaNumCtx(): number { - return ( - parsePositiveIntegerEnv(process.env.OPENCLAUDE_OLLAMA_NUM_CTX) ?? - parsePositiveIntegerEnv(process.env.OLLAMA_CONTEXT_LENGTH) ?? - MIN_RECOMMENDED_OLLAMA_CONTEXT_TOKENS - ) -} - -function buildOllamaChatUrl(baseUrl: string): string { - const parsed = new URL(baseUrl) - parsed.pathname = parsed.pathname.replace(/\/+$/, '').replace(/\/v1$/i, '') - parsed.pathname = `${parsed.pathname.replace(/\/+$/, '')}/api/chat` - parsed.search = '' - parsed.hash = '' - return parsed.toString() -} - -function extractOllamaImageData(url: string): string | null { - const match = url.match(/^data:[^;,]+;base64,(.+)$/i) - if (!match) { - return null - } - return match[1] -} - -function normalizeOllamaNativeToolCalls( - toolCalls: OpenAIMessage['tool_calls'], -): OllamaChatMessage['tool_calls'] { - if (!Array.isArray(toolCalls) || toolCalls.length === 0) { - return undefined - } - - const normalized = toolCalls - .map(toolCall => { - const name = toolCall.function?.name - if (!name) { - return null - } - - let args: Record = {} - try { - const parsed = JSON.parse(toolCall.function.arguments || '{}') - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - args = parsed as Record - } - } catch { - args = {} - } - - return { - function: { - name, - arguments: args, - }, - } - }) - .filter((toolCall): toolCall is NonNullable => toolCall !== null) - - return normalized.length > 0 ? normalized : undefined -} - -function normalizeOllamaNativeMessages(messages: unknown): OllamaChatMessage[] { - if (!Array.isArray(messages)) { - return [] - } - - return messages.map(message => { - const openAIMessage = message as OpenAIMessage - const content = openAIMessage.content - const toolCalls = normalizeOllamaNativeToolCalls(openAIMessage.tool_calls) - if (!Array.isArray(content)) { - return { - ...openAIMessage, - content, - ...(toolCalls ? { tool_calls: toolCalls } : { tool_calls: undefined }), - } - } - - const textParts: string[] = [] - const images: string[] = [] - - for (const part of content) { - if (part.type === 'text') { - if (part.text) { - textParts.push(part.text) - } - continue - } - - if (part.type === 'image_url') { - const imageUrl = part.image_url.url - const imageData = extractOllamaImageData(imageUrl) - if (imageData) { - images.push(imageData) - } else { - textParts.push(`[Image: ${imageUrl}]`) - } - } - } - - return { - ...openAIMessage, - content: textParts.join('\n'), - ...(images.length > 0 ? { images } : {}), - ...(toolCalls ? { tool_calls: toolCalls } : { tool_calls: undefined }), - } - }) -} - -function mapOllamaDoneReason(doneReason: unknown): string | null { - if (doneReason === 'length') return 'length' - if (doneReason === 'stop') return 'stop' - if (typeof doneReason === 'string' && doneReason) return doneReason - return null -} - -function normalizeOllamaToolCalls( - toolCalls: NonNullable['tool_calls'], -): Array<{ - id: string - type: 'function' - function: { name: string; arguments: string } -}> | undefined { - if (!Array.isArray(toolCalls) || toolCalls.length === 0) { - return undefined - } - - const normalized = toolCalls - .map(toolCall => { - const name = toolCall.function?.name - if (!name) { - return null - } - const args = toolCall.function?.arguments - return { - id: `call_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`, - type: 'function' as const, - function: { - name, - arguments: - typeof args === 'string' ? args : JSON.stringify(args ?? {}), - }, - } - }) - .filter((toolCall): toolCall is NonNullable => toolCall !== null) - - return normalized.length > 0 ? normalized : undefined -} - -function buildOpenAIUsageFromOllama(data: OllamaChatResponse) { - const promptTokens = data.prompt_eval_count ?? 0 - const completionTokens = data.eval_count ?? 0 - return { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: promptTokens + completionTokens, - } -} - -function convertOllamaChatResponseToOpenAI( - data: OllamaChatResponse, - fallbackModel: string, -): Record { - const toolCalls = normalizeOllamaToolCalls(data.message?.tool_calls) - return { - id: makeMessageId(), - object: 'chat.completion', - created: Math.floor(Date.now() / 1000), - model: data.model ?? fallbackModel, - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: data.message?.content ?? '', - ...(toolCalls ? { tool_calls: toolCalls } : {}), - }, - finish_reason: mapOllamaDoneReason(data.done_reason), - }, - ], - usage: buildOpenAIUsageFromOllama(data), - } -} - -function responseWithPreservedUrl( - body: BodyInit | null, - init: ResponseInit, - url: string, -): Response { - const response = new Response(body, init) - try { - Object.defineProperty(response, 'url', { - value: url, - configurable: true, - }) - } catch { - /* some runtimes lock the property; downstream has transport fallback */ - } - return response -} - -async function convertOllamaNonStreamingResponse( - response: Response, - fallbackModel: string, -): Promise { - const data = await response.json() as OllamaChatResponse - return responseWithPreservedUrl( - JSON.stringify(convertOllamaChatResponseToOpenAI(data, fallbackModel)), - { - status: response.status, - statusText: response.statusText, - headers: { 'content-type': 'application/json' }, - }, - response.url, - ) -} - -function openAIStreamChunk( - id: string, - model: string, - delta: Record, - finishReason: string | null = null, -): string { - return `data: ${JSON.stringify({ - id, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta, finish_reason: finishReason }], - })}\n\n` -} - -function convertOllamaStreamingResponse( - response: Response, - fallbackModel: string, -): Response { - const body = response.body - if (!body) { - return response - } - - const decoder = new TextDecoder() - const encoder = new TextEncoder() - const reader = body.getReader() - const streamId = makeMessageId() - let buffer = '' - let hasEmittedRole = false - let hasEmittedToolCall = false - - const stream = new ReadableStream({ - async pull(controller) { - while (true) { - const { done, value } = await reader.read() - if (done) { - if (buffer.trim()) { - enqueueOllamaLineAsOpenAI(buffer.trim(), controller) - buffer = '' - } - controller.enqueue(encoder.encode('data: [DONE]\n\n')) - controller.close() - return - } - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split(/\r?\n/) - buffer = lines.pop() ?? '' - - let emittedLine = false - for (const line of lines) { - if (line.trim()) { - enqueueOllamaLineAsOpenAI(line.trim(), controller) - emittedLine = true - } - } - if (emittedLine) { - return - } - } - }, - cancel(reason) { - return reader.cancel(reason) - }, - }) - - function enqueueOllamaLineAsOpenAI( - line: string, - controller: ReadableStreamDefaultController, - ): void { - let data: OllamaChatResponse - try { - data = JSON.parse(line) as OllamaChatResponse - } catch { - return - } - - const model = data.model ?? fallbackModel - const chunks: string[] = [] - const delta: Record = {} - if (!hasEmittedRole) { - delta.role = 'assistant' - hasEmittedRole = true - } - if (data.message?.content) { - delta.content = data.message.content - } - const toolCalls = normalizeOllamaToolCalls(data.message?.tool_calls) - if (toolCalls) { - hasEmittedToolCall = true - delta.tool_calls = toolCalls.map((toolCall, index) => ({ - index, - id: toolCall.id, - type: toolCall.type, - function: toolCall.function, - })) - } - if (Object.keys(delta).length > 0) { - chunks.push(openAIStreamChunk(streamId, model, delta)) - } - if (data.done) { - chunks.push(openAIStreamChunk( - streamId, - model, - {}, - hasEmittedToolCall - ? 'tool_calls' - : mapOllamaDoneReason(data.done_reason), - )) - chunks.push(`data: ${JSON.stringify({ - id: streamId, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model, - choices: [], - usage: buildOpenAIUsageFromOllama(data), - })}\n\n`) - } - - for (const chunk of chunks) { - controller.enqueue(encoder.encode(chunk)) - } - } - - return responseWithPreservedUrl( - stream, - { - status: response.status, - statusText: response.statusText, - headers: { 'content-type': 'text/event-stream' }, - }, - response.url, - ) -} - function convertSystemPrompt( system: unknown, ): string { diff --git a/src/services/api/openaiShim/ollamaAdapter.test.ts b/src/services/api/openaiShim/ollamaAdapter.test.ts new file mode 100644 index 0000000000..df8d342061 --- /dev/null +++ b/src/services/api/openaiShim/ollamaAdapter.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../../test/sharedMutationLock.js' +import { createOpenAIShimClient } from '../openaiShim.js' +import { + buildOllamaChatUrl, + convertOllamaNonStreamingResponse, + convertOllamaStreamingResponse, + getOllamaNumCtx, + normalizeOllamaNativeMessages, +} from './ollamaAdapter.js' + +const originalEnv = { + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_API_BASE: process.env.OPENAI_API_BASE, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + OPENAI_API_KEYS: process.env.OPENAI_API_KEYS, + OPENAI_MODEL: process.env.OPENAI_MODEL, + OPENAI_API_FORMAT: process.env.OPENAI_API_FORMAT, + OPENAI_AZURE_STYLE: process.env.OPENAI_AZURE_STYLE, + CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, + CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, + CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, + CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL, + OPENCLAUDE_OLLAMA_NUM_CTX: process.env.OPENCLAUDE_OLLAMA_NUM_CTX, + OLLAMA_CONTEXT_LENGTH: process.env.OLLAMA_CONTEXT_LENGTH, +} +const originalFetch = globalThis.fetch + +beforeEach(async () => { + await acquireSharedMutationLock('openaiShim-ollamaAdapter.test.ts') + process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1' + delete process.env.OPENAI_API_BASE + process.env.OPENAI_API_KEY = 'test-key' + delete process.env.OPENAI_API_KEYS + delete process.env.OPENAI_MODEL + delete process.env.OPENAI_API_FORMAT + delete process.env.OPENAI_AZURE_STYLE + delete process.env.CLAUDE_CODE_USE_GITHUB + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.CLAUDE_CODE_USE_GEMINI + delete process.env.CLAUDE_CODE_USE_MISTRAL + delete process.env.OPENCLAUDE_OLLAMA_NUM_CTX + delete process.env.OLLAMA_CONTEXT_LENGTH +}) + +afterEach(() => { + try { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + globalThis.fetch = originalFetch + } finally { + releaseSharedMutationLock() + } +}) + +type ShimClient = { + beta: { + messages: { + create: (params: Record) => Promise & { + withResponse: () => Promise<{ data: AsyncIterable> }> + } + } + } +} + +function nativeResponse({ + content = 'hello from native Ollama', + model = 'qwen2.5-coder:7b', +}: { + content?: string + model?: string +} = {}): Response { + return new Response(JSON.stringify({ + model, + message: { role: 'assistant', content }, + done: true, + done_reason: 'stop', + prompt_eval_count: 5, + eval_count: 2, + }), { headers: { 'Content-Type': 'application/json' } }) +} + +test('builds native URLs and selects the configured Ollama context length', () => { + expect(buildOllamaChatUrl('http://localhost:11434/v1?token=secret')).toBe( + 'http://localhost:11434/api/chat', + ) + expect(getOllamaNumCtx()).toBe(32768) + process.env.OLLAMA_CONTEXT_LENGTH = '32768' + expect(getOllamaNumCtx()).toBe(32768) + process.env.OPENCLAUDE_OLLAMA_NUM_CTX = '65536' + expect(getOllamaNumCtx()).toBe(65536) + process.env.OPENCLAUDE_OLLAMA_NUM_CTX = 'invalid' + expect(getOllamaNumCtx()).toBe(32768) +}) + +test('normalizes multipart messages, tool calls, and matching tool results', () => { + expect(normalizeOllamaNativeMessages([ + { + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,aW1hZ2U=' } }, + ], + }, + { + role: 'assistant', + content: '', + tool_calls: [{ + id: 'call_read', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"a.txt"}' }, + }], + }, + { + role: 'tool', + content: 'contents', + tool_call_id: 'call_read', + }, + ])).toEqual([ + { + role: 'user', + content: 'describe this', + images: ['aW1hZ2U='], + tool_calls: undefined, + }, + { + role: 'assistant', + content: '', + tool_calls: [{ function: { name: 'read_file', arguments: { path: 'a.txt' } } }], + }, + { + role: 'tool', + content: 'contents', + tool_name: 'read_file', + tool_calls: undefined, + }, + ]) + + expect(normalizeOllamaNativeMessages([{ + role: 'assistant', + content: '', + tool_calls: {}, + }])).toEqual([{ + role: 'assistant', + content: '', + tool_calls: undefined, + }]) +}) + +test('converts native non-streaming text and tool responses', async () => { + const textResponse = await convertOllamaNonStreamingResponse( + nativeResponse({ model: 'llama3', content: 'hello' }), + 'fallback', + () => 'chatcmpl-text', + ) + expect(await textResponse.json()).toMatchObject({ + id: 'chatcmpl-text', + model: 'llama3', + choices: [{ message: { role: 'assistant', content: 'hello' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, + }) + + const toolResponse = await convertOllamaNonStreamingResponse( + new Response(JSON.stringify({ + model: 'qwen2.5-coder:7b', + message: { + role: 'assistant', + content: '', + tool_calls: [{ function: { name: 'read_file', arguments: { path: 'a.txt' } } }], + }, + done: true, + done_reason: 'stop', + })), + 'fallback', + () => 'chatcmpl-tool', + ) + const toolBody = await toolResponse.json() as { + choices?: Array<{ + finish_reason?: string + message?: { tool_calls?: Array<{ function?: { name?: string; arguments?: string } }> } + }> + } + expect(toolBody.choices?.[0]?.finish_reason).toBe('tool_calls') + expect(toolBody.choices?.[0]?.message?.tool_calls?.[0]?.function).toEqual({ + name: 'read_file', + arguments: JSON.stringify({ path: 'a.txt' }), + }) +}) + +test('converts native NDJSON streams to OpenAI SSE with tool finish and usage', async () => { + const native = [ + JSON.stringify({ model: 'llama3', message: { content: 'hello' }, done: false }), + JSON.stringify({ + model: 'llama3', + message: { tool_calls: [{ function: { name: 'read_file', arguments: { path: 'a.txt' } } }] }, + done: true, + done_reason: 'stop', + prompt_eval_count: 3, + eval_count: 2, + }), + ].join('\n') + const converted = convertOllamaStreamingResponse( + new Response(native), + 'fallback', + () => 'chatcmpl-stream', + ) + const body = await converted.text() + expect(body).toContain('data: [DONE]') + expect(body).toContain('"content":"hello"') + expect(body).toContain('"name":"read_file"') + expect(body).toContain('"finish_reason":"tool_calls"') + expect(body).toContain('"total_tokens":5') +}) + +test('uses native Ollama chat endpoint when local base URL omits /v1', async () => { + process.env.OPENAI_BASE_URL = 'http://localhost:11434' + const requestUrls: string[] = [] + globalThis.fetch = (async input => { + requestUrls.push(typeof input === 'string' ? input : input.url) + return nativeResponse() + }) as unknown as typeof globalThis.fetch + const client = createOpenAIShimClient({}) as unknown as ShimClient + + const message = await client.beta.messages.create({ + model: 'qwen2.5-coder:7b', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) as { content?: Array<{ type?: string; text?: string }> } + + expect(requestUrls).toEqual(['http://localhost:11434/api/chat']) + expect(message.content?.[0]).toMatchObject({ + type: 'text', + text: 'hello from native Ollama', + }) +}) + +test('uses max_tokens and request-level num_ctx for local Ollama', async () => { + let requestUrl = '' + let requestBody: Record | undefined + globalThis.fetch = (async (input, init) => { + requestUrl = typeof input === 'string' ? input : input.url + requestBody = JSON.parse(String(init?.body)) as Record + return new Response(JSON.stringify({ + model: 'llama3.1:8b', + message: { role: 'assistant', content: 'hello' }, + done: true, + done_reason: 'stop', + prompt_eval_count: 5, + eval_count: 1, + }), { headers: { 'Content-Type': 'application/json' } }) + }) as unknown as typeof globalThis.fetch + const client = createOpenAIShimClient({}) as unknown as ShimClient + + await client.beta.messages.create({ + model: 'llama3.1:8b', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 64, + stream: false, + }) + + expect(requestUrl).toBe('http://localhost:11434/api/chat') + expect(requestBody?.options).toMatchObject({ num_predict: 64, num_ctx: 32768 }) + expect(requestBody?.stream_options).toBeUndefined() +}) + +test('the façade sends native tool names and preserves streaming tool finish', async () => { + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as Record + const native = [ + JSON.stringify({ + model: 'qwen2.5-coder:7b', + message: { + role: 'assistant', + content: '', + tool_calls: [{ function: { name: 'Write', arguments: { file_path: 'out.txt', content: 'ok' } } }], + }, + done: true, + done_reason: 'stop', + }), + ].join('\n') + return new Response(native, { headers: { 'Content-Type': 'application/x-ndjson' } }) + }) as unknown as typeof globalThis.fetch + const client = createOpenAIShimClient({}) as unknown as ShimClient + + const result = await client.beta.messages.create({ + model: 'qwen2.5-coder:7b', + messages: [ + { role: 'user', content: 'read a file' }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'call_read', name: 'Read', input: { file_path: 'a.txt' } }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_read', content: 'contents' }], + }, + ], + max_tokens: 64, + stream: true, + }).withResponse() + const events: Array> = [] + for await (const event of result.data) events.push(event) + + const nativeMessages = requestBody?.messages as Array> + expect(nativeMessages.find(message => message.role === 'tool')).toMatchObject({ + role: 'tool', + content: 'contents', + tool_name: 'Read', + }) + expect(nativeMessages.find(message => message.role === 'tool')?.tool_call_id).toBeUndefined() + expect(events.find(event => event.type === 'content_block_start')).toMatchObject({ + content_block: { type: 'tool_use', name: 'Write' }, + }) + expect(events.find(event => event.type === 'message_delta')).toMatchObject({ + delta: { stop_reason: 'tool_use' }, + }) +}) diff --git a/src/services/api/openaiShim/ollamaAdapter.ts b/src/services/api/openaiShim/ollamaAdapter.ts new file mode 100644 index 0000000000..068740b501 --- /dev/null +++ b/src/services/api/openaiShim/ollamaAdapter.ts @@ -0,0 +1,263 @@ +import { MIN_RECOMMENDED_OLLAMA_CONTEXT_TOKENS } from '../../../utils/ollamaContext.js' + +type OpenAIMessage = { + role: 'system' | 'user' | 'assistant' | 'tool' + content?: string | OpenAIContentPart[] + tool_calls?: Array<{ + id: string + type: 'function' + function: { name: string; arguments: string } + extra_content?: Record + }> + tool_call_id?: string + name?: string + reasoning_content?: string +} + +type OpenAIContentPart = + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: { url: string } } + +type OllamaChatResponse = { + model?: string + message?: { + role?: string + content?: string + tool_calls?: Array<{ + function?: { + name?: string + arguments?: unknown + } + }> + } + done?: boolean + done_reason?: string + prompt_eval_count?: number + eval_count?: number +} + +type OllamaChatMessage = Omit & { + content?: string + images?: string[] + tool_name?: string + tool_calls?: Array<{ + function: { + name: string + arguments: Record + } + }> +} + +function parsePositiveIntegerEnv(value: string | undefined): number | null { + if (!value?.trim()) return null + const parsed = Number(value.trim()) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +export function getOllamaNumCtx(): number { + return ( + parsePositiveIntegerEnv(process.env.OPENCLAUDE_OLLAMA_NUM_CTX) ?? + parsePositiveIntegerEnv(process.env.OLLAMA_CONTEXT_LENGTH) ?? + MIN_RECOMMENDED_OLLAMA_CONTEXT_TOKENS + ) +} + +export function buildOllamaChatUrl(baseUrl: string): string { + const parsed = new URL(baseUrl) + parsed.pathname = parsed.pathname.replace(/\/+$/, '').replace(/\/v1$/i, '') + parsed.pathname = `${parsed.pathname.replace(/\/+$/, '')}/api/chat` + parsed.search = '' + parsed.hash = '' + return parsed.toString() +} + +function extractOllamaImageData(url: string): string | null { + return url.match(/^data:[^;,]+;base64,(.+)$/i)?.[1] ?? null +} + +function normalizeOllamaNativeToolCalls( + toolCalls: OpenAIMessage['tool_calls'], +): OllamaChatMessage['tool_calls'] { + if (!Array.isArray(toolCalls) || toolCalls.length === 0) return undefined + const normalized = toolCalls + .map(toolCall => { + const name = toolCall.function?.name + if (!name) return null + let args: Record = {} + try { + const parsed = JSON.parse(toolCall.function.arguments || '{}') + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + args = parsed as Record + } + } catch { + args = {} + } + return { function: { name, arguments: args } } + }) + .filter((toolCall): toolCall is NonNullable => toolCall !== null) + return normalized.length > 0 ? normalized : undefined +} + +export function normalizeOllamaNativeMessages(messages: unknown): OllamaChatMessage[] { + if (!Array.isArray(messages)) return [] + const toolNames = new Map() + return messages.map(message => { + const openAIMessage = message as OpenAIMessage + const content = openAIMessage.content + const toolCalls = normalizeOllamaNativeToolCalls(openAIMessage.tool_calls) + for (const toolCall of Array.isArray(openAIMessage.tool_calls) + ? openAIMessage.tool_calls + : []) { + if (toolCall.id && toolCall.function?.name) { + toolNames.set(toolCall.id, toolCall.function.name) + } + } + const { tool_call_id: toolCallId, ...messageWithoutToolCallId } = openAIMessage + const toolName = toolCallId ? toolNames.get(toolCallId) : undefined + if (!Array.isArray(content)) { + return { + ...messageWithoutToolCallId, + content, + ...(openAIMessage.role === 'tool' && toolName ? { tool_name: toolName } : {}), + ...(toolCalls ? { tool_calls: toolCalls } : { tool_calls: undefined }), + } + } + const textParts: string[] = [] + const images: string[] = [] + for (const part of content) { + if (part.type === 'text') { + if (part.text) textParts.push(part.text) + continue + } + const imageUrl = part.image_url.url + const imageData = extractOllamaImageData(imageUrl) + if (imageData) images.push(imageData) + else textParts.push(`[Image: ${imageUrl}]`) + } + return { + ...messageWithoutToolCallId, + content: textParts.join('\n'), + ...(openAIMessage.role === 'tool' && toolName ? { tool_name: toolName } : {}), + ...(images.length > 0 ? { images } : {}), + ...(toolCalls ? { tool_calls: toolCalls } : { tool_calls: undefined }), + } + }) +} + +function mapOllamaDoneReason(doneReason: unknown): string | null { + if (doneReason === 'length' || doneReason === 'stop') return doneReason + return typeof doneReason === 'string' && doneReason ? doneReason : null +} + +function normalizeOllamaToolCalls( + toolCalls: NonNullable['tool_calls'], +): Array<{ + id: string + type: 'function' + function: { name: string; arguments: string } +}> | undefined { + if (!Array.isArray(toolCalls) || toolCalls.length === 0) return undefined + const normalized = toolCalls + .map(toolCall => { + const name = toolCall.function?.name + if (!name) return null + const args = toolCall.function?.arguments + return { + id: `call_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`, + type: 'function' as const, + function: { name, arguments: typeof args === 'string' ? args : JSON.stringify(args ?? {}) }, + } + }) + .filter((toolCall): toolCall is NonNullable => toolCall !== null) + return normalized.length > 0 ? normalized : undefined +} + +function buildOpenAIUsageFromOllama(data: OllamaChatResponse) { + const promptTokens = data.prompt_eval_count ?? 0 + const completionTokens = data.eval_count ?? 0 + return { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens } +} + +function convertOllamaChatResponseToOpenAI( + data: OllamaChatResponse, + fallbackModel: string, + makeMessageId: () => string, +): Record { + const toolCalls = normalizeOllamaToolCalls(data.message?.tool_calls) + return { + id: makeMessageId(), object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: data.model ?? fallbackModel, + choices: [{ index: 0, message: { role: 'assistant', content: data.message?.content ?? '', ...(toolCalls ? { tool_calls: toolCalls } : {}) }, finish_reason: toolCalls ? 'tool_calls' : mapOllamaDoneReason(data.done_reason) }], + usage: buildOpenAIUsageFromOllama(data), + } +} + +function responseWithPreservedUrl(body: BodyInit | null, init: ResponseInit, url: string): Response { + const response = new Response(body, init) + try { Object.defineProperty(response, 'url', { value: url, configurable: true }) } catch { /* routing has a transport fallback */ } + return response +} + +const defaultMakeMessageId = (): string => + `msg_${crypto.randomUUID().replace(/-/g, '')}` + +export async function convertOllamaNonStreamingResponse(response: Response, fallbackModel: string, makeMessageId: () => string = defaultMakeMessageId): Promise { + const data = await response.json() as OllamaChatResponse + return responseWithPreservedUrl(JSON.stringify(convertOllamaChatResponseToOpenAI(data, fallbackModel, makeMessageId)), { status: response.status, statusText: response.statusText, headers: { 'content-type': 'application/json' } }, response.url) +} + +function openAIStreamChunk(id: string, model: string, delta: Record, finishReason: string | null = null): string { + return `data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n` +} + +export function convertOllamaStreamingResponse(response: Response, fallbackModel: string, makeMessageId: () => string = defaultMakeMessageId): Response { + const body = response.body + if (!body) return response + const decoder = new TextDecoder() + const encoder = new TextEncoder() + const reader = body.getReader() + const streamId = makeMessageId() + let buffer = '' + let hasEmittedRole = false + let hasEmittedToolCall = false + const stream = new ReadableStream({ + async pull(controller) { + while (true) { + const { done, value } = await reader.read() + if (done) { + if (buffer.trim()) enqueue(buffer.trim(), controller) + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + return + } + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() ?? '' + let emitted = false + for (const line of lines) if (line.trim()) { enqueue(line.trim(), controller); emitted = true } + if (emitted) return + } + }, + cancel(reason) { return reader.cancel(reason) }, + }) + function enqueue(line: string, controller: ReadableStreamDefaultController): void { + let data: OllamaChatResponse + try { data = JSON.parse(line) as OllamaChatResponse } catch { return } + const model = data.model ?? fallbackModel + const chunks: string[] = [] + const delta: Record = {} + if (!hasEmittedRole) { delta.role = 'assistant'; hasEmittedRole = true } + if (data.message?.content) delta.content = data.message.content + const toolCalls = normalizeOllamaToolCalls(data.message?.tool_calls) + if (toolCalls) { + hasEmittedToolCall = true + delta.tool_calls = toolCalls.map((toolCall, index) => ({ index, id: toolCall.id, type: toolCall.type, function: toolCall.function })) + } + if (Object.keys(delta).length > 0) chunks.push(openAIStreamChunk(streamId, model, delta)) + if (data.done) { + chunks.push(openAIStreamChunk(streamId, model, {}, hasEmittedToolCall ? 'tool_calls' : mapOllamaDoneReason(data.done_reason))) + chunks.push(`data: ${JSON.stringify({ id: streamId, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model, choices: [], usage: buildOpenAIUsageFromOllama(data) })}\n\n`) + } + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + } + return responseWithPreservedUrl(stream, { status: response.status, statusText: response.statusText, headers: { 'content-type': 'text/event-stream' } }, response.url) +}