From 83d94daa82679e614a00d2afb114a1329d5a8386 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 12 Mar 2026 15:24:10 +0700 Subject: [PATCH] feat(ollama): Enhance Ollama support by adding new models, updating API format handling, and integrating translation functionality. --- open-sse/config/constants.js | 10 +- open-sse/config/providerModels.js | 5 + .../handlers/chatCore/nonStreamingHandler.js | 6 + open-sse/translator/formats.js | 3 +- open-sse/translator/index.js | 6 +- .../translator/request/openai-to-ollama.js | 159 ++++++++++++++++++ .../translator/response/ollama-to-openai.js | 152 +++++++++++++++++ open-sse/utils/stream.js | 2 +- open-sse/utils/streamHelpers.js | 20 ++- 9 files changed, 352 insertions(+), 11 deletions(-) create mode 100644 open-sse/translator/request/openai-to-ollama.js create mode 100644 open-sse/translator/response/ollama-to-openai.js diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js index 35092e86..99520e5a 100644 --- a/open-sse/config/constants.js +++ b/open-sse/config/constants.js @@ -424,10 +424,6 @@ export const PROVIDERS = { baseUrl: "https://api.hyperbolic.xyz/v1/chat/completions", format: "openai" }, - ollama: { - baseUrl: "https://ollama.com/api/chat", - format: "openai" - }, deepgram: { baseUrl: "https://api.deepgram.com/v1/listen", format: "openai" @@ -443,7 +439,11 @@ export const PROVIDERS = { chutes: { baseUrl: "https://llm.chutes.ai/v1/chat/completions", format: "openai" - } + }, + ollama: { + baseUrl: "https://ollama.com/api/chat", + format: "ollama" + }, }; // Claude system prompt diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 6932b918..86bba9b0 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -303,6 +303,11 @@ export const PROVIDER_MODELS = { ], ollama: [ { id: "gpt-oss:120b", name: "GPT OSS 120B" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "glm-5", name: "GLM 5" }, + { id: "minimax-m2.5", name: "MiniMax M2.5" }, + { id: "glm-4.7-flash", name: "GLM 4.7 Flash" }, + { id: "qwen3.5", name: "Qwen3.5" }, ], }; diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 190def6e..9468dc86 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -1,5 +1,6 @@ import { FORMATS } from "../../translator/formats.js"; import { needsTranslation } from "../../translator/index.js"; +import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js"; import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js"; import { createErrorResult } from "../../utils/error.js"; import { HTTP_STATUS } from "../../config/constants.js"; @@ -111,6 +112,11 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source return result; } + // Ollama + if (targetFormat === FORMATS.OLLAMA) { + return ollamaBodyToOpenAI(responseBody); + } + return responseBody; } diff --git a/open-sse/translator/formats.js b/open-sse/translator/formats.js index dffd8dc4..68288685 100644 --- a/open-sse/translator/formats.js +++ b/open-sse/translator/formats.js @@ -9,7 +9,8 @@ export const FORMATS = { CODEX: "codex", ANTIGRAVITY: "antigravity", KIRO: "kiro", - CURSOR: "cursor" + CURSOR: "cursor", + OLLAMA: "ollama" }; /** diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index b1309e30..fe622ff9 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -26,7 +26,7 @@ export function register(from, to, requestFn, responseFn) { function ensureInitialized() { if (initialized) return; initialized = true; - + // Request translators - sync require pattern for bundler require("./request/claude-to-openai.js"); require("./request/openai-to-claude.js"); @@ -36,7 +36,8 @@ function ensureInitialized() { require("./request/openai-responses.js"); require("./request/openai-to-kiro.js"); require("./request/openai-to-cursor.js"); - + require("./request/openai-to-ollama.js"); + // Response translators require("./response/claude-to-openai.js"); require("./response/openai-to-claude.js"); @@ -45,6 +46,7 @@ function ensureInitialized() { require("./response/openai-responses.js"); require("./response/kiro-to-openai.js"); require("./response/cursor-to-openai.js"); + require("./response/ollama-to-openai.js"); } // Translate request: source -> openai -> target diff --git a/open-sse/translator/request/openai-to-ollama.js b/open-sse/translator/request/openai-to-ollama.js new file mode 100644 index 00000000..6a316ead --- /dev/null +++ b/open-sse/translator/request/openai-to-ollama.js @@ -0,0 +1,159 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; + +/** + * Convert OpenAI request to Ollama format + * + * Ollama expects: + * - model: string + * - messages: Array<{role: string, content: string}> + * - stream: boolean + * - options?: {temperature?: number, num_predict?: number} + * + * Key differences from OpenAI: + * - Content must be string, not array + * - No support for tool_calls in request (tools are handled differently) + * - tool role maps to user + */ +export function openaiToOllamaRequest(model, body, stream) { + const result = { + model: model, + messages: normalizeMessages(body.messages), + stream: stream + }; + + // Temperature + if (body.temperature !== undefined) { + result.options = result.options || {}; + result.options.temperature = body.temperature; + } + + // Max tokens (Ollama uses num_predict) + if (body.max_tokens !== undefined) { + result.options = result.options || {}; + result.options.num_predict = body.max_tokens; + } + + // Top_p + if (body.top_p !== undefined) { + result.options = result.options || {}; + result.options.top_p = body.top_p; + } + + // Tools (Ollama supports tools in OpenAI format) + if (body.tools && Array.isArray(body.tools)) { + result.tools = body.tools; + } + + // Tool choice + if (body.tool_choice) { + result.tool_choice = body.tool_choice; + } + + return result; +} + +/** + * Normalize messages to Ollama format + * - Content must be string + * - tool messages: convert tool_call_id to tool_name + * - assistant messages: keep tool_calls as-is + */ +function normalizeMessages(messages) { + if (!Array.isArray(messages)) return messages; + + const result = []; + const toolCallMap = new Map(); // Map tool_call_id -> tool_name + + // First pass: build tool_call_id -> tool_name map from assistant messages + for (const msg of messages) { + if (msg.role === "assistant" && msg.tool_calls) { + for (const tc of msg.tool_calls) { + if (tc.id && tc.function?.name) { + toolCallMap.set(tc.id, tc.function.name); + } + } + } + } + + // Second pass: convert messages + for (const msg of messages) { + // Handle tool result messages (OpenAI format -> Ollama format) + if (msg.role === "tool") { + const toolResult = normalizeContent(msg.content); + if (!toolResult) continue; + + // Get tool_name from map or use msg.name as fallback + const toolName = toolCallMap.get(msg.tool_call_id) || msg.name || "unknown_tool"; + + result.push({ + role: "tool", + tool_name: toolName, + content: toolResult + }); + continue; + } + + // Handle assistant messages with tool_calls + if (msg.role === "assistant" && msg.tool_calls) { + const content = normalizeContent(msg.content) || ""; + + // Convert OpenAI tool_calls format to Ollama format + const ollamaToolCalls = msg.tool_calls.map(tc => ({ + type: "function", + function: { + index: tc.index || 0, + name: tc.function?.name || "", + arguments: typeof tc.function?.arguments === "string" + ? JSON.parse(tc.function.arguments || "{}") + : tc.function?.arguments || {} + } + })); + + result.push({ + role: "assistant", + content: content, + tool_calls: ollamaToolCalls + }); + continue; + } + + // Normal messages + const role = msg.role; + const content = normalizeContent(msg.content); + + // Skip empty messages (except assistant) + if (!content && role !== "assistant") continue; + + result.push({ + role: role, + content: content + }); + } + + return result; +} + +/** + * Normalize content to string + * Ollama only accepts string content + */ +function normalizeContent(content) { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + // Extract text from content array + const textParts = content + .filter(block => block && block.type === "text" && block.text) + .map(block => block.text); + + return textParts.join("\n") || ""; + } + + return ""; +} + +// Register translator +register(FORMATS.OPENAI, FORMATS.OLLAMA, openaiToOllamaRequest, null); diff --git a/open-sse/translator/response/ollama-to-openai.js b/open-sse/translator/response/ollama-to-openai.js new file mode 100644 index 00000000..9cd5b888 --- /dev/null +++ b/open-sse/translator/response/ollama-to-openai.js @@ -0,0 +1,152 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; + +/** + * Convert Ollama NDJSON response to OpenAI SSE format + * + * Ollama response format: + * {"model": "...", "message": {"role": "assistant", "content": "..."}, "done": false} + * {"model": "...", "done": true, "prompt_eval_count": 123, "eval_count": 456} + * + * OpenAI format: + * {"id": "...", "object": "chat.completion.chunk", "created": 123, "model": "...", + * "choices": [{"index": 0, "delta": {"content": "..."}, "finish_reason": null}]} + */ +export function ollamaToOpenAI(chunk, state) { + if (!chunk || typeof chunk !== "object") return null; + + // Initialize state on first chunk + if (!state.ollama) { + state.ollama = { + id: `chatcmpl-${Date.now()}`, + created: Math.floor(Date.now() / 1000), + model: chunk.model || state.model + }; + } + + const { id, created, model } = state.ollama; + + // Final chunk with done=true + if (chunk.done) { + const usage = extractUsage(chunk); + + // Determine finish_reason based on done_reason and previous tool_calls + let finishReason = "stop"; + if (chunk.done_reason === "tool_calls" || state.hadToolCalls) { + finishReason = "tool_calls"; + } + + return { + id: id, + object: "chat.completion.chunk", + created: created, + model: model, + choices: [{ + index: 0, + delta: {}, + finish_reason: finishReason + }], + usage: usage + }; + } + + // Content chunk + const message = chunk.message; + if (!message) return null; + + const content = typeof message.content === "string" ? message.content : ""; + const thinking = typeof message.thinking === "string" ? message.thinking : ""; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : null; + + // Skip empty chunks + if (!content && !thinking && !toolCalls) return null; + + // Accumulate content in state + if (content) { + state.accumulatedContent = (state.accumulatedContent || "") + content; + } + if (thinking) { + state.accumulatedThinking = (state.accumulatedThinking || "") + thinking; + } + + const delta = {}; + if (content) delta.content = content; + if (thinking) delta.reasoning_content = thinking; + + // Convert Ollama tool_calls to OpenAI format + if (toolCalls) { + state.hadToolCalls = true; + delta.tool_calls = convertToolCalls(toolCalls); + } + + return { + id: id, + object: "chat.completion.chunk", + created: created, + model: model, + choices: [{ + index: 0, + delta: delta, + finish_reason: null + }] + }; +} + +/** + * Extract usage stats from Ollama response + */ +function extractUsage(ollamaChunk) { + return { + prompt_tokens: ollamaChunk.prompt_eval_count || 0, + completion_tokens: ollamaChunk.eval_count || 0, + total_tokens: (ollamaChunk.prompt_eval_count || 0) + (ollamaChunk.eval_count || 0) + }; +} + +/** + * Convert tool_calls from Ollama format to OpenAI format + */ +function convertToolCalls(toolCalls) { + return toolCalls.map((tc, i) => ({ + index: tc.function?.index ?? i, + id: tc.id || `call_${i}_${Date.now()}`, + type: "function", + function: { + name: tc.function?.name || "", + arguments: typeof tc.function?.arguments === "string" + ? tc.function.arguments + : JSON.stringify(tc.function?.arguments || {}) + } + })); +} + +/** + * Convert Ollama non-streaming response body to OpenAI chat.completion format + */ +export function ollamaBodyToOpenAI(body) { + const msg = body.message || {}; + const content = msg.content || ""; + const thinking = msg.thinking || ""; + const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + + const message = { role: "assistant" }; + if (content) message.content = content; + if (thinking) message.reasoning_content = thinking; + if (toolCalls.length > 0) message.tool_calls = convertToolCalls(toolCalls); + if (!message.content && !message.tool_calls) message.content = ""; + + let finishReason = body.done_reason || "stop"; + if (toolCalls.length > 0) finishReason = "tool_calls"; + + return { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: body.model || "ollama", + choices: [{ index: 0, message, finish_reason: finishReason }], + usage: extractUsage(body) + }; +} + +// Register translator +register(FORMATS.OLLAMA, FORMATS.OPENAI, null, ollamaToOpenAI); diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 4538035f..4f5e4a01 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -159,7 +159,7 @@ export function createSSEStream(options = {}) { // Translate mode if (!trimmed) continue; - const parsed = parseSSELine(trimmed); + const parsed = parseSSELine(trimmed, targetFormat); if (!parsed) continue; if (parsed && parsed.done) { diff --git a/open-sse/utils/streamHelpers.js b/open-sse/utils/streamHelpers.js index 50cfe06e..a7a19180 100644 --- a/open-sse/utils/streamHelpers.js +++ b/open-sse/utils/streamHelpers.js @@ -1,8 +1,24 @@ import { FORMATS } from "../translator/formats.js"; // Parse SSE data line -export function parseSSELine(line) { - if (!line || line.charCodeAt(0) !== 100) return null; // 'd' = 100 +export function parseSSELine(line, format = null) { + if (!line) return null; + + // NDJSON format (Ollama): raw JSON lines without "data:" prefix + if (format === FORMATS.OLLAMA) { + const trimmed = line.trim(); + if (trimmed.startsWith("{")) { + try { + return JSON.parse(trimmed); + } catch (error) { + return null; + } + } + return null; + } + + // Standard SSE format: "data: {...}" + if (line.charCodeAt(0) !== 100) return null; // 'd' = 100 const data = line.slice(5).trim(); if (data === "[DONE]") return { done: true };