diff --git a/README-JA.md b/README-JA.md index aacae95..436c1bc 100644 --- a/README-JA.md +++ b/README-JA.md @@ -30,6 +30,7 @@ > > **📅 バージョン更新ログ** > +> - **2025.11.16** - Ollamaプロトコルサポートの追加、統一インターフェースでサポートされるすべてのモデルにアクセス > - **2025.11.11** - Web UI管理コントロールコンソールの追加、リアルタイム設定管理与健康状態モニタリングをサポート > - **2025.11.06** - Gemini 3 プレビュー版のサポートを追加、モデル互換性とパフォーマンス最適化を向上 > - **2025.10.18** - Kiroオープン登録、新規アカウントに500クレジット付与、Claude Sonnet 4.5を完全サポート @@ -267,6 +268,7 @@ install-and-run.bat APIリクエストパスでプロバイダー識別子を指定して即座に切り替え: + | ルートパス | 説明 | 使用ケース | |---------|------|---------| | `/claude-custom` | 設定ファイルのClaude APIを使用 | 公式Claude API呼び出し | @@ -275,20 +277,51 @@ APIリクエストパスでプロバイダー識別子を指定して即座に | `/gemini-cli-oauth` | Gemini CLI OAuth経由でアクセス | Gemini無料制限の突破 | | `/openai-qwen-oauth` | Qwen OAuth経由でアクセス | Qwen Code Plusの使用 | | `/openaiResponses-custom` | OpenAI Responses API | 構造化対話シナリオ | - +| `/ollama` | Ollama APIプロトコル | サポートされるすべてのモデルへの統一アクセス | + **使用例**: ```bash # Cline、Kiloなどのプログラミングエージェントで設定 API_ENDPOINT=http://localhost:3000/claude-kiro-oauth - + # 直接API呼び出し curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"gemini-2.0-flash-exp","messages":[...]}' ``` ---- +#### Ollamaプロトコル使用例 +本プロジェクトはOllamaプロトコルをサポートしており、統一インターフェースを通じてすべてのサポートモデルにアクセスできます。Ollamaエンドポイントは`/api/tags`、`/api/chat`、`/api/generate`などの標準インターフェースを提供します。 + +**Ollama API呼び出し例**: + +1. **利用可能なすべてのモデルをリスト表示**: +```bash +curl http://localhost:3000/ollama/api/tags +``` + +2. **チャットインターフェース**: +```bash +curl http://localhost:3000/ollama/api/chat \ + -H "Content-Type: application/json" \ + -d '{ + "model": "[Claude] claude-sonnet-4.5", + "messages": [ + {"role": "user", "content": "こんにちは"} + ] + }' +``` + +3. **モデルプレフィックスを使用してプロバイダーを指定**: +- `[Kiro]` - Kiro APIを使用してClaudeモデルにアクセス +- `[Claude]` - 公式Claude APIを使用 +- `[Gemini CLI]` - Gemini CLI OAuth経由でアクセス +- `[OpenAI]` - 公式OpenAI APIを使用 +- `[Qwen CLI]` - Qwen OAuth経由でアクセス + +--- + ### 📁 認証ファイル保存パス 各サービスの認証情報ファイルのデフォルト保存場所: @@ -453,6 +486,15 @@ node src/api-server.js \ ## 🙏 謝辞 本プロジェクトの開発は公式Google Gemini CLIから大きなインスピレーションを受け、Cline 3.18.0版 `gemini-cli.ts` の一部のコード実装を参考にしました。ここにGoogle公式チームとCline開発チームの優れた仕事に心より感謝申し上げます! +### 貢献者リスト + +AIClient-2-APIプロジェクトに貢献してくれたすべての開発者に感謝します: + +
+ +[justlikemaki](https://github.com/justlikemaki)[eltociear](https://github.com/eltociear)[LaelLuo](https://github.com/LaelLuo)[d7185540](https://github.com/d7185540)[bee4come](https://github.com/bee4come)[HALDRO](https://github.com/HALDRO) + +
## 🌟 Star History diff --git a/README-ZH.md b/README-ZH.md index 25638a3..7a2b040 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -30,6 +30,7 @@ > > **📅 版本更新日志** > +> - **2025.11.16** - 新增 Ollama 协议支持,统一接口访问所有支持的模型(Claude、Gemini、Qwen、OpenAI等) > - **2025.11.11** - 新增 Web UI 管理控制台,支持实时配置管理和健康状态监控 > - **2025.11.06** - 新增对 Gemini 3 预览版的支持,增强模型兼容性和性能优化 > - **2025.10.18** - Kiro 开放注册,新用户赠送 500 额度,已完整支持 Claude Sonnet 4.5 @@ -275,7 +276,8 @@ install-and-run.bat | `/gemini-cli-oauth` | 通过 Gemini CLI OAuth 访问 | 突破 Gemini 免费限制 | | `/openai-qwen-oauth` | 通过 Qwen OAuth 访问 | 使用 Qwen Code Plus | | `/openaiResponses-custom` | OpenAI Responses API | 结构化对话场景 | - +| `/ollama` | Ollama API 协议 | 统一访问所有支持的模型 | + **使用示例**: ```bash # 在 Cline、Kilo 等编程 Agent 中配置 @@ -287,6 +289,36 @@ curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \ -d '{"model":"gemini-2.0-flash-exp","messages":[...]}' ``` +### 🦙 Ollama 协议使用示例 + +本项目支持 Ollama 协议,可以通过统一接口访问所有支持的模型。Ollama 端点提供 `/api/tags`、`/api/chat`、`/api/generate` 等标准接口。 + +**Ollama API 调用示例**: + +1. **列出所有可用模型**: +```bash +curl http://localhost:3000/ollama/api/tags +``` + +2. **聊天接口**: +```bash +curl http://localhost:3000/ollama/api/chat \ + -H "Content-Type: application/json" \ + -d '{ + "model": "[Claude] claude-sonnet-4.5", + "messages": [ + {"role": "user", "content": "你好"} + ] + }' +``` + +3. **使用模型前缀指定提供商**: +- `[Kiro]` - 使用 Kiro API 访问 Claude 模型 +- `[Claude]` - 使用 Claude 官方 API +- `[Gemini CLI]` - 通过 Gemini CLI OAuth 访问 +- `[OpenAI]` - 使用 OpenAI 官方 API +- `[Qwen CLI]` - 通过 Qwen OAuth 访问 + --- ### 📁 授权文件存储路径 @@ -454,13 +486,23 @@ node src/api-server.js \ ## 📄 开源许可 本项目遵循 [**GNU General Public License v3 (GPLv3)**](https://www.gnu.org/licenses/gpl-3.0) 开源许可。详情请查看根目录下的 `LICENSE` 文件。 - ## 🙏 致谢 本项目的开发受到了官方 Google Gemini CLI 的极大启发,并参考了Cline 3.18.0 版本 `gemini-cli.ts` 的部分代码实现。在此对 Google 官方团队和 Cline 开发团队的卓越工作表示衷心的感谢! +### 贡献者列表 + +感谢以下所有为 AIClient-2-API 项目做出贡献的开发者: + +
+ +[justlikemaki](https://github.com/justlikemaki)[eltociear](https://github.com/eltociear)[LaelLuo](https://github.com/LaelLuo)[d7185540](https://github.com/d7185540)[bee4come](https://github.com/bee4come)[HALDRO](https://github.com/HALDRO) + +
+ ## 🌟 Star History + [![Star History Chart](https://api.star-history.com/svg?repos=justlovemaki/AIClient-2-API&type=Timeline)](https://www.star-history.com/#justlovemaki/AIClient-2-API&Timeline) --- diff --git a/README.md b/README.md index 49de594..2bb5e06 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ > > **📅 Version Update Log** > +> - **2025.11.16** - Added Ollama protocol support, unified interface to access all supported models (Claude, Gemini, Qwen, OpenAI, etc.) > - **2025.11.11** - Added Web UI management console, supporting real-time configuration management and health status monitoring > - **2025.11.06** - Added support for Gemini 3 Preview, enhanced model compatibility and performance optimization > - **2025.10.18** - Kiro open registration, new accounts get 500 credits, full support for Claude Sonnet 4.5 @@ -266,6 +267,7 @@ This project provides two flexible model switching methods to meet different usa Achieve instant switching by specifying provider identifier in API request path: + | Route Path | Description | Use Case | |---------|------|---------| | `/claude-custom` | Use Claude API from config file | Official Claude API calls | @@ -274,20 +276,51 @@ Achieve instant switching by specifying provider identifier in API request path: | `/gemini-cli-oauth` | Access via Gemini CLI OAuth | Break through Gemini free limits | | `/openai-qwen-oauth` | Access via Qwen OAuth | Use Qwen Code Plus | | `/openaiResponses-custom` | OpenAI Responses API | Structured dialogue scenarios | - +| `/ollama` | Ollama API protocol | Unified access to all supported models | + **Usage Examples**: ```bash # Configure in programming agents like Cline, Kilo API_ENDPOINT=http://localhost:3000/claude-kiro-oauth - + # Direct API call curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"gemini-2.0-flash-exp","messages":[...]}' ``` ---- +### Ollama Protocol Usage Examples +This project supports the Ollama protocol, allowing access to all supported models through a unified interface. The Ollama endpoint provides standard interfaces such as `/api/tags`, `/api/chat`, `/api/generate`, etc. + +**Ollama API Call Examples**: + +1. **List all available models**: +```bash +curl http://localhost:3000/ollama/api/tags +``` + +2. **Chat interface**: +```bash +curl http://localhost:3000/ollama/api/chat \ + -H "Content-Type: application/json" \ + -d '{ + "model": "[Claude] claude-sonnet-4.5", + "messages": [ + {"role": "user", "content": "Hello"} + ] + }' +``` + +3. **Specify provider using model prefix**: +- `[Kiro]` - Access Claude models using Kiro API +- `[Claude]` - Use official Claude API +- `[Gemini CLI]` - Access via Gemini CLI OAuth +- `[OpenAI]` - Use official OpenAI API +- `[Qwen CLI]` - Access via Qwen OAuth + +--- + ### 📁 Authorization File Storage Paths Default storage locations for authorization credential files of each service: @@ -453,6 +486,16 @@ This project operates under the [**GNU General Public License v3 (GPLv3)**](http ## 🙏 Acknowledgements The development of this project was significantly inspired by the official Google Gemini CLI and incorporated some code implementations from Cline 3.18.0's `gemini-cli.ts`. We extend our sincere gratitude to the official Google team and the Cline development team for their exceptional work! +### Contributor List + +Thanks to all the developers who contributed to the AIClient-2-API project: + +
+ +[justlikemaki](https://github.com/justlikemaki)[eltociear](https://github.com/eltociear)[LaelLuo](https://github.com/LaelLuo)[d7185540](https://github.com/d7185540)[bee4come](https://github.com/bee4come)[HALDRO](https://github.com/HALDRO) + +
+ ## 🌟 Star History diff --git a/src/common.js b/src/common.js index a53b2fd..e2d4946 100644 --- a/src/common.js +++ b/src/common.js @@ -17,6 +17,7 @@ export const MODEL_PROTOCOL_PREFIX = { OPENAI: 'openai', OPENAI_RESPONSES: 'openaiResponses', CLAUDE: 'claude', + OLLAMA: 'ollama', } export const MODEL_PROVIDER = { @@ -325,7 +326,7 @@ export async function handleModelListRequest(req, res, service, endpointType, CO // 1. Get the model list in the backend's native format. const nativeModelList = await service.listModels(); - + // 2. Convert the model list to the client's expected format, if necessary. let clientModelList = nativeModelList; if (!getProtocolPrefix(toProvider).includes(getProtocolPrefix(fromProvider))) { @@ -375,7 +376,7 @@ export async function handleContentGenerationRequest(req, res, service, endpoint const fromProvider = clientProviderMap[endpointType]; const toProvider = CONFIG.MODEL_PROVIDER; - + if (!fromProvider) { throw new Error(`Unsupported endpoint type for content generation: ${endpointType}`); } diff --git a/src/convert.js b/src/convert.js index b1c56b8..6703162 100644 --- a/src/convert.js +++ b/src/convert.js @@ -7,8 +7,7 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { MODEL_PROTOCOL_PREFIX } from './common.js'; -import { getProtocolPrefix } from './common.js'; +import { MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from './common.js'; import { ConverterFactory } from './converters/ConverterFactory.js'; import { generateResponseCreated, diff --git a/src/converters/register-converters.js b/src/converters/register-converters.js index 95278e8..3b3c565 100644 --- a/src/converters/register-converters.js +++ b/src/converters/register-converters.js @@ -9,6 +9,7 @@ import { OpenAIConverter } from './strategies/OpenAIConverter.js'; import { OpenAIResponsesConverter } from './strategies/OpenAIResponsesConverter.js'; import { ClaudeConverter } from './strategies/ClaudeConverter.js'; import { GeminiConverter } from './strategies/GeminiConverter.js'; +import { OllamaConverter } from './strategies/OllamaConverter.js'; /** * 注册所有转换器到工厂 @@ -19,6 +20,7 @@ export function registerAllConverters() { ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES, OpenAIResponsesConverter); ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.CLAUDE, ClaudeConverter); ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.GEMINI, GeminiConverter); + ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.OLLAMA, OllamaConverter); } // 自动注册所有转换器 diff --git a/src/converters/strategies/GeminiConverter.js b/src/converters/strategies/GeminiConverter.js index 438bbe0..37727d6 100644 --- a/src/converters/strategies/GeminiConverter.js +++ b/src/converters/strategies/GeminiConverter.js @@ -174,75 +174,73 @@ export class GeminiConverter extends BaseConverter { toOpenAIStreamChunk(geminiChunk, model) { if (!geminiChunk) return null; - // 处理完整的Gemini chunk对象 - if (typeof geminiChunk === 'object' && !Array.isArray(geminiChunk)) { - const candidate = geminiChunk.candidates?.[0]; - - // 提取文本内容 - let content = ''; - let finishReason = null; - - if (candidate) { - // 从parts中提取文本 - const parts = candidate.content?.parts; - if (parts && Array.isArray(parts)) { - content = parts - .filter(part => part && typeof part.text === 'string') - .map(part => part.text) - .join(''); + const candidate = geminiChunk.candidates?.[0]; + if (!candidate) return null; + + let content = ''; + const toolCalls = []; + + // 从parts中提取文本和tool calls + const parts = candidate.content?.parts; + if (parts && Array.isArray(parts)) { + for (const part of parts) { + if (part.text) { + content += part.text; } - - // 处理finishReason - if (candidate.finishReason) { - finishReason = candidate.finishReason === 'STOP' ? 'stop' : - candidate.finishReason === 'MAX_TOKENS' ? 'length' : - candidate.finishReason.toLowerCase(); + if (part.functionCall) { + toolCalls.push({ + id: part.functionCall.id || `call_${uuidv4()}`, + type: 'function', + function: { + name: part.functionCall.name, + arguments: typeof part.functionCall.args === 'string' + ? part.functionCall.args + : JSON.stringify(part.functionCall.args) + } + }); } + // thoughtSignature is ignored (internal Gemini data) } - - return { - id: `chatcmpl-${uuidv4()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: content ? { content: content } : {}, - finish_reason: finishReason, - }], - usage: geminiChunk.usageMetadata ? { - prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, - completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, - total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0, - } : { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; } - // 向后兼容:处理字符串格式 - if (typeof geminiChunk === 'string') { - return { - id: `chatcmpl-${uuidv4()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: { content: geminiChunk }, - finish_reason: null, - }], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; + // 处理finishReason + let finishReason = null; + if (candidate.finishReason) { + finishReason = candidate.finishReason === 'STOP' ? 'stop' : + candidate.finishReason === 'MAX_TOKENS' ? 'length' : + candidate.finishReason.toLowerCase(); } - return null; + // 构建delta对象 + const delta = {}; + if (content) delta.content = content; + if (toolCalls.length > 0) delta.tool_calls = toolCalls; + + // Don't return empty delta chunks + if (Object.keys(delta).length === 0 && !finishReason) { + return null; + } + + return { + id: `chatcmpl-${uuidv4()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: model, + choices: [{ + index: 0, + delta: delta, + finish_reason: finishReason, + }], + usage: geminiChunk.usageMetadata ? { + prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, + completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, + total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0, + } : { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + }; } /** diff --git a/src/converters/strategies/OllamaConverter.js b/src/converters/strategies/OllamaConverter.js new file mode 100644 index 0000000..69bed9d --- /dev/null +++ b/src/converters/strategies/OllamaConverter.js @@ -0,0 +1,582 @@ +/** + * Ollama转换器 + * 处理Ollama协议与其他协议之间的转换 + */ + +import { v4 as uuidv4 } from 'uuid'; +import { createHash } from 'crypto'; +import { BaseConverter } from '../BaseConverter.js'; +import { MODEL_PROTOCOL_PREFIX } from '../../common.js'; + + + +/** + * Ollama转换器类 + * 实现Ollama协议到其他协议的转换 + */ +export class OllamaConverter extends BaseConverter { + constructor() { + super('ollama'); + } + + /** + * 转换请求 - Ollama -> 其他协议 + */ + convertRequest(data, targetProtocol) { + switch (targetProtocol) { + case MODEL_PROTOCOL_PREFIX.OPENAI: + case MODEL_PROTOCOL_PREFIX.CLAUDE: + case MODEL_PROTOCOL_PREFIX.GEMINI: + return this.toOpenAIRequest(data); + default: + throw new Error(`Unsupported target protocol: ${targetProtocol}`); + } + } + + /** + * 转换响应 - 其他协议 -> Ollama + */ + convertResponse(data, sourceProtocol, model) { + return this.toOllamaChatResponse(data, model); + } + + /** + * 转换流式响应块 - 其他协议 -> Ollama + */ + convertStreamChunk(chunk, sourceProtocol, model, isDone = false) { + return this.toOllamaStreamChunk(chunk, model, isDone); + } + + /** + * 转换模型列表 - 其他协议 -> Ollama + */ + convertModelList(data, sourceProtocol) { + return this.toOllamaTags(data, sourceProtocol); + } + + // ========================================================================= + // Ollama -> OpenAI 转换 + // ========================================================================= + + /** + * Ollama请求 -> OpenAI请求 + */ + toOpenAIRequest(ollamaRequest) { + const openaiRequest = { + model: ollamaRequest.model || 'default', + messages: [], + stream: ollamaRequest.stream !== undefined ? ollamaRequest.stream : false + }; + + // Map Ollama messages to OpenAI format + if (ollamaRequest.messages && Array.isArray(ollamaRequest.messages)) { + openaiRequest.messages = ollamaRequest.messages.map(msg => ({ + role: msg.role || 'user', + content: msg.content || '' + })); + } + + // Map Ollama options to OpenAI parameters + if (ollamaRequest.options) { + const opts = ollamaRequest.options; + if (opts.temperature !== undefined) openaiRequest.temperature = opts.temperature; + if (opts.top_p !== undefined) openaiRequest.top_p = opts.top_p; + if (opts.top_k !== undefined) openaiRequest.top_k = opts.top_k; + if (opts.num_predict !== undefined) openaiRequest.max_tokens = opts.num_predict; + if (opts.stop !== undefined) openaiRequest.stop = opts.stop; + } + + // Handle system prompt + if (ollamaRequest.system) { + openaiRequest.messages.unshift({ + role: 'system', + content: ollamaRequest.system + }); + } + + // Handle template/prompt for generate endpoint + if (ollamaRequest.prompt) { + openaiRequest.messages = [{ + role: 'user', + content: ollamaRequest.prompt + }]; + + // Add system prompt if provided + if (ollamaRequest.system) { + openaiRequest.messages.unshift({ + role: 'system', + content: ollamaRequest.system + }); + } + } + + return openaiRequest; + } + + // ========================================================================= + // OpenAI/Claude/Gemini -> Ollama 转换 + // ========================================================================= + + /** + * OpenAI/Claude/Gemini响应 -> Ollama chat响应 + */ + toOllamaChatResponse(response, model) { + const ollamaResponse = { + model: model || response.model || 'unknown', + created_at: new Date().toISOString(), + done: true + }; + + // Handle OpenAI format (choices array) + if (response.choices && response.choices.length > 0) { + const choice = response.choices[0]; + ollamaResponse.message = { + role: choice.message?.role || 'assistant', + content: choice.message?.content || '' + }; + + // Map finish reason + if (choice.finish_reason) { + ollamaResponse.done_reason = choice.finish_reason === 'stop' ? 'stop' : choice.finish_reason; + } + } + // Handle Claude format (content array) + else if (response.content && Array.isArray(response.content)) { + let textContent = ''; + response.content.forEach(block => { + if (block.type === 'text' && block.text) { + textContent += block.text; + } + }); + + ollamaResponse.message = { + role: response.role || 'assistant', + content: textContent + }; + + if (response.stop_reason) { + ollamaResponse.done_reason = response.stop_reason === 'end_turn' ? 'stop' : response.stop_reason; + } + } + + // Add usage statistics if available + if (response.usage) { + ollamaResponse.prompt_eval_count = response.usage.prompt_tokens || response.usage.input_tokens || 0; + ollamaResponse.eval_count = response.usage.completion_tokens || response.usage.output_tokens || 0; + ollamaResponse.total_duration = 0; + ollamaResponse.load_duration = 0; + ollamaResponse.prompt_eval_duration = 0; + ollamaResponse.eval_duration = 0; + } + + return ollamaResponse; + } + + /** + * OpenAI/Claude/Gemini generate响应 -> Ollama generate响应 + */ + toOllamaGenerateResponse(response, model) { + const ollamaResponse = { + model: model || response.model || 'unknown', + created_at: new Date().toISOString(), + done: true + }; + + // Handle OpenAI format + if (response.choices && response.choices.length > 0) { + const choice = response.choices[0]; + ollamaResponse.response = choice.message?.content || choice.text || ''; + + if (choice.finish_reason) { + ollamaResponse.done_reason = choice.finish_reason === 'stop' ? 'stop' : choice.finish_reason; + } + } + // Handle Claude format + else if (response.content && Array.isArray(response.content)) { + let textContent = ''; + response.content.forEach(block => { + if (block.type === 'text' && block.text) { + textContent += block.text; + } + }); + ollamaResponse.response = textContent; + + if (response.stop_reason) { + ollamaResponse.done_reason = response.stop_reason === 'end_turn' ? 'stop' : response.stop_reason; + } + } + + // Add usage statistics + if (response.usage) { + ollamaResponse.prompt_eval_count = response.usage.prompt_tokens || response.usage.input_tokens || 0; + ollamaResponse.eval_count = response.usage.completion_tokens || response.usage.output_tokens || 0; + ollamaResponse.total_duration = 0; + ollamaResponse.load_duration = 0; + ollamaResponse.prompt_eval_duration = 0; + ollamaResponse.eval_duration = 0; + } + + return ollamaResponse; + } + + /** + * OpenAI/Claude/Gemini流式块 -> Ollama流式块 + */ + toOllamaStreamChunk(chunk, model, isDone = false) { + const ollamaChunk = { + model: model || 'unknown', + created_at: new Date().toISOString(), + done: isDone + }; + + // Handle Claude SSE format + if (chunk.type) { + if (chunk.type === 'content_block_delta' && chunk.delta) { + ollamaChunk.message = { + role: 'assistant', + content: chunk.delta.text || '' + }; + } else if (chunk.type === 'message_delta' && chunk.usage) { + ollamaChunk.message = { + role: 'assistant', + content: '' + }; + ollamaChunk.prompt_eval_count = 0; + ollamaChunk.eval_count = chunk.usage.output_tokens || 0; + } else { + ollamaChunk.message = { + role: 'assistant', + content: '' + }; + } + } + // Handle Gemini format + else if (!isDone && chunk.candidates && chunk.candidates.length > 0) { + const candidate = chunk.candidates[0]; + let content = ''; + if (candidate.content && candidate.content.parts) { + content = candidate.content.parts + .filter(part => part.text) + .map(part => part.text) + .join(''); + } + ollamaChunk.message = { + role: 'assistant', + content: content + }; + } + // Handle OpenAI format + else if (!isDone && chunk.choices && chunk.choices.length > 0) { + const delta = chunk.choices[0].delta; + ollamaChunk.message = { + role: delta.role || 'assistant', + content: delta.content || '' + }; + } + // Handle final chunk + else if (isDone) { + ollamaChunk.message = { + role: 'assistant', + content: '' + }; + ollamaChunk.done_reason = 'stop'; + } + + return ollamaChunk; + } + + /** + * OpenAI/Claude/Gemini流式块 -> Ollama generate流式块 + */ + toOllamaGenerateStreamChunk(chunk, model, isDone = false) { + const ollamaChunk = { + model: model || 'unknown', + created_at: new Date().toISOString(), + done: isDone + }; + + // Handle Claude SSE format + if (chunk.type) { + if (chunk.type === 'content_block_delta' && chunk.delta) { + ollamaChunk.response = chunk.delta.text || ''; + } else if (chunk.type === 'message_delta' && chunk.usage) { + ollamaChunk.response = ''; + ollamaChunk.prompt_eval_count = 0; + ollamaChunk.eval_count = chunk.usage.output_tokens || 0; + } else { + ollamaChunk.response = ''; + } + } + // Handle OpenAI format + else if (!isDone && chunk.choices && chunk.choices.length > 0) { + const delta = chunk.choices[0].delta; + ollamaChunk.response = delta.content || ''; + } + // Handle final chunk + else if (isDone) { + ollamaChunk.response = ''; + ollamaChunk.done_reason = 'stop'; + } + + return ollamaChunk; + } + + /** + * OpenAI/Claude/Gemini模型列表 -> Ollama tags + */ + toOllamaTags(modelList, sourceProtocol = null) { + const models = []; + + // Handle both OpenAI format (data array) and Gemini format (models array) + const sourceModels = modelList.data || modelList.models || []; + + if (Array.isArray(sourceModels)) { + sourceModels.forEach(model => { + // Get model name + let modelName = model.id || model.name || model.displayName || 'unknown'; + + // Remove "models/" prefix if present (for Gemini) + if (modelName.startsWith('models/')) { + modelName = modelName.substring(7); // Remove "models/" + } + + // Skip models with invalid names + if (modelName === 'unknown' || !modelName) { + return; + } + + // IMPORTANT: Copilot expects family: "Ollama" with capital O! + const modelOwner = 'Ollama'; + + models.push({ + name: modelName, + model: modelName, + modified_at: new Date().toISOString(), + size: 0, // As in the old patch + digest: '', // Empty string, as in the old patch + details: { + parent_model: '', + format: 'gguf', + family: modelOwner, // "Ollama" with capital O + families: [modelOwner], + parameter_size: '0B', // As in the old patch + quantization_level: 'Q4_0' + } + }); + }); + } + + return { models }; + } + + /** + * Generate Ollama show response + */ + toOllamaShowResponse(modelName) { + // Minimal implementation, as in the old patch + let contextLength = 8192; + let maxOutputTokens = 4096; + let family = 'Ollama'; // ВАЖНО: С большой буквы, как ожидает Copilot! + let architecture = 'transformer'; + + const lowerName = modelName.toLowerCase(); + + // Determine contextLength by model name + // Claude models + if (lowerName.includes('claude')) { + architecture = 'claude'; + contextLength = 200000; // Default 200K + + // Claude Sonnet 4.5 + if (lowerName.includes('sonnet-4-5') || lowerName.includes('sonnet-4.5')) { + contextLength = 200000; // 200K (1M beta available) + maxOutputTokens = 64000; // 64K output + } + // Claude Haiku 4.5 + else if (lowerName.includes('haiku-4-5') || lowerName.includes('haiku-4.5')) { + contextLength = 200000; // 200K + maxOutputTokens = 64000; // 64K output + } + // Claude Opus 4.1 + else if (lowerName.includes('opus-4-1') || lowerName.includes('opus-4.1')) { + contextLength = 200000; // 200K + maxOutputTokens = 32000; // 32K output + } + // Claude Sonnet 4.0 (legacy) + else if (lowerName.includes('sonnet-4-0') || lowerName.includes('sonnet-4.0') || lowerName.includes('sonnet-4-20')) { + contextLength = 200000; // 200K (1M beta available) + maxOutputTokens = 64000; // 64K output + } + // Claude Sonnet 3.7 (legacy) + else if (lowerName.includes('3-7') || lowerName.includes('3.7')) { + contextLength = 200000; // 200K + maxOutputTokens = 64000; // 64K output (128K beta available) + } + // Claude Opus 4.0 (legacy) + else if (lowerName.includes('opus-4-0') || lowerName.includes('opus-4.0') || lowerName.includes('opus-4-20')) { + contextLength = 200000; // 200K + maxOutputTokens = 32000; // 32K output + } + // Claude Haiku 3.5 (legacy) + else if (lowerName.includes('haiku-3-5') || lowerName.includes('haiku-3.5')) { + contextLength = 200000; // 200K + maxOutputTokens = 8192; // 8K output + } + // Claude Haiku 3.0 (legacy) + else if (lowerName.includes('haiku-3-0') || lowerName.includes('haiku-3.0') || lowerName.includes('haiku-20240307')) { + contextLength = 200000; // 200K + maxOutputTokens = 4096; // 4K output + } + // Claude Sonnet 3.5 (legacy) + else if (lowerName.includes('sonnet-3-5') || lowerName.includes('sonnet-3.5')) { + contextLength = 200000; // 200K + maxOutputTokens = 8192; // 8K output + } + // Claude Opus 3.0 (legacy) + else if (lowerName.includes('opus-3-0') || lowerName.includes('opus-3.0') || lowerName.includes('opus') && lowerName.includes('20240229')) { + contextLength = 200000; // 200K + maxOutputTokens = 4096; // 4K output + } + // Default for Claude + else { + contextLength = 200000; // 200K + maxOutputTokens = 8192; // 8K output + } + } + // Gemini models + else if (lowerName.includes('gemini')) { + architecture = 'gemini'; + + // Gemini 2.5 Pro + if (lowerName.includes('2.5') && lowerName.includes('pro')) { + contextLength = 1048576; // 1M input tokens + maxOutputTokens = 65536; // 65K output tokens + } + // Gemini 2.5 Flash / Flash-Lite + else if (lowerName.includes('2.5') && (lowerName.includes('flash') || lowerName.includes('lite'))) { + contextLength = 1048576; // 1M input tokens + maxOutputTokens = 65536; // 65K output tokens + } + // Gemini 2.5 Flash Image + else if (lowerName.includes('2.5') && lowerName.includes('image')) { + contextLength = 65536; // 65K input tokens + maxOutputTokens = 32768; // 32K output tokens + } + // Gemini 2.5 Flash Live / Native Audio + else if (lowerName.includes('2.5') && (lowerName.includes('live') || lowerName.includes('native-audio'))) { + contextLength = 131072; // 131K input tokens + maxOutputTokens = 8192; // 8K output tokens + } + // Gemini 2.5 TTS + else if (lowerName.includes('2.5') && lowerName.includes('tts')) { + contextLength = 8192; // 8K input tokens + maxOutputTokens = 16384; // 16K output tokens + } + // Gemini 2.0 Flash + else if (lowerName.includes('2.0') && lowerName.includes('flash')) { + contextLength = 1048576; // 1M input tokens + maxOutputTokens = 8192; // 8K output tokens + } + // Gemini 2.0 Flash Image + else if (lowerName.includes('2.0') && lowerName.includes('image')) { + contextLength = 32768; // 32K input tokens + maxOutputTokens = 8192; // 8K output tokens + } + // Gemini 1.5 Pro (legacy) + else if (lowerName.includes('1.5') && lowerName.includes('pro')) { + contextLength = 2097152; // 2M tokens + maxOutputTokens = 8192; + } + // Gemini 1.5 Flash (legacy) + else if (lowerName.includes('1.5') && lowerName.includes('flash')) { + contextLength = 1048576; // 1M tokens + maxOutputTokens = 8192; + } + // Default for Gemini + else { + contextLength = 1048576; // 1M tokens + maxOutputTokens = 8192; + } + } + // GPT-4 models + else if (lowerName.includes('gpt-4')) { + architecture = 'gpt'; + + if (lowerName.includes('turbo') || lowerName.includes('preview')) { + contextLength = 128000; // GPT-4 Turbo + maxOutputTokens = 4096; + } else if (lowerName.includes('32k')) { + contextLength = 32768; + maxOutputTokens = 4096; + } else { + contextLength = 8192; // GPT-4 base + maxOutputTokens = 4096; + } + } + // GPT-3.5 models + else if (lowerName.includes('gpt-3.5')) { + architecture = 'gpt'; + + if (lowerName.includes('16k')) { + contextLength = 16385; + maxOutputTokens = 4096; + } else { + contextLength = 4096; + maxOutputTokens = 4096; + } + } + // Qwen models + else if (lowerName.includes('qwen')) { + architecture = 'qwen'; + + // Qwen3 Coder Plus (coder-model) + if (lowerName.includes('coder-plus') || lowerName.includes('coder_plus') || lowerName.includes('coder-model')) { + contextLength = 128000; // 128K tokens + maxOutputTokens = 65536; // 65K output + } + // Qwen3 VL Plus (vision-model) + else if (lowerName.includes('vl-plus') || lowerName.includes('vl_plus') || lowerName.includes('vision-model')) { + contextLength = 262144; // 256K tokens + maxOutputTokens = 32768; // 32K output + } + // Qwen3 Coder Flash + else if (lowerName.includes('coder-flash') || lowerName.includes('coder_flash')) { + contextLength = 128000; // 128K tokens + maxOutputTokens = 65536; // 65K output + } + // Default for Qwen + else { + contextLength = 32768; // 32K tokens + maxOutputTokens = 8192; + } + } + + // Minimal parameter_size, as in the old patch + let parameterSize = '0B'; + + return { + license: '', + modelfile: `# Modelfile for ${modelName}\nFROM ${modelName}`, + parameters: `num_ctx ${contextLength}\nnum_predict ${maxOutputTokens}\ntemperature 0.7\ntop_p 0.9`, + template: '{{ if .System }}{{ .System }}\n{{ end }}{{ .Prompt }}', + details: { + parent_model: '', + format: 'gguf', + family: family, + families: [family], + parameter_size: parameterSize, + quantization_level: 'Q4_K_M' + }, + model_info: { + 'general.architecture': architecture, + 'general.file_type': 2, + 'general.parameter_count': 0, + 'general.quantization_version': 2, + 'general.context_length': contextLength, + 'llama.context_length': contextLength, + 'llama.rope.freq_base': 10000.0 + }, + capabilities: ['tools', 'vision', 'completion'] // Indicate that the model supports tool calling + }; + } +} diff --git a/src/converters/strategies/OpenAIConverter.js b/src/converters/strategies/OpenAIConverter.js index b2796c6..fafeab4 100644 --- a/src/converters/strategies/OpenAIConverter.js +++ b/src/converters/strategies/OpenAIConverter.js @@ -533,13 +533,41 @@ export class OpenAIConverter extends BaseConverter { const geminiRole = message.role === 'assistant' ? 'model' : message.role; if (geminiRole === 'tool') { - if (lastMessage) processedMessages.push(lastMessage); + // Save previous model response with functionCall + if (lastMessage) { + processedMessages.push(lastMessage); + lastMessage = null; + } + + // Get function name from message.name or via tool_call_id + let functionName = message.name; + if (!functionName && message.tool_call_id) { + const currentIndex = nonSystemMessages.indexOf(message); + for (let i = currentIndex - 1; i >= 0; i--) { + const prevMsg = nonSystemMessages[i]; + if (prevMsg.role === 'assistant' && prevMsg.tool_calls) { + const toolCall = prevMsg.tool_calls.find(tc => tc.id === message.tool_call_id); + if (toolCall?.function?.name) { + functionName = toolCall.function.name; + break; + } + } + } + } + + // Build functionResponse according to Gemini API spec + const parsedContent = safeParseJSON(message.content); + const contentStr = typeof parsedContent === 'string' ? parsedContent : JSON.stringify(parsedContent); + processedMessages.push({ - role: 'function', + role: 'user', parts: [{ functionResponse: { - name: message.name, - response: { content: safeParseJSON(message.content) } + name: functionName || 'unknown', + response: { + name: functionName || 'unknown', + content: contentStr + } } }] }); @@ -547,7 +575,21 @@ export class OpenAIConverter extends BaseConverter { continue; } - const processedContent = this.processOpenAIContentToGeminiParts(message.content); + let processedContent = this.processOpenAIContentToGeminiParts(message.content); + + // Add tool_calls as functionCall to parts + if (message.tool_calls && Array.isArray(message.tool_calls)) { + for (const toolCall of message.tool_calls) { + if (toolCall.function) { + processedContent.push({ + functionCall: { + name: toolCall.function.name, + args: safeParseJSON(toolCall.function.arguments) + } + }); + } + } + } if (lastMessage && lastMessage.role === geminiRole && !message.tool_calls && Array.isArray(processedContent) && processedContent.every(p => p.text) && @@ -589,7 +631,7 @@ export class OpenAIConverter extends BaseConverter { geminiRequest.toolConfig = this.buildGeminiToolConfig(openaiRequest.tool_choice); } - const config = this.buildGeminiGenerationConfig(openaiRequest); + const config = this.buildGeminiGenerationConfig(openaiRequest, openaiRequest.model); if (Object.keys(config).length) geminiRequest.generationConfig = config; return geminiRequest; @@ -649,12 +691,23 @@ export class OpenAIConverter extends BaseConverter { /** * 构建Gemini生成配置 */ - buildGeminiGenerationConfig({ temperature, max_tokens, top_p, stop }) { + buildGeminiGenerationConfig({ temperature, max_tokens, top_p, stop, tools }, model) { const config = {}; config.temperature = checkAndAssignOrDefault(temperature, 1); config.maxOutputTokens = checkAndAssignOrDefault(max_tokens, 65535); config.topP = checkAndAssignOrDefault(top_p, 0.95); if (stop !== undefined) config.stopSequences = Array.isArray(stop) ? stop : [stop]; + + // Gemini 2.5 and thinking models require responseModalities: ["TEXT"] + // But this parameter cannot be added when using tools (causes 400 error) + const hasTools = tools && Array.isArray(tools) && tools.length > 0; + if (!hasTools && model && (model.includes('2.5') || model.includes('thinking') || model.includes('2.0-flash-thinking'))) { + console.log(`[OpenAI->Gemini] Adding responseModalities: ["TEXT"] for model: ${model}`); + config.responseModalities = ["TEXT"]; + } else if (hasTools && model && (model.includes('2.5') || model.includes('thinking') || model.includes('2.0-flash-thinking'))) { + console.log(`[OpenAI->Gemini] Skipping responseModalities for model ${model} because tools are present`); + } + return config; } /** diff --git a/src/ollama-handler.js b/src/ollama-handler.js new file mode 100644 index 0000000..78b56fc --- /dev/null +++ b/src/ollama-handler.js @@ -0,0 +1,676 @@ +/** + * Ollama API 处理器 + * 处理Ollama特定的端点并在后端协议之间进行转换 + */ + +import { getRequestBody, handleError, MODEL_PROTOCOL_PREFIX, MODEL_PROVIDER, getProtocolPrefix } from './common.js'; +import { convertData } from './convert.js'; +import { ConverterFactory } from './converters/ConverterFactory.js'; + +// Ollama版本号 +/** + * Model name prefix mapping for different providers + * These prefixes are added to model names in the list for user visibility + * but are removed before sending to actual providers + */ +export const MODEL_PREFIX_MAP = { + [MODEL_PROVIDER.KIRO_API]: '[Kiro]', + [MODEL_PROVIDER.CLAUDE_CUSTOM]: '[Claude]', + [MODEL_PROVIDER.GEMINI_CLI]: '[Gemini CLI]', + [MODEL_PROVIDER.OPENAI_CUSTOM]: '[OpenAI]', + [MODEL_PROVIDER.QWEN_API]: '[Qwen CLI]', + [MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES]: '[OpenAI Responses]', +} + +/** + * Adds provider prefix to model name for display purposes + * @param {string} modelName - Original model name + * @param {string} provider - Provider type + * @returns {string} Model name with prefix + */ +export function addModelPrefix(modelName, provider) { + if (!modelName) return modelName; + + // Don't add prefix if already exists + if (/^\[.*?\]\s+/.test(modelName)) { + return modelName; + } + + const prefix = MODEL_PREFIX_MAP[provider]; + if (!prefix) { + return modelName; + } + return `${prefix} ${modelName}`; +} + +/** + * Removes provider prefix from model name before sending to provider + * @param {string} modelName - Model name with possible prefix + * @returns {string} Clean model name without prefix + */ +export function removeModelPrefix(modelName) { + if (!modelName) { + return modelName; + } + + // Remove any prefix pattern like [Warp], [Kiro], etc. + const prefixPattern = /^\[.*?\]\s+/; + return modelName.replace(prefixPattern, ''); +} + +/** + * Extracts provider type from prefixed model name + * @param {string} modelName - Model name with possible prefix + * @returns {string|null} Provider type or null if no prefix found + */ +export function getProviderFromPrefix(modelName) { + if (!modelName) { + return null; + } + + const match = modelName.match(/^\[(.*?)\]/); + if (!match) { + return null; + } + + const prefixText = `[${match[1]}]`; + + // Find provider by prefix + for (const [provider, prefix] of Object.entries(MODEL_PREFIX_MAP)) { + if (prefix === prefixText) { + return provider; + } + } + + return null; +} + +/** + * Adds provider prefix to array of models (works with any format) + * @param {Array} models - Array of model objects + * @param {string} provider - Provider type + * @param {string} format - Format type ('openai', 'gemini', 'ollama') + * @returns {Array} Models with prefixed names + */ +export function addPrefixToModels(models, provider, format = 'openai') { + if (!Array.isArray(models)) return models; + + return models.map(model => { + if (format === 'openai') { + return { ...model, id: addModelPrefix(model.id, provider) }; + } else if (format === 'ollama') { + return { + ...model, + name: addModelPrefix(model.name, provider), + model: addModelPrefix(model.model || model.name, provider) + }; + } else { + // gemini/claude format + return { + ...model, + name: addModelPrefix(model.name, provider), + displayName: model.displayName ? addModelPrefix(model.displayName, provider) : undefined + }; + } + }); +} + +/** + * Determine which provider to use based on model name + * @param {string} modelName - Model name (may include prefix like "[Warp] gpt-5") + * @param {Object} providerPoolManager - Provider pool manager + * @param {string} defaultProvider - Default provider + * @returns {string} Provider type + */ +export function getProviderByModelName(modelName, providerPoolManager, defaultProvider) { + if (!modelName || !providerPoolManager || !providerPoolManager.providerPools) { + return defaultProvider; + } + + // First, check if model name has a prefix that directly indicates the provider + const providerFromPrefix = getProviderFromPrefix(modelName); + if (providerFromPrefix) { + console.log(`[Provider Selection] Provider determined from prefix: ${providerFromPrefix}`); + return providerFromPrefix; + } + + // Remove prefix for further analysis + const cleanModelName = removeModelPrefix(modelName); + const lowerModelName = cleanModelName.toLowerCase(); + + // Check if it's a Claude model + if (lowerModelName.includes('claude') || lowerModelName.includes('sonnet') || lowerModelName.includes('opus') || lowerModelName.includes('haiku')) { + // Find available Claude provider + for (const [providerType, providers] of Object.entries(providerPoolManager.providerPools)) { + if (providerType.includes('claude') || providerType.includes('kiro')) { + const healthyProvider = providers.find(p => p.isHealthy); + if (healthyProvider) { + return providerType; + } + } + } + } + + // Check if it's a Gemini model + if (lowerModelName.includes('gemini')) { + // Find available Gemini provider + for (const [providerType, providers] of Object.entries(providerPoolManager.providerPools)) { + if (providerType.includes('gemini')) { + const healthyProvider = providers.find(p => p.isHealthy); + if (healthyProvider) { + return providerType; + } + } + } + } + + // Check if it's a Qwen model + if (lowerModelName.includes('qwen')) { + // Find available Qwen provider + for (const [providerType, providers] of Object.entries(providerPoolManager.providerPools)) { + if (providerType.includes('qwen')) { + const healthyProvider = providers.find(p => p.isHealthy); + if (healthyProvider) { + return providerType; + } + } + } + } + + // Check if it's a GPT model + if (lowerModelName.includes('gpt')) { + // Find available OpenAI provider + for (const [providerType, providers] of Object.entries(providerPoolManager.providerPools)) { + if (providerType.includes('openai')) { + const healthyProvider = providers.find(p => p.isHealthy); + if (healthyProvider) { + return providerType; + } + } + } + } + + return defaultProvider; +} + +const OLLAMA_VERSION = '0.12.10'; + +/** + * Model to Provider Mapper + * Maps model names to their corresponding providers + */ + +/** + * Get provider type for a given model name + * @param {string} modelName - The model name to look up (may include prefix like "[Warp] gpt-5") + * @param {string} defaultProvider - The default provider if no match is found + * @returns {string} The provider type + */ +export function getProviderForModel(modelName, defaultProvider) { + if (!modelName) { + return defaultProvider; + } + + // First, check if model name has a prefix that directly indicates the provider + const providerFromPrefix = getProviderFromPrefix(modelName); + if (providerFromPrefix) { + return providerFromPrefix; + } + + // Remove prefix for further analysis + const cleanModelName = removeModelPrefix(modelName); + const lowerModel = cleanModelName.toLowerCase(); + + // Gemini models + if (lowerModel.includes('gemini')) { + return MODEL_PROVIDER.GEMINI_CLI; + } + + // Claude models (excluding Warp's claude models) + if (lowerModel.includes('claude')) { + // Check if it's a Kiro model + if (lowerModel.includes('amazonq')) { + return MODEL_PROVIDER.KIRO_API; + } + return MODEL_PROVIDER.CLAUDE_CUSTOM; + } + + // Qwen models + if (lowerModel.includes('qwen')) { + return MODEL_PROVIDER.QWEN_API; + } + + // OpenAI models (excluding Warp's gpt models) + if (lowerModel.includes('gpt') || lowerModel.includes('o1') || lowerModel.includes('o3')) { + return MODEL_PROVIDER.OPENAI_CUSTOM; + } + + // Default to the provided default provider + return defaultProvider; +} + +/** + * Check if a model belongs to a specific provider + * @param {string} modelName - The model name + * @param {string} providerType - The provider type to check + * @returns {boolean} True if the model belongs to the provider + */ +export function isModelFromProvider(modelName, providerType) { + const detectedProvider = getProviderForModel(modelName, null); + return detectedProvider === providerType; +} + +/** + * 规范化 Ollama 路径并检查是否为 Ollama 端点 + * @param {string} path - 原始路径 + * @param {URL} requestUrl - 请求 URL 对象 + * @returns {Object} - { normalizedPath: string, isOllamaEndpoint: boolean } + */ +export function normalizeOllamaPath(path, requestUrl) { + let normalizedPath = path; + + // Normalize common Ollama path aliases (e.g., '/ollama/api/tags' -> '/api/tags') + if (normalizedPath.startsWith('/ollama/')) { + normalizedPath = normalizedPath.replace(/^\/ollama/, ''); + if (requestUrl) { + requestUrl.pathname = normalizedPath; + } + } + + // Map other common aliases + if (normalizedPath === '/api/models') { + normalizedPath = '/api/tags'; + if (requestUrl) { + requestUrl.pathname = normalizedPath; + } + } + if (normalizedPath === '/api/tags/') { + normalizedPath = '/api/tags'; + if (requestUrl) { + requestUrl.pathname = normalizedPath; + } + } + + // Check if this is an Ollama endpoint + const isOllamaEndpoint = normalizedPath.startsWith('/api/'); + + return { normalizedPath, isOllamaEndpoint }; +} + +/** + * 处理所有 Ollama 相关的路径规范化和端点路由 + * @param {string} method - HTTP 方法 + * @param {string} path - 请求路径 + * @param {URL} requestUrl - 请求 URL 对象 + * @param {Object} req - 请求对象 + * @param {Object} res - 响应对象 + * @param {Object} apiService - API 服务实例 + * @param {Object} currentConfig - 当前配置 + * @param {Object} providerPoolManager - 提供商池管理器 + * @returns {Object} - { handled: boolean, normalizedPath: string } + */ +export async function handleOllamaRequest(method, path, requestUrl, req, res, apiService, currentConfig, providerPoolManager) { + // Normalize Ollama paths + const { normalizedPath } = normalizeOllamaPath(path, requestUrl); + + // Handle Ollama endpoints before auth check + const ollamaHandledBeforeAuth = await handleOllamaEndpointsBeforeAuth(method, normalizedPath, req, res); + if (ollamaHandledBeforeAuth) { + return { handled: true, normalizedPath }; + } + + // Handle Ollama endpoints after auth check + const ollamaHandledAfterAuth = await handleOllamaEndpointsAfterAuth(method, normalizedPath, req, res, apiService, currentConfig, providerPoolManager); + if (ollamaHandledAfterAuth) { + return { handled: true, normalizedPath }; + } + + return { handled: false, normalizedPath }; +} + +/** + * 处理 Ollama 端点路由(在认证检查之前) + * @param {string} method - HTTP 方法 + * @param {string} path - 请求路径 + * @param {Object} req - 请求对象 + * @param {Object} res - 响应对象 + * @returns {boolean} - 是否已处理请求 + */ +export async function handleOllamaEndpointsBeforeAuth(method, path, req, res) { + // Handle Ollama API endpoints BEFORE auth check (Ollama doesn't use authentication by default) + if (method === 'GET' && path === '/api/version') { + handleOllamaVersion(res); + return true; + } + + return false; +} + +/** + * 处理 Ollama 端点路由(在认证检查之后) + * @param {string} method - HTTP 方法 + * @param {string} path - 请求路径 + * @param {Object} req - 请求对象 + * @param {Object} res - 响应对象 + * @param {Object} apiService - API 服务实例 + * @param {Object} currentConfig - 当前配置 + * @param {Object} providerPoolManager - 提供商池管理器 + * @returns {boolean} - 是否已处理请求 + */ +export async function handleOllamaEndpointsAfterAuth(method, path, req, res, apiService, currentConfig, providerPoolManager) { + // Handle Ollama endpoints that need apiService (after auth check) + if (method === 'GET' && path === '/api/tags') { + await handleOllamaTags(req, res, apiService, currentConfig, providerPoolManager); + return true; + } + if (method === 'POST' && path === '/api/chat') { + await handleOllamaChat(req, res, apiService, currentConfig, providerPoolManager); + return true; + } + if (method === 'POST' && path === '/api/generate') { + await handleOllamaGenerate(req, res, apiService, currentConfig, providerPoolManager); + return true; + } + + return false; +} + +/** + * 处理 Ollama /api/tags 端点(列出模型) + */ +export async function handleOllamaTags(req, res, apiService, currentConfig, providerPoolManager) { + try { + console.log('[Ollama] Handling /api/tags request'); + + const ollamaConverter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OLLAMA); + + // Helper to fetch and convert models from a provider + const fetchProviderModels = async (providerType, service) => { + try { + const models = await service.listModels(); + const sourceProtocol = getProtocolPrefix(providerType); + const tags = ollamaConverter.convertModelList(models, sourceProtocol); + + if (tags.models && Array.isArray(tags.models)) { + return addPrefixToModels(tags.models, providerType, 'ollama'); + } + return []; + } catch (error) { + console.error(`[Ollama] Error from ${providerType}:`, error.message); + return []; + } + }; + + // Collect fetch promises + const fetchPromises = [fetchProviderModels(currentConfig.MODEL_PROVIDER, apiService)]; + + // Add provider pool fetches + if (providerPoolManager?.providerPools) { + const { getServiceAdapter } = await import('./adapter.js'); + + for (const [providerType, providers] of Object.entries(providerPoolManager.providerPools)) { + if (providerType === currentConfig.MODEL_PROVIDER) continue; + + const healthyProvider = providers.find(p => p.isHealthy); + if (healthyProvider) { + const tempConfig = { ...currentConfig, ...healthyProvider, MODEL_PROVIDER: providerType }; + const service = getServiceAdapter(tempConfig); + fetchPromises.push(fetchProviderModels(providerType, service)); + } + } + } + + // Execute all fetches in parallel + const results = await Promise.all(fetchPromises); + const allModels = results.flat(); + + const response = { models: allModels }; + + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Server': `ollama/${OLLAMA_VERSION}` + }); + res.end(JSON.stringify(response)); + } catch (error) { + console.error('[Ollama Tags Error]', error); + handleError(res, error); + } +} + +/** + * 处理 Ollama /api/show 端点(显示模型信息) + */ +export async function handleOllamaShow(req, res) { + try { + // console.log('[Ollama] Handling /api/show request'); + + const body = await getRequestBody(req); + const modelName = body.name || body.model || 'unknown'; + + const ollamaConverter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OLLAMA); + const showResponse = ollamaConverter.toOllamaShowResponse(modelName); + + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Server': `ollama/${OLLAMA_VERSION}` + }); + res.end(JSON.stringify(showResponse)); + } catch (error) { + console.error('[Ollama Show Error]', error); + handleError(res, error); + } +} + +/** + * 处理 Ollama /api/version 端点 + */ +export function handleOllamaVersion(res) { + try { + const response = { version: OLLAMA_VERSION }; + + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Server': `ollama/${OLLAMA_VERSION}` + }); + res.end(JSON.stringify(response)); + } catch (error) { + console.error('[Ollama Version Error]', error); + handleError(res, error); + } +} + +/** + * 处理 Ollama /api/chat 端点 + */ +export async function handleOllamaChat(req, res, apiService, currentConfig, providerPoolManager) { + try { + console.log('[Ollama] Handling /api/chat request'); + + const ollamaRequest = await getRequestBody(req); + + // Determine provider based on model name + const rawModelName = ollamaRequest.model; + const modelName = removeModelPrefix(rawModelName); + ollamaRequest.model = modelName; // Use clean model name + const detectedProvider = getProviderForModel(rawModelName, currentConfig.MODEL_PROVIDER); + + console.log(`[Ollama] Model: ${modelName}, Detected provider: ${detectedProvider}`); + + // If provider is different, get the appropriate service + let actualApiService = apiService; + let actualConfig = currentConfig; + + if (detectedProvider !== currentConfig.MODEL_PROVIDER && providerPoolManager) { + // Select provider from pool + const providerConfig = providerPoolManager.selectProvider(detectedProvider); + if (providerConfig) { + actualConfig = { + ...currentConfig, + ...providerConfig, + MODEL_PROVIDER: detectedProvider + }; + + // Get service adapter for the detected provider + const { getServiceAdapter } = await import('./adapter.js'); + actualApiService = getServiceAdapter(actualConfig); + console.log(`[Ollama] Switched to provider: ${detectedProvider}`); + } else { + console.warn(`[Ollama] No healthy provider found for ${detectedProvider}, using default`); + } + } + + // Convert Ollama request to OpenAI format + const ollamaConverter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OLLAMA); + const openaiRequest = ollamaConverter.convertRequest(ollamaRequest, MODEL_PROTOCOL_PREFIX.OPENAI); + + // Get the source protocol from the actual provider + const sourceProtocol = getProtocolPrefix(actualConfig.MODEL_PROVIDER); + + // Convert OpenAI format to backend provider format if needed + let backendRequest = openaiRequest; + if (sourceProtocol !== MODEL_PROTOCOL_PREFIX.OPENAI) { + backendRequest = convertData(openaiRequest, 'request', MODEL_PROTOCOL_PREFIX.OPENAI, sourceProtocol); + } + + // Handle streaming + if (ollamaRequest.stream) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked', + 'Access-Control-Allow-Origin': '*', + 'Server': `ollama/${OLLAMA_VERSION}` + }); + + const stream = await actualApiService.generateContentStream(openaiRequest.model, backendRequest); + + for await (const chunk of stream) { + try { + // Convert backend chunk to Ollama format + const ollamaChunk = ollamaConverter.convertStreamChunk(chunk, sourceProtocol, ollamaRequest.model, false); + res.write(JSON.stringify(ollamaChunk) + '\n'); + } catch (chunkError) { + console.error('[Ollama] Error processing chunk:', chunkError); + } + } + + // Send final chunk + const finalChunk = ollamaConverter.convertStreamChunk({}, sourceProtocol, ollamaRequest.model, true); + res.write(JSON.stringify(finalChunk) + '\n'); + res.end(); + } else { + // Non-streaming response + const backendResponse = await actualApiService.generateContent(openaiRequest.model, backendRequest); + const ollamaResponse = ollamaConverter.convertResponse(backendResponse, sourceProtocol, ollamaRequest.model); + + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Server': `ollama/${OLLAMA_VERSION}` + }); + res.end(JSON.stringify(ollamaResponse)); + } + } catch (error) { + console.error('[Ollama Chat Error]', error); + handleError(res, error); + } +} + +/** + * 处理 Ollama /api/generate 端点 + */ +export async function handleOllamaGenerate(req, res, apiService, currentConfig, providerPoolManager) { + try { + console.log('[Ollama] Handling /api/generate request'); + + const ollamaRequest = await getRequestBody(req); + + // Determine provider based on model name + const rawModelName = ollamaRequest.model; + const modelName = removeModelPrefix(rawModelName); + ollamaRequest.model = modelName; // Use clean model name + const detectedProvider = getProviderForModel(rawModelName, currentConfig.MODEL_PROVIDER); + + console.log(`[Ollama] Model: ${modelName}, Detected provider: ${detectedProvider}`); + + // If provider is different, get the appropriate service + let actualApiService = apiService; + let actualConfig = currentConfig; + + if (detectedProvider !== currentConfig.MODEL_PROVIDER && providerPoolManager) { + // Select provider from pool + const providerConfig = providerPoolManager.selectProvider(detectedProvider); + if (providerConfig) { + actualConfig = { + ...currentConfig, + ...providerConfig, + MODEL_PROVIDER: detectedProvider + }; + + // Get service adapter for the detected provider + const { getServiceAdapter } = await import('./adapter.js'); + actualApiService = getServiceAdapter(actualConfig); + console.log(`[Ollama] Switched to provider: ${detectedProvider}`); + } else { + console.warn(`[Ollama] No healthy provider found for ${detectedProvider}, using default`); + } + } + + // Convert Ollama request to OpenAI format + const ollamaConverter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OLLAMA); + const openaiRequest = ollamaConverter.convertRequest(ollamaRequest, MODEL_PROTOCOL_PREFIX.OPENAI); + + // Get the source protocol from the actual provider + const sourceProtocol = getProtocolPrefix(actualConfig.MODEL_PROVIDER); + + // Convert OpenAI format to backend provider format if needed + let backendRequest = openaiRequest; + if (sourceProtocol !== MODEL_PROTOCOL_PREFIX.OPENAI) { + backendRequest = convertData(openaiRequest, 'request', MODEL_PROTOCOL_PREFIX.OPENAI, sourceProtocol); + } + + // Handle streaming + if (ollamaRequest.stream) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked', + 'Access-Control-Allow-Origin': '*', + 'Server': `ollama/${OLLAMA_VERSION}` + }); + + const stream = await actualApiService.generateContentStream(openaiRequest.model, backendRequest); + + for await (const chunk of stream) { + try { + // Convert backend chunk to Ollama generate format + const ollamaChunk = ollamaConverter.toOllamaGenerateStreamChunk(chunk, ollamaRequest.model, false); + res.write(JSON.stringify(ollamaChunk) + '\n'); + } catch (chunkError) { + console.error('[Ollama] Error processing chunk:', chunkError); + } + } + + // Send final chunk + const finalChunk = ollamaConverter.toOllamaGenerateStreamChunk({}, ollamaRequest.model, true); + res.write(JSON.stringify(finalChunk) + '\n'); + res.end(); + } else { + // Non-streaming response + const backendResponse = await actualApiService.generateContent(openaiRequest.model, backendRequest); + const ollamaResponse = ollamaConverter.toOllamaGenerateResponse(backendResponse, ollamaRequest.model); + + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Server': `ollama/${OLLAMA_VERSION}` + }); + res.end(JSON.stringify(ollamaResponse)); + } + } catch (error) { + console.error('[Ollama Generate Error]', error); + handleError(res, error); + } +} + diff --git a/src/request-handler.js b/src/request-handler.js index aea628e..d972fd4 100644 --- a/src/request-handler.js +++ b/src/request-handler.js @@ -6,6 +6,8 @@ import { getApiService } from './service-manager.js'; import { getProviderPoolManager } from './service-manager.js'; import { MODEL_PROVIDER } from './common.js'; import { PROMPT_LOG_FILENAME } from './config-manager.js'; +import { handleOllamaRequest, handleOllamaShow } from './ollama-handler.js'; + /** * Main request handler. It authenticates the request, determines the endpoint type, * and delegates to the appropriate specialized handler function. @@ -40,6 +42,12 @@ export function createRequestHandler(config, providerPoolManager) { const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager); if (uiHandled) return; + // Ollama show endpoint with model name + if (method === 'POST' && path === '/ollama/api/show') { + await handleOllamaShow(req, res); + return true; + } + console.log(`\n${new Date().toLocaleString()}`); console.log(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`); // Handle API requests @@ -104,13 +112,22 @@ export function createRequestHandler(config, providerPoolManager) { } // Check authentication for API requests - if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) { + // Allow empty Bearer token (from Ollama clients like VS Code Copilot) + const authHeader = req.headers['authorization']; + const hasEmptyBearer = authHeader === 'Bearer' || authHeader === 'Bearer '; + + if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY) && !hasEmptyBearer) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } })); return; } try { + // Handle Ollama request (normalize path and route to appropriate endpoints) + const { handled, normalizedPath } = await handleOllamaRequest(method, path, requestUrl, req, res, apiService, currentConfig, providerPoolManager); + if (handled) return; + path = normalizedPath; + // Handle API requests const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME); if (apiHandled) return;