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

View file

@ -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 项目做出贡献的开发者:
<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 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**
>
> - **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:
<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

View file

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

View file

@ -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,

View file

@ -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);
}
// 自动注册所有转换器

View file

@ -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,
},
};
}
/**

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;
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;
}
/**

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 { 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;