refactor(openai-shim): extract Ollama adapter (#2004)

This commit is contained in:
JATMN
2026-07-23 07:18:25 +08:00
committed by GitHub
parent 0ff1d1cb7b
commit 6bef0e1604
4 changed files with 591 additions and 412 deletions
-18
View File
@@ -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 () => {
+7 -394
View File
@@ -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<OpenAIMessage, 'content' | 'tool_calls'> & {
content?: string
images?: string[]
tool_calls?: Array<{
function: {
name: string
arguments: Record<string, unknown>
}
}>
}
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<string, unknown> = {}
try {
const parsed = JSON.parse(toolCall.function.arguments || '{}')
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
args = parsed as Record<string, unknown>
}
} catch {
args = {}
}
return {
function: {
name,
arguments: args,
},
}
})
.filter((toolCall): toolCall is NonNullable<typeof toolCall> => 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<OllamaChatResponse['message']>['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<typeof toolCall> => 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<string, unknown> {
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<Response> {
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<string, unknown>,
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<Uint8Array>({
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<Uint8Array>,
): void {
let data: OllamaChatResponse
try {
data = JSON.parse(line) as OllamaChatResponse
} catch {
return
}
const model = data.model ?? fallbackModel
const chunks: string[] = []
const delta: Record<string, unknown> = {}
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 {
@@ -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<string, unknown>) => Promise<unknown> & {
withResponse: () => Promise<{ data: AsyncIterable<Record<string, unknown>> }>
}
}
}
}
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<string, unknown> | undefined
globalThis.fetch = (async (input, init) => {
requestUrl = typeof input === 'string' ? input : input.url
requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>
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<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>
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<Record<string, unknown>> = []
for await (const event of result.data) events.push(event)
const nativeMessages = requestBody?.messages as Array<Record<string, unknown>>
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' },
})
})
@@ -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<string, unknown>
}>
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<OpenAIMessage, 'content' | 'tool_calls' | 'tool_call_id'> & {
content?: string
images?: string[]
tool_name?: string
tool_calls?: Array<{
function: {
name: string
arguments: Record<string, unknown>
}
}>
}
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<string, unknown> = {}
try {
const parsed = JSON.parse(toolCall.function.arguments || '{}')
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
args = parsed as Record<string, unknown>
}
} catch {
args = {}
}
return { function: { name, arguments: args } }
})
.filter((toolCall): toolCall is NonNullable<typeof toolCall> => toolCall !== null)
return normalized.length > 0 ? normalized : undefined
}
export function normalizeOllamaNativeMessages(messages: unknown): OllamaChatMessage[] {
if (!Array.isArray(messages)) return []
const toolNames = new Map<string, string>()
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<OllamaChatResponse['message']>['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<typeof toolCall> => 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<string, unknown> {
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<Response> {
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<string, unknown>, 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<Uint8Array>({
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<Uint8Array>): void {
let data: OllamaChatResponse
try { data = JSON.parse(line) as OllamaChatResponse } catch { return }
const model = data.model ?? fallbackModel
const chunks: string[] = []
const delta: Record<string, unknown> = {}
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)
}