feat(ollama): 新增Ollama协议支持,统一接口访问所有支持的模型

- 添加OllamaConverter处理Ollama协议与其他协议的转换
- 实现Ollama处理器处理Ollama特定端点
- 支持Ollama API标准接口如/api/tags、/api/chat、/api/generate
- 更新README文档添加Ollama使用说明和示例
- 优化模型前缀处理,支持通过前缀指定不同提供商
- 改进认证处理,允许空Bearer token以兼容VS Code Copilot等客户端
This commit is contained in:
hex2077 2025-11-16 21:35:03 +08:00
parent 7746e94154
commit a435b137e7
11 changed files with 1537 additions and 82 deletions

View file

@ -30,6 +30,7 @@
> >
> **📅 バージョン更新ログ** > **📅 バージョン更新ログ**
> >
> - **2025.11.16** - Ollamaプロトコルサポートの追加、統一インターフェースでサポートされるすべてのモデルにアクセス
> - **2025.11.11** - Web UI管理コントロールコンソールの追加、リアルタイム設定管理与健康状態モニタリングをサポート > - **2025.11.11** - Web UI管理コントロールコンソールの追加、リアルタイム設定管理与健康状態モニタリングをサポート
> - **2025.11.06** - Gemini 3 プレビュー版のサポートを追加、モデル互換性とパフォーマンス最適化を向上 > - **2025.11.06** - Gemini 3 プレビュー版のサポートを追加、モデル互換性とパフォーマンス最適化を向上
> - **2025.10.18** - Kiroオープン登録、新規アカウントに500クレジット付与、Claude Sonnet 4.5を完全サポート > - **2025.10.18** - Kiroオープン登録、新規アカウントに500クレジット付与、Claude Sonnet 4.5を完全サポート
@ -267,6 +268,7 @@ install-and-run.bat
APIリクエストパスでプロバイダー識別子を指定して即座に切り替え APIリクエストパスでプロバイダー識別子を指定して即座に切り替え
| ルートパス | 説明 | 使用ケース | | ルートパス | 説明 | 使用ケース |
|---------|------|---------| |---------|------|---------|
| `/claude-custom` | 設定ファイルのClaude APIを使用 | 公式Claude API呼び出し | | `/claude-custom` | 設定ファイルのClaude APIを使用 | 公式Claude API呼び出し |
@ -275,20 +277,51 @@ APIリクエストパスでプロバイダー識別子を指定して即座に
| `/gemini-cli-oauth` | Gemini CLI OAuth経由でアクセス | Gemini無料制限の突破 | | `/gemini-cli-oauth` | Gemini CLI OAuth経由でアクセス | Gemini無料制限の突破 |
| `/openai-qwen-oauth` | Qwen OAuth経由でアクセス | Qwen Code Plusの使用 | | `/openai-qwen-oauth` | Qwen OAuth経由でアクセス | Qwen Code Plusの使用 |
| `/openaiResponses-custom` | OpenAI Responses API | 構造化対話シナリオ | | `/openaiResponses-custom` | OpenAI Responses API | 構造化対話シナリオ |
| `/ollama` | Ollama APIプロトコル | サポートされるすべてのモデルへの統一アクセス |
**使用例** **使用例**
```bash ```bash
# Cline、Kiloなどのプログラミングエージェントで設定 # Cline、Kiloなどのプログラミングエージェントで設定
API_ENDPOINT=http://localhost:3000/claude-kiro-oauth API_ENDPOINT=http://localhost:3000/claude-kiro-oauth
# 直接API呼び出し # 直接API呼び出し
curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \ curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"model":"gemini-2.0-flash-exp","messages":[...]}' -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開発チームの優れた仕事に心より感謝申し上げます 本プロジェクトの開発は公式Google Gemini CLIから大きなインスピレーションを受け、Cline 3.18.0版 `gemini-cli.ts` の一部のコード実装を参考にしました。ここにGoogle公式チームとCline開発チームの優れた仕事に心より感謝申し上げます
### 貢献者リスト
AIClient-2-APIプロジェクトに貢献してくれたすべての開発者に感謝します
<div align="left">
[<img src="https://avatars.githubusercontent.com/u/12859173?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="justlikemaki"/>](https://github.com/justlikemaki)[<img src="https://avatars.githubusercontent.com/u/22633385?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="eltociear"/>](https://github.com/eltociear)[<img src="https://avatars.githubusercontent.com/u/26056971?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="LaelLuo"/>](https://github.com/LaelLuo)[<img src="https://avatars.githubusercontent.com/u/24641689?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="d7185540"/>](https://github.com/d7185540)[<img src="https://avatars.githubusercontent.com/u/122232211?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="bee4come"/>](https://github.com/bee4come)[<img src="https://avatars.githubusercontent.com/u/121296348?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="HALDRO"/>](https://github.com/HALDRO)
</div>
## 🌟 Star History ## 🌟 Star History

View file

@ -30,6 +30,7 @@
> >
> **📅 版本更新日志** > **📅 版本更新日志**
> >
> - **2025.11.16** - 新增 Ollama 协议支持统一接口访问所有支持的模型Claude、Gemini、Qwen、OpenAI等
> - **2025.11.11** - 新增 Web UI 管理控制台,支持实时配置管理和健康状态监控 > - **2025.11.11** - 新增 Web UI 管理控制台,支持实时配置管理和健康状态监控
> - **2025.11.06** - 新增对 Gemini 3 预览版的支持,增强模型兼容性和性能优化 > - **2025.11.06** - 新增对 Gemini 3 预览版的支持,增强模型兼容性和性能优化
> - **2025.10.18** - Kiro 开放注册,新用户赠送 500 额度,已完整支持 Claude Sonnet 4.5 > - **2025.10.18** - Kiro 开放注册,新用户赠送 500 额度,已完整支持 Claude Sonnet 4.5
@ -275,7 +276,8 @@ install-and-run.bat
| `/gemini-cli-oauth` | 通过 Gemini CLI OAuth 访问 | 突破 Gemini 免费限制 | | `/gemini-cli-oauth` | 通过 Gemini CLI OAuth 访问 | 突破 Gemini 免费限制 |
| `/openai-qwen-oauth` | 通过 Qwen OAuth 访问 | 使用 Qwen Code Plus | | `/openai-qwen-oauth` | 通过 Qwen OAuth 访问 | 使用 Qwen Code Plus |
| `/openaiResponses-custom` | OpenAI Responses API | 结构化对话场景 | | `/openaiResponses-custom` | OpenAI Responses API | 结构化对话场景 |
| `/ollama` | Ollama API 协议 | 统一访问所有支持的模型 |
**使用示例** **使用示例**
```bash ```bash
# 在 Cline、Kilo 等编程 Agent 中配置 # 在 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":[...]}' -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` 文件。 本项目遵循 [**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 开发团队的卓越工作表示衷心的感谢! 本项目的开发受到了官方 Google Gemini CLI 的极大启发并参考了Cline 3.18.0 版本 `gemini-cli.ts` 的部分代码实现。在此对 Google 官方团队和 Cline 开发团队的卓越工作表示衷心的感谢!
### 贡献者列表
感谢以下所有为 AIClient-2-API 项目做出贡献的开发者:
<div align="left">
[<img src="https://avatars.githubusercontent.com/u/12859173?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="justlikemaki"/>](https://github.com/justlikemaki)[<img src="https://avatars.githubusercontent.com/u/22633385?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="eltociear"/>](https://github.com/eltociear)[<img src="https://avatars.githubusercontent.com/u/26056971?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="LaelLuo"/>](https://github.com/LaelLuo)[<img src="https://avatars.githubusercontent.com/u/24641689?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="d7185540"/>](https://github.com/d7185540)[<img src="https://avatars.githubusercontent.com/u/122232211?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="bee4come"/>](https://github.com/bee4come)[<img src="https://avatars.githubusercontent.com/u/121296348?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="HALDRO"/>](https://github.com/HALDRO)
</div>
## 🌟 Star History ## 🌟 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) [![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)
--- ---

View file

@ -30,6 +30,7 @@
> >
> **📅 Version Update Log** > **📅 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.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.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 > - **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: Achieve instant switching by specifying provider identifier in API request path:
| Route Path | Description | Use Case | | Route Path | Description | Use Case |
|---------|------|---------| |---------|------|---------|
| `/claude-custom` | Use Claude API from config file | Official Claude API calls | | `/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 | | `/gemini-cli-oauth` | Access via Gemini CLI OAuth | Break through Gemini free limits |
| `/openai-qwen-oauth` | Access via Qwen OAuth | Use Qwen Code Plus | | `/openai-qwen-oauth` | Access via Qwen OAuth | Use Qwen Code Plus |
| `/openaiResponses-custom` | OpenAI Responses API | Structured dialogue scenarios | | `/openaiResponses-custom` | OpenAI Responses API | Structured dialogue scenarios |
| `/ollama` | Ollama API protocol | Unified access to all supported models |
**Usage Examples**: **Usage Examples**:
```bash ```bash
# Configure in programming agents like Cline, Kilo # Configure in programming agents like Cline, Kilo
API_ENDPOINT=http://localhost:3000/claude-kiro-oauth API_ENDPOINT=http://localhost:3000/claude-kiro-oauth
# Direct API call # Direct API call
curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \ curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"model":"gemini-2.0-flash-exp","messages":[...]}' -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 ### 📁 Authorization File Storage Paths
Default storage locations for authorization credential files of each service: 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 ## 🙏 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! 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:
<div align="left">
[<img src="https://avatars.githubusercontent.com/u/12859173?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="justlikemaki"/>](https://github.com/justlikemaki)[<img src="https://avatars.githubusercontent.com/u/22633385?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="eltociear"/>](https://github.com/eltociear)[<img src="https://avatars.githubusercontent.com/u/26056971?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="LaelLuo"/>](https://github.com/LaelLuo)[<img src="https://avatars.githubusercontent.com/u/24641689?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="d7185540"/>](https://github.com/d7185540)[<img src="https://avatars.githubusercontent.com/u/122232211?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="bee4come"/>](https://github.com/bee4come)[<img src="https://avatars.githubusercontent.com/u/121296348?v=4" width="50px" style="border-radius: 50%; margin: 5px;" alt="HALDRO"/>](https://github.com/HALDRO)
</div>
## 🌟 Star History ## 🌟 Star History

View file

@ -17,6 +17,7 @@ export const MODEL_PROTOCOL_PREFIX = {
OPENAI: 'openai', OPENAI: 'openai',
OPENAI_RESPONSES: 'openaiResponses', OPENAI_RESPONSES: 'openaiResponses',
CLAUDE: 'claude', CLAUDE: 'claude',
OLLAMA: 'ollama',
} }
export const MODEL_PROVIDER = { 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. // 1. Get the model list in the backend's native format.
const nativeModelList = await service.listModels(); const nativeModelList = await service.listModels();
// 2. Convert the model list to the client's expected format, if necessary. // 2. Convert the model list to the client's expected format, if necessary.
let clientModelList = nativeModelList; let clientModelList = nativeModelList;
if (!getProtocolPrefix(toProvider).includes(getProtocolPrefix(fromProvider))) { if (!getProtocolPrefix(toProvider).includes(getProtocolPrefix(fromProvider))) {
@ -375,7 +376,7 @@ export async function handleContentGenerationRequest(req, res, service, endpoint
const fromProvider = clientProviderMap[endpointType]; const fromProvider = clientProviderMap[endpointType];
const toProvider = CONFIG.MODEL_PROVIDER; const toProvider = CONFIG.MODEL_PROVIDER;
if (!fromProvider) { if (!fromProvider) {
throw new Error(`Unsupported endpoint type for content generation: ${endpointType}`); throw new Error(`Unsupported endpoint type for content generation: ${endpointType}`);
} }

View file

@ -7,8 +7,7 @@
*/ */
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { MODEL_PROTOCOL_PREFIX } from './common.js'; import { MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from './common.js';
import { getProtocolPrefix } from './common.js';
import { ConverterFactory } from './converters/ConverterFactory.js'; import { ConverterFactory } from './converters/ConverterFactory.js';
import { import {
generateResponseCreated, generateResponseCreated,

View file

@ -9,6 +9,7 @@ import { OpenAIConverter } from './strategies/OpenAIConverter.js';
import { OpenAIResponsesConverter } from './strategies/OpenAIResponsesConverter.js'; import { OpenAIResponsesConverter } from './strategies/OpenAIResponsesConverter.js';
import { ClaudeConverter } from './strategies/ClaudeConverter.js'; import { ClaudeConverter } from './strategies/ClaudeConverter.js';
import { GeminiConverter } from './strategies/GeminiConverter.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.OPENAI_RESPONSES, OpenAIResponsesConverter);
ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.CLAUDE, ClaudeConverter); ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.CLAUDE, ClaudeConverter);
ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.GEMINI, GeminiConverter); ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.GEMINI, GeminiConverter);
ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.OLLAMA, OllamaConverter);
} }
// 自动注册所有转换器 // 自动注册所有转换器

View file

@ -174,75 +174,73 @@ export class GeminiConverter extends BaseConverter {
toOpenAIStreamChunk(geminiChunk, model) { toOpenAIStreamChunk(geminiChunk, model) {
if (!geminiChunk) return null; if (!geminiChunk) return null;
// 处理完整的Gemini chunk对象 const candidate = geminiChunk.candidates?.[0];
if (typeof geminiChunk === 'object' && !Array.isArray(geminiChunk)) { if (!candidate) return null;
const candidate = geminiChunk.candidates?.[0];
let content = '';
// 提取文本内容 const toolCalls = [];
let content = '';
let finishReason = null; // 从parts中提取文本和tool calls
const parts = candidate.content?.parts;
if (candidate) { if (parts && Array.isArray(parts)) {
// 从parts中提取文本 for (const part of parts) {
const parts = candidate.content?.parts; if (part.text) {
if (parts && Array.isArray(parts)) { content += part.text;
content = parts
.filter(part => part && typeof part.text === 'string')
.map(part => part.text)
.join('');
} }
if (part.functionCall) {
// 处理finishReason toolCalls.push({
if (candidate.finishReason) { id: part.functionCall.id || `call_${uuidv4()}`,
finishReason = candidate.finishReason === 'STOP' ? 'stop' : type: 'function',
candidate.finishReason === 'MAX_TOKENS' ? 'length' : function: {
candidate.finishReason.toLowerCase(); 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,
},
};
} }
// 向后兼容:处理字符串格式 // 处理finishReason
if (typeof geminiChunk === 'string') { let finishReason = null;
return { if (candidate.finishReason) {
id: `chatcmpl-${uuidv4()}`, finishReason = candidate.finishReason === 'STOP' ? 'stop' :
object: "chat.completion.chunk", candidate.finishReason === 'MAX_TOKENS' ? 'length' :
created: Math.floor(Date.now() / 1000), candidate.finishReason.toLowerCase();
model: model,
choices: [{
index: 0,
delta: { content: geminiChunk },
finish_reason: null,
}],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
},
};
} }
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,
},
};
} }
/** /**

View file

@ -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
};
}
}

View file

@ -533,13 +533,41 @@ export class OpenAIConverter extends BaseConverter {
const geminiRole = message.role === 'assistant' ? 'model' : message.role; const geminiRole = message.role === 'assistant' ? 'model' : message.role;
if (geminiRole === 'tool') { 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({ processedMessages.push({
role: 'function', role: 'user',
parts: [{ parts: [{
functionResponse: { functionResponse: {
name: message.name, name: functionName || 'unknown',
response: { content: safeParseJSON(message.content) } response: {
name: functionName || 'unknown',
content: contentStr
}
} }
}] }]
}); });
@ -547,7 +575,21 @@ export class OpenAIConverter extends BaseConverter {
continue; 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 && if (lastMessage && lastMessage.role === geminiRole && !message.tool_calls &&
Array.isArray(processedContent) && processedContent.every(p => p.text) && Array.isArray(processedContent) && processedContent.every(p => p.text) &&
@ -589,7 +631,7 @@ export class OpenAIConverter extends BaseConverter {
geminiRequest.toolConfig = this.buildGeminiToolConfig(openaiRequest.tool_choice); 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; if (Object.keys(config).length) geminiRequest.generationConfig = config;
return geminiRequest; return geminiRequest;
@ -649,12 +691,23 @@ export class OpenAIConverter extends BaseConverter {
/** /**
* 构建Gemini生成配置 * 构建Gemini生成配置
*/ */
buildGeminiGenerationConfig({ temperature, max_tokens, top_p, stop }) { buildGeminiGenerationConfig({ temperature, max_tokens, top_p, stop, tools }, model) {
const config = {}; const config = {};
config.temperature = checkAndAssignOrDefault(temperature, 1); config.temperature = checkAndAssignOrDefault(temperature, 1);
config.maxOutputTokens = checkAndAssignOrDefault(max_tokens, 65535); config.maxOutputTokens = checkAndAssignOrDefault(max_tokens, 65535);
config.topP = checkAndAssignOrDefault(top_p, 0.95); config.topP = checkAndAssignOrDefault(top_p, 0.95);
if (stop !== undefined) config.stopSequences = Array.isArray(stop) ? stop : [stop]; 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; return config;
} }
/** /**

676
src/ollama-handler.js Normal file
View file

@ -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);
}
}

View file

@ -6,6 +6,8 @@ import { getApiService } from './service-manager.js';
import { getProviderPoolManager } from './service-manager.js'; import { getProviderPoolManager } from './service-manager.js';
import { MODEL_PROVIDER } from './common.js'; import { MODEL_PROVIDER } from './common.js';
import { PROMPT_LOG_FILENAME } from './config-manager.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, * Main request handler. It authenticates the request, determines the endpoint type,
* and delegates to the appropriate specialized handler function. * 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); const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
if (uiHandled) return; 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(`\n${new Date().toLocaleString()}`);
console.log(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`); console.log(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`);
// Handle API requests // Handle API requests
@ -104,13 +112,22 @@ export function createRequestHandler(config, providerPoolManager) {
} }
// Check authentication for API requests // 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.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } })); res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
return; return;
} }
try { 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 // Handle API requests
const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME); const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME);
if (apiHandled) return; if (apiHandled) return;