mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
- Bug B1-B7: media UI m.kind||m.type, serviceKinds, gemini mediaPriority, schema kind, models/info lookup by kind - Dead code D1-D6: safeParseJSON, drop PROVIDER_ENDPOINTS, orphan fetcher, GITHUB_CONFIG derive, getProviderConfig internal, legacy kiro file - Translator concerns: toOpenAIUsage, toOpenAIFinish (gemini/kiro/ollama + fix kiro tool finish), thinking effort maps - Reorg helpers/ → concerns/ (logic) + formats/ (per-format) + schema/ (pure enums: roles/blocks/finishReasons/defaults) - Wire ~280 hardcoded role/block/finish/default literals to schema enums across 20+ files - collapseTextParts + extractTextContent dedup - Normalize translator fn names to openaiToXRequest / xToOpenAIResponse - Golden tests lock behavior; 0 regression (byte-for-byte providers/alias, 26=26 known fails) Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
5.2 KiB
JavaScript
196 lines
5.2 KiB
JavaScript
import { register } from "../index.js";
|
|
import { FORMATS } from "../formats.js";
|
|
import { parseDataUri } from "../concerns/image.js";
|
|
import { safeParseJSON } from "../concerns/json.js";
|
|
import { ROLE, OPENAI_BLOCK } from "../schema/index.js";
|
|
|
|
/**
|
|
* Convert OpenAI request to Ollama format
|
|
*
|
|
* Ollama expects:
|
|
* - model: string
|
|
* - messages: Array<{role: string, content: string, images?: string[] }>
|
|
* - stream: boolean
|
|
* - options?: {temperature?: number, num_predict?: number}
|
|
*
|
|
* Key differences from OpenAI:
|
|
* - Content must be string, not array
|
|
* - Multimodal images should be mapped to `message.images[]` (raw base64, no data: prefix)
|
|
* - tool role maps to tool (Ollama supports tool messages)
|
|
*/
|
|
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 === 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 === 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: ROLE.TOOL,
|
|
tool_name: toolName,
|
|
content: toolResult
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Handle assistant messages with tool_calls
|
|
if (msg.role === 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: OPENAI_BLOCK.FUNCTION,
|
|
function: {
|
|
index: tc.index || 0,
|
|
name: tc.function?.name || "",
|
|
arguments: typeof tc.function?.arguments === "string"
|
|
? safeParseJSON(tc.function.arguments || "{}", {})
|
|
: tc.function?.arguments || {}
|
|
}
|
|
}));
|
|
|
|
result.push({
|
|
role: ROLE.ASSISTANT,
|
|
content: content,
|
|
tool_calls: ollamaToolCalls
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Normal messages
|
|
const role = msg.role;
|
|
const content = normalizeContent(msg.content);
|
|
const images = extractImagesFromContent(msg.content);
|
|
|
|
// Skip empty messages (except assistant)
|
|
if (!content && role !== ROLE.ASSISTANT) continue;
|
|
|
|
const out = {
|
|
role: role,
|
|
content: content
|
|
};
|
|
|
|
if (images.length > 0) {
|
|
out.images = images;
|
|
}
|
|
|
|
result.push(out);
|
|
}
|
|
|
|
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 === OPENAI_BLOCK.TEXT && block.text)
|
|
.map(block => block.text);
|
|
|
|
return textParts.join("\n") || "";
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Extract base64 images from OpenAI multimodal content blocks.
|
|
* OpenAI image block format:
|
|
* { type: "image_url", image_url: { url: "data:image/png;base64,..." } }
|
|
* Ollama expects raw base64 strings in message.images[].
|
|
*/
|
|
function extractImagesFromContent(content) {
|
|
if (!Array.isArray(content)) return [];
|
|
|
|
const images = [];
|
|
|
|
for (const block of content) {
|
|
if (!block || block.type !== OPENAI_BLOCK.IMAGE_URL) continue;
|
|
|
|
const url = typeof block.image_url === "string" ? block.image_url : block.image_url?.url;
|
|
if (typeof url !== "string" || !url) continue;
|
|
|
|
const parsed = parseDataUri(url);
|
|
if (!parsed) continue;
|
|
|
|
images.push(parsed.base64);
|
|
}
|
|
|
|
return images;
|
|
}
|
|
|
|
// Register translator
|
|
register(FORMATS.OPENAI, FORMATS.OLLAMA, openaiToOllamaRequest, null);
|