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:
parent
7746e94154
commit
a435b137e7
11 changed files with 1537 additions and 82 deletions
48
README-JA.md
48
README-JA.md
|
|
@ -30,6 +30,7 @@
|
|||
>
|
||||
> **📅 バージョン更新ログ**
|
||||
>
|
||||
> - **2025.11.16** - Ollamaプロトコルサポートの追加、統一インターフェースでサポートされるすべてのモデルにアクセス
|
||||
> - **2025.11.11** - Web UI管理コントロールコンソールの追加、リアルタイム設定管理与健康状態モニタリングをサポート
|
||||
> - **2025.11.06** - Gemini 3 プレビュー版のサポートを追加、モデル互換性とパフォーマンス最適化を向上
|
||||
> - **2025.10.18** - Kiroオープン登録、新規アカウントに500クレジット付与、Claude Sonnet 4.5を完全サポート
|
||||
|
|
@ -267,6 +268,7 @@ install-and-run.bat
|
|||
|
||||
APIリクエストパスでプロバイダー識別子を指定して即座に切り替え:
|
||||
|
||||
|
||||
| ルートパス | 説明 | 使用ケース |
|
||||
|---------|------|---------|
|
||||
| `/claude-custom` | 設定ファイルのClaude APIを使用 | 公式Claude API呼び出し |
|
||||
|
|
@ -275,20 +277,51 @@ APIリクエストパスでプロバイダー識別子を指定して即座に
|
|||
| `/gemini-cli-oauth` | Gemini CLI OAuth経由でアクセス | Gemini無料制限の突破 |
|
||||
| `/openai-qwen-oauth` | Qwen OAuth経由でアクセス | Qwen Code Plusの使用 |
|
||||
| `/openaiResponses-custom` | OpenAI Responses API | 構造化対話シナリオ |
|
||||
|
||||
| `/ollama` | Ollama APIプロトコル | サポートされるすべてのモデルへの統一アクセス |
|
||||
|
||||
**使用例**:
|
||||
```bash
|
||||
# Cline、Kiloなどのプログラミングエージェントで設定
|
||||
API_ENDPOINT=http://localhost:3000/claude-kiro-oauth
|
||||
|
||||
|
||||
# 直接API呼び出し
|
||||
curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gemini-2.0-flash-exp","messages":[...]}'
|
||||
```
|
||||
|
||||
---
|
||||
#### Ollamaプロトコル使用例
|
||||
|
||||
本プロジェクトはOllamaプロトコルをサポートしており、統一インターフェースを通じてすべてのサポートモデルにアクセスできます。Ollamaエンドポイントは`/api/tags`、`/api/chat`、`/api/generate`などの標準インターフェースを提供します。
|
||||
|
||||
**Ollama API呼び出し例**:
|
||||
|
||||
1. **利用可能なすべてのモデルをリスト表示**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/tags
|
||||
```
|
||||
|
||||
2. **チャットインターフェース**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "[Claude] claude-sonnet-4.5",
|
||||
"messages": [
|
||||
{"role": "user", "content": "こんにちは"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
3. **モデルプレフィックスを使用してプロバイダーを指定**:
|
||||
- `[Kiro]` - Kiro APIを使用してClaudeモデルにアクセス
|
||||
- `[Claude]` - 公式Claude APIを使用
|
||||
- `[Gemini CLI]` - Gemini CLI OAuth経由でアクセス
|
||||
- `[OpenAI]` - 公式OpenAI APIを使用
|
||||
- `[Qwen CLI]` - Qwen OAuth経由でアクセス
|
||||
|
||||
---
|
||||
|
||||
### 📁 認証ファイル保存パス
|
||||
|
||||
各サービスの認証情報ファイルのデフォルト保存場所:
|
||||
|
|
@ -453,6 +486,15 @@ node src/api-server.js \
|
|||
## 🙏 謝辞
|
||||
|
||||
本プロジェクトの開発は公式Google Gemini CLIから大きなインスピレーションを受け、Cline 3.18.0版 `gemini-cli.ts` の一部のコード実装を参考にしました。ここにGoogle公式チームとCline開発チームの優れた仕事に心より感謝申し上げます!
|
||||
### 貢献者リスト
|
||||
|
||||
AIClient-2-APIプロジェクトに貢献してくれたすべての開発者に感謝します:
|
||||
|
||||
<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
|
||||
|
||||
|
|
|
|||
46
README-ZH.md
46
README-ZH.md
|
|
@ -30,6 +30,7 @@
|
|||
>
|
||||
> **📅 版本更新日志**
|
||||
>
|
||||
> - **2025.11.16** - 新增 Ollama 协议支持,统一接口访问所有支持的模型(Claude、Gemini、Qwen、OpenAI等)
|
||||
> - **2025.11.11** - 新增 Web UI 管理控制台,支持实时配置管理和健康状态监控
|
||||
> - **2025.11.06** - 新增对 Gemini 3 预览版的支持,增强模型兼容性和性能优化
|
||||
> - **2025.10.18** - Kiro 开放注册,新用户赠送 500 额度,已完整支持 Claude Sonnet 4.5
|
||||
|
|
@ -275,7 +276,8 @@ install-and-run.bat
|
|||
| `/gemini-cli-oauth` | 通过 Gemini CLI OAuth 访问 | 突破 Gemini 免费限制 |
|
||||
| `/openai-qwen-oauth` | 通过 Qwen OAuth 访问 | 使用 Qwen Code Plus |
|
||||
| `/openaiResponses-custom` | OpenAI Responses API | 结构化对话场景 |
|
||||
|
||||
| `/ollama` | Ollama API 协议 | 统一访问所有支持的模型 |
|
||||
|
||||
**使用示例**:
|
||||
```bash
|
||||
# 在 Cline、Kilo 等编程 Agent 中配置
|
||||
|
|
@ -287,6 +289,36 @@ curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \
|
|||
-d '{"model":"gemini-2.0-flash-exp","messages":[...]}'
|
||||
```
|
||||
|
||||
### 🦙 Ollama 协议使用示例
|
||||
|
||||
本项目支持 Ollama 协议,可以通过统一接口访问所有支持的模型。Ollama 端点提供 `/api/tags`、`/api/chat`、`/api/generate` 等标准接口。
|
||||
|
||||
**Ollama API 调用示例**:
|
||||
|
||||
1. **列出所有可用模型**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/tags
|
||||
```
|
||||
|
||||
2. **聊天接口**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "[Claude] claude-sonnet-4.5",
|
||||
"messages": [
|
||||
{"role": "user", "content": "你好"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
3. **使用模型前缀指定提供商**:
|
||||
- `[Kiro]` - 使用 Kiro API 访问 Claude 模型
|
||||
- `[Claude]` - 使用 Claude 官方 API
|
||||
- `[Gemini CLI]` - 通过 Gemini CLI OAuth 访问
|
||||
- `[OpenAI]` - 使用 OpenAI 官方 API
|
||||
- `[Qwen CLI]` - 通过 Qwen OAuth 访问
|
||||
|
||||
---
|
||||
|
||||
### 📁 授权文件存储路径
|
||||
|
|
@ -454,13 +486,23 @@ node src/api-server.js \
|
|||
## 📄 开源许可
|
||||
|
||||
本项目遵循 [**GNU General Public License v3 (GPLv3)**](https://www.gnu.org/licenses/gpl-3.0) 开源许可。详情请查看根目录下的 `LICENSE` 文件。
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
本项目的开发受到了官方 Google Gemini CLI 的极大启发,并参考了Cline 3.18.0 版本 `gemini-cli.ts` 的部分代码实现。在此对 Google 官方团队和 Cline 开发团队的卓越工作表示衷心的感谢!
|
||||
### 贡献者列表
|
||||
|
||||
感谢以下所有为 AIClient-2-API 项目做出贡献的开发者:
|
||||
|
||||
<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
|
||||
|
||||
|
||||
[](https://www.star-history.com/#justlovemaki/AIClient-2-API&Timeline)
|
||||
|
||||
---
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -30,6 +30,7 @@
|
|||
>
|
||||
> **📅 Version Update Log**
|
||||
>
|
||||
> - **2025.11.16** - Added Ollama protocol support, unified interface to access all supported models (Claude, Gemini, Qwen, OpenAI, etc.)
|
||||
> - **2025.11.11** - Added Web UI management console, supporting real-time configuration management and health status monitoring
|
||||
> - **2025.11.06** - Added support for Gemini 3 Preview, enhanced model compatibility and performance optimization
|
||||
> - **2025.10.18** - Kiro open registration, new accounts get 500 credits, full support for Claude Sonnet 4.5
|
||||
|
|
@ -266,6 +267,7 @@ This project provides two flexible model switching methods to meet different usa
|
|||
|
||||
Achieve instant switching by specifying provider identifier in API request path:
|
||||
|
||||
|
||||
| Route Path | Description | Use Case |
|
||||
|---------|------|---------|
|
||||
| `/claude-custom` | Use Claude API from config file | Official Claude API calls |
|
||||
|
|
@ -274,20 +276,51 @@ Achieve instant switching by specifying provider identifier in API request path:
|
|||
| `/gemini-cli-oauth` | Access via Gemini CLI OAuth | Break through Gemini free limits |
|
||||
| `/openai-qwen-oauth` | Access via Qwen OAuth | Use Qwen Code Plus |
|
||||
| `/openaiResponses-custom` | OpenAI Responses API | Structured dialogue scenarios |
|
||||
|
||||
| `/ollama` | Ollama API protocol | Unified access to all supported models |
|
||||
|
||||
**Usage Examples**:
|
||||
```bash
|
||||
# Configure in programming agents like Cline, Kilo
|
||||
API_ENDPOINT=http://localhost:3000/claude-kiro-oauth
|
||||
|
||||
|
||||
# Direct API call
|
||||
curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gemini-2.0-flash-exp","messages":[...]}'
|
||||
```
|
||||
|
||||
---
|
||||
### Ollama Protocol Usage Examples
|
||||
|
||||
This project supports the Ollama protocol, allowing access to all supported models through a unified interface. The Ollama endpoint provides standard interfaces such as `/api/tags`, `/api/chat`, `/api/generate`, etc.
|
||||
|
||||
**Ollama API Call Examples**:
|
||||
|
||||
1. **List all available models**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/tags
|
||||
```
|
||||
|
||||
2. **Chat interface**:
|
||||
```bash
|
||||
curl http://localhost:3000/ollama/api/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "[Claude] claude-sonnet-4.5",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
3. **Specify provider using model prefix**:
|
||||
- `[Kiro]` - Access Claude models using Kiro API
|
||||
- `[Claude]` - Use official Claude API
|
||||
- `[Gemini CLI]` - Access via Gemini CLI OAuth
|
||||
- `[OpenAI]` - Use official OpenAI API
|
||||
- `[Qwen CLI]` - Access via Qwen OAuth
|
||||
|
||||
---
|
||||
|
||||
### 📁 Authorization File Storage Paths
|
||||
|
||||
Default storage locations for authorization credential files of each service:
|
||||
|
|
@ -453,6 +486,16 @@ This project operates under the [**GNU General Public License v3 (GPLv3)**](http
|
|||
## 🙏 Acknowledgements
|
||||
|
||||
The development of this project was significantly inspired by the official Google Gemini CLI and incorporated some code implementations from Cline 3.18.0's `gemini-cli.ts`. We extend our sincere gratitude to the official Google team and the Cline development team for their exceptional work!
|
||||
### Contributor List
|
||||
|
||||
Thanks to all the developers who contributed to the AIClient-2-API project:
|
||||
|
||||
<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// 自动注册所有转换器
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
582
src/converters/strategies/OllamaConverter.js
Normal file
582
src/converters/strategies/OllamaConverter.js
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
676
src/ollama-handler.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue