mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
refactor(openai-shim): extract Ollama adapter (#2004)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user