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プロジェクトに貢献してくれたすべての開発者に感謝します:
+
+
+
+[

](https://github.com/justlikemaki)[

](https://github.com/eltociear)[

](https://github.com/LaelLuo)[

](https://github.com/d7185540)[

](https://github.com/bee4come)[

](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 项目做出贡献的开发者:
+
+
+
+[

](https://github.com/justlikemaki)[

](https://github.com/eltociear)[

](https://github.com/LaelLuo)[

](https://github.com/d7185540)[

](https://github.com/bee4come)[

](https://github.com/HALDRO)
+
+
+
## 🌟 Star History
+
[](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:
+
+
+
+[

](https://github.com/justlikemaki)[

](https://github.com/eltociear)[

](https://github.com/LaelLuo)[

](https://github.com/d7185540)[

](https://github.com/bee4come)[

](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;