mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 14:35:22 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e01ef2f51a | |||
| 3434dcb87c | |||
| dc8a70b14e | |||
| 4ee45d2bc3 | |||
| be246f8596 | |||
| 6046c166cc | |||
| fe4a968eef | |||
| 55301abc00 | |||
| 1c6689a0bc | |||
| 9404341f14 | |||
| 53dd0a90f3 | |||
| 779fefd86d | |||
| 096c1d57c4 | |||
| adb868d0cf | |||
| a6ad896db0 | |||
| d6cf4390ac |
@@ -9,6 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [3.10.2] - 2026-01-24
|
||||||
|
|
||||||
|
### Patch Release
|
||||||
|
|
||||||
|
This maintenance release adds skill sync options and includes important bug fixes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Skills**: Add skill sync method setting with symlink/copy options
|
||||||
|
- **Partners**: Add RightCode as official partner
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Prompts**: Clear prompt file when all prompts are disabled
|
||||||
|
- **OpenCode**: Preserve extra model fields during serialization
|
||||||
|
- **Provider Form**: Backfill model fields when editing Claude provider
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.10.1] - 2026-01-23
|
## [3.10.1] - 2026-01-23
|
||||||
|
|
||||||
### Patch Release
|
### Patch Release
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
# All-in-One Assistant for Claude Code, Codex & Gemini CLI
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -52,7 +52,7 @@ This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Current Version: v3.10.0 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
### Current Version: v3.10.2 | [Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-note-v3.9.0-en.md)
|
||||||
|
|
||||||
**v3.8.0 Major Update (2025-11-28)**
|
**v3.8.0 Major Update (2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
# Claude Code / Codex / Gemini CLI オールインワン・アシスタント
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
## 特長
|
## 特長
|
||||||
|
|
||||||
### 現在のバージョン:v3.10.0 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
### 現在のバージョン:v3.10.2 | [完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-note-v3.9.0-ja.md)
|
||||||
|
|
||||||
**v3.8.0 メジャーアップデート (2025-11-28)**
|
**v3.8.0 メジャーアップデート (2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
# Claude Code / Codex / Gemini CLI 全方位辅助工具
|
||||||
|
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/farion1231/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 当前版本:v3.10.0 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
### 当前版本:v3.10.2 | [完整更新日志](CHANGELOG.md) | [发布说明](docs/release-note-v3.9.0-zh.md)
|
||||||
|
|
||||||
**v3.8.0 重大更新(2025-11-28)**
|
**v3.8.0 重大更新(2025-11-28)**
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.10.1",
|
"version": "3.10.2",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -701,7 +701,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.10.1"
|
version = "3.10.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.10.1"
|
version = "3.10.2"
|
||||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -191,6 +191,23 @@ pub struct ProviderProxyConfig {
|
|||||||
pub proxy_password: Option<String>,
|
pub proxy_password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct FormatTransformConfig {
|
||||||
|
/// 是否启用格式转换
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 源格式:anthropic, openai, gemini
|
||||||
|
#[serde(rename = "sourceFormat", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_format: Option<String>,
|
||||||
|
/// 目标格式:anthropic, openai, gemini
|
||||||
|
#[serde(rename = "targetFormat", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub target_format: Option<String>,
|
||||||
|
/// 是否转换流式响应(默认 true)
|
||||||
|
#[serde(rename = "transformStreaming", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub transform_streaming: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 供应商元数据
|
/// 供应商元数据
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderMeta {
|
pub struct ProviderMeta {
|
||||||
@@ -227,6 +244,9 @@ pub struct ProviderMeta {
|
|||||||
/// 供应商单独的代理配置
|
/// 供应商单独的代理配置
|
||||||
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
|
||||||
pub proxy_config: Option<ProviderProxyConfig>,
|
pub proxy_config: Option<ProviderProxyConfig>,
|
||||||
|
/// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
|
||||||
|
#[serde(rename = "formatTransform", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub format_transform: Option<FormatTransformConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
@@ -596,6 +616,11 @@ pub struct OpenCodeModel {
|
|||||||
/// 模型额外选项(provider 路由等)
|
/// 模型额外选项(provider 路由等)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub options: Option<HashMap<String, Value>>,
|
pub options: Option<HashMap<String, Value>>,
|
||||||
|
|
||||||
|
/// 额外字段(cost、modalities、thinking、variants 等)
|
||||||
|
/// 使用 flatten 捕获所有未明确定义的字段
|
||||||
|
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub extra: HashMap<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OpenCode 模型限制
|
/// OpenCode 模型限制
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use super::{
|
|||||||
provider_router::ProviderRouter,
|
provider_router::ProviderRouter,
|
||||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||||
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||||
|
transform::{get_transformer, TransformConfig},
|
||||||
types::{ProxyStatus, RectifierConfig},
|
types::{ProxyStatus, RectifierConfig},
|
||||||
ProxyError,
|
ProxyError,
|
||||||
};
|
};
|
||||||
@@ -558,26 +559,63 @@ impl RequestForwarder {
|
|||||||
// 使用适配器提取 base_url
|
// 使用适配器提取 base_url
|
||||||
let base_url = adapter.extract_base_url(provider)?;
|
let base_url = adapter.extract_base_url(provider)?;
|
||||||
|
|
||||||
// 检查是否需要格式转换
|
// 获取格式转换配置
|
||||||
let needs_transform = adapter.needs_transform(provider);
|
let transform_config = TransformConfig::from_provider(provider);
|
||||||
|
let needs_transform = transform_config.needs_transform();
|
||||||
|
|
||||||
let effective_endpoint =
|
// 如果需要转换但找不到转换器,直接返回错误(避免静默透传后在响应阶段失败)
|
||||||
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
|
let transformer = if needs_transform {
|
||||||
"/v1/chat/completions"
|
let t = get_transformer(
|
||||||
} else {
|
transform_config.source_format,
|
||||||
endpoint
|
transform_config.target_format,
|
||||||
};
|
);
|
||||||
|
if t.is_none() {
|
||||||
|
log::error!(
|
||||||
|
"[Forwarder] 格式转换已启用但找不到转换器: {:?} → {:?}",
|
||||||
|
transform_config.source_format,
|
||||||
|
transform_config.target_format
|
||||||
|
);
|
||||||
|
return Err(ProxyError::TransformError(format!(
|
||||||
|
"No transformer registered for {:?} → {:?}. Please disable format transform or use supported formats (Anthropic ↔ OpenAI).",
|
||||||
|
transform_config.source_format,
|
||||||
|
transform_config.target_format
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
t
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确定有效端点
|
||||||
|
let effective_endpoint = if let Some(ref t) = transformer {
|
||||||
|
t.transform_endpoint(endpoint)
|
||||||
|
} else {
|
||||||
|
endpoint.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
// 使用适配器构建 URL
|
// 使用适配器构建 URL
|
||||||
let url = adapter.build_url(&base_url, effective_endpoint);
|
let url = adapter.build_url(&base_url, &effective_endpoint);
|
||||||
|
|
||||||
// 应用模型映射(独立于格式转换)
|
// 应用模型映射(独立于格式转换)
|
||||||
let (mapped_body, _original_model, _mapped_model) =
|
let (mut mapped_body, _original_model, _mapped_model) =
|
||||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||||
|
|
||||||
|
// 如果启用格式转换但禁用流式转换,强制将 stream 设为 false
|
||||||
|
// 避免上游返回 SSE 流但我们无法转换的情况
|
||||||
|
if needs_transform && !transform_config.transform_streaming {
|
||||||
|
if let Some(stream_val) = mapped_body.get("stream") {
|
||||||
|
if stream_val.as_bool() == Some(true) {
|
||||||
|
log::info!("[Forwarder] transform_streaming=false,强制将 stream 设为 false");
|
||||||
|
if let Some(obj) = mapped_body.as_object_mut() {
|
||||||
|
obj.insert("stream".to_string(), serde_json::Value::Bool(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 转换请求体(如果需要)
|
// 转换请求体(如果需要)
|
||||||
let request_body = if needs_transform {
|
let request_body = if let Some(ref t) = transformer {
|
||||||
adapter.transform_request(mapped_body, provider)?
|
t.transform_request(mapped_body)?
|
||||||
} else {
|
} else {
|
||||||
mapped_body
|
mapped_body
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use super::{
|
|||||||
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||||
},
|
},
|
||||||
handler_context::RequestContext,
|
handler_context::RequestContext,
|
||||||
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
|
|
||||||
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
||||||
server::ProxyState,
|
server::ProxyState,
|
||||||
|
transform::{get_transformer, TransformConfig},
|
||||||
types::*,
|
types::*,
|
||||||
usage::parser::TokenUsage,
|
usage::parser::TokenUsage,
|
||||||
ProxyError,
|
ProxyError,
|
||||||
@@ -94,13 +94,20 @@ pub async fn handle_messages(
|
|||||||
ctx.provider = result.provider;
|
ctx.provider = result.provider;
|
||||||
let response = result.response;
|
let response = result.response;
|
||||||
|
|
||||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
// 检查是否需要格式转换(通过 Provider 配置)
|
||||||
let adapter = get_adapter(&AppType::Claude);
|
let transform_config = TransformConfig::from_provider(&ctx.provider);
|
||||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
|
||||||
|
|
||||||
// Claude 特有:格式转换处理
|
// Claude 特有:格式转换处理
|
||||||
if needs_transform {
|
if transform_config.needs_transform() {
|
||||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
return handle_claude_transform(
|
||||||
|
response,
|
||||||
|
&ctx,
|
||||||
|
&state,
|
||||||
|
&body,
|
||||||
|
is_stream,
|
||||||
|
&transform_config,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通用响应处理(透传模式)
|
// 通用响应处理(透传模式)
|
||||||
@@ -116,13 +123,26 @@ async fn handle_claude_transform(
|
|||||||
state: &ProxyState,
|
state: &ProxyState,
|
||||||
_original_body: &Value,
|
_original_body: &Value,
|
||||||
is_stream: bool,
|
is_stream: bool,
|
||||||
|
transform_config: &TransformConfig,
|
||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
|
||||||
if is_stream {
|
// 获取响应转换器(OpenAI → Anthropic)
|
||||||
|
let response_transformer = get_transformer(
|
||||||
|
transform_config.target_format,
|
||||||
|
transform_config.source_format,
|
||||||
|
)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::TransformError(format!(
|
||||||
|
"No transformer for {:?} → {:?}",
|
||||||
|
transform_config.target_format, transform_config.source_format
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if is_stream && transform_config.transform_streaming {
|
||||||
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
||||||
let stream = response.bytes_stream();
|
let stream = response.bytes_stream();
|
||||||
let sse_stream = create_anthropic_sse_stream(stream);
|
let sse_stream = response_transformer.transform_stream(Box::pin(stream));
|
||||||
|
|
||||||
// 创建使用量收集器
|
// 创建使用量收集器
|
||||||
let usage_collector = {
|
let usage_collector = {
|
||||||
@@ -202,10 +222,12 @@ async fn handle_claude_transform(
|
|||||||
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
|
let anthropic_response = response_transformer
|
||||||
log::error!("[Claude] 转换响应失败: {e}");
|
.transform_response(openai_response)
|
||||||
e
|
.map_err(|e| {
|
||||||
})?;
|
log::error!("[Claude] 转换响应失败: {e}");
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
|
||||||
// 记录使用量
|
// 记录使用量
|
||||||
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
|
if let Some(usage) = TokenUsage::from_claude_response(&anthropic_response) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod response_processor;
|
|||||||
pub(crate) mod server;
|
pub(crate) mod server;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod thinking_rectifier;
|
pub mod thinking_rectifier;
|
||||||
|
pub mod transform;
|
||||||
pub(crate) mod types;
|
pub(crate) mod types;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use super::auth::AuthInfo;
|
|||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::error::ProxyError;
|
use crate::proxy::error::ProxyError;
|
||||||
use reqwest::RequestBuilder;
|
use reqwest::RequestBuilder;
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
/// 供应商适配器 Trait
|
/// 供应商适配器 Trait
|
||||||
///
|
///
|
||||||
@@ -83,49 +82,4 @@ pub trait ProviderAdapter: Send + Sync {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// 添加了认证头的 RequestBuilder
|
/// 添加了认证头的 RequestBuilder
|
||||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
|
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
|
||||||
|
|
||||||
/// 是否需要格式转换
|
|
||||||
///
|
|
||||||
/// 默认返回 `false`(透传模式)。
|
|
||||||
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `provider` - Provider 配置
|
|
||||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换请求体
|
|
||||||
///
|
|
||||||
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
|
|
||||||
/// 默认实现直接返回原始请求体(透传)。
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `body` - 原始请求体
|
|
||||||
/// * `provider` - Provider 配置(用于获取模型映射等)
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// * `Ok(Value)` - 转换后的请求体
|
|
||||||
/// * `Err(ProxyError)` - 转换失败
|
|
||||||
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
|
|
||||||
Ok(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换响应体
|
|
||||||
///
|
|
||||||
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
|
|
||||||
/// 默认实现直接返回原始响应体(透传)。
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `body` - 原始响应体
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// * `Ok(Value)` - 转换后的响应体
|
|
||||||
/// * `Err(ProxyError)` - 转换失败
|
|
||||||
///
|
|
||||||
/// Note: 响应转换将在 handler 层集成,目前预留接口
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
|
|
||||||
Ok(body)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,25 +48,6 @@ impl ClaudeAdapter {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检测 OpenRouter 是否启用兼容模式
|
|
||||||
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
|
|
||||||
if !self.is_openrouter(provider) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
|
||||||
match raw {
|
|
||||||
Some(serde_json::Value::Bool(enabled)) => *enabled,
|
|
||||||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
|
||||||
Some(serde_json::Value::String(value)) => {
|
|
||||||
let normalized = value.trim().to_lowercase();
|
|
||||||
normalized == "true" || normalized == "1"
|
|
||||||
}
|
|
||||||
// OpenRouter now supports Claude Code compatible API, default to passthrough
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 检测是否为仅 Bearer 认证模式
|
/// 检测是否为仅 Bearer 认证模式
|
||||||
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
||||||
// 检查 settings_config 中的 auth_mode
|
// 检查 settings_config 中的 auth_mode
|
||||||
@@ -252,27 +233,6 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
_ => request,
|
_ => request,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
|
||||||
// NOTE:
|
|
||||||
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
|
|
||||||
// Anthropic ↔ OpenAI 的格式转换。
|
|
||||||
//
|
|
||||||
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
|
|
||||||
self.is_openrouter_compat_enabled(_provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transform_request(
|
|
||||||
&self,
|
|
||||||
body: serde_json::Value,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<serde_json::Value, ProxyError> {
|
|
||||||
super::transform::anthropic_to_openai(body, provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
|
||||||
super::transform::openai_to_anthropic(body)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -454,41 +414,4 @@ mod tests {
|
|||||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
||||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_needs_transform() {
|
|
||||||
let adapter = ClaudeAdapter::new();
|
|
||||||
|
|
||||||
let anthropic_provider = create_provider(json!({
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
assert!(!adapter.needs_transform(&anthropic_provider));
|
|
||||||
|
|
||||||
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
|
|
||||||
let openrouter_provider = create_provider(json!({
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
assert!(!adapter.needs_transform(&openrouter_provider));
|
|
||||||
|
|
||||||
// OpenRouter provider with explicit compat mode enabled should transform
|
|
||||||
let openrouter_enabled = create_provider(json!({
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
|
||||||
},
|
|
||||||
"openrouter_compat_mode": true
|
|
||||||
}));
|
|
||||||
assert!(adapter.needs_transform(&openrouter_enabled));
|
|
||||||
|
|
||||||
let openrouter_disabled = create_provider(json!({
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
|
||||||
},
|
|
||||||
"openrouter_compat_mode": false
|
|
||||||
}));
|
|
||||||
assert!(!adapter.needs_transform(&openrouter_disabled));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,20 @@ impl ProviderAdapter for CodexAdapter {
|
|||||||
let base_trimmed = base_url.trim_end_matches('/');
|
let base_trimmed = base_url.trim_end_matches('/');
|
||||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||||
|
|
||||||
|
// 检查 base_url 是否已包含 endpoint 的核心路径
|
||||||
|
// 例如:base_url = "https://api.example.com/v1/chat/completions"
|
||||||
|
// endpoint = "/v1/chat/completions"
|
||||||
|
// 此时不应再拼接,直接返回 base_url
|
||||||
|
let endpoint_core = endpoint_trimmed
|
||||||
|
.trim_start_matches("v1/")
|
||||||
|
.trim_start_matches("v1");
|
||||||
|
let endpoint_core = endpoint_core.trim_start_matches('/');
|
||||||
|
|
||||||
|
// 如果 base_url 已经以 endpoint 核心路径结尾,直接返回 base_url
|
||||||
|
if !endpoint_core.is_empty() && base_trimmed.ends_with(endpoint_core) {
|
||||||
|
return base_trimmed.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||||
|
|
||||||
// 去除重复的 /v1/v1
|
// 去除重复的 /v1/v1
|
||||||
@@ -231,6 +245,33 @@ mod tests {
|
|||||||
assert_eq!(url, "https://www.packyapi.com/v1/responses");
|
assert_eq!(url, "https://www.packyapi.com/v1/responses");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_base_already_has_chat_completions() {
|
||||||
|
let adapter = CodexAdapter::new();
|
||||||
|
// base_url 已包含 chat/completions,不应再拼接
|
||||||
|
let url = adapter.build_url(
|
||||||
|
"https://api.example.com/v1/chat/completions",
|
||||||
|
"/v1/chat/completions",
|
||||||
|
);
|
||||||
|
assert_eq!(url, "https://api.example.com/v1/chat/completions");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_base_already_has_responses() {
|
||||||
|
let adapter = CodexAdapter::new();
|
||||||
|
// base_url 已包含 responses,不应再拼接
|
||||||
|
let url = adapter.build_url("https://api.example.com/v1/responses", "/v1/responses");
|
||||||
|
assert_eq!(url, "https://api.example.com/v1/responses");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_url_base_without_endpoint() {
|
||||||
|
let adapter = CodexAdapter::new();
|
||||||
|
// base_url 不包含 endpoint,应正常拼接
|
||||||
|
let url = adapter.build_url("https://api.example.com/v1", "/v1/chat/completions");
|
||||||
|
assert_eq!(url, "https://api.example.com/v1/chat/completions");
|
||||||
|
}
|
||||||
|
|
||||||
// 官方客户端检测测试
|
// 官方客户端检测测试
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_official_client_vscode() {
|
fn test_is_official_client_vscode() {
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ mod claude;
|
|||||||
mod codex;
|
mod codex;
|
||||||
mod gemini;
|
mod gemini;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod streaming;
|
|
||||||
pub mod transform;
|
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
|
|||||||
@@ -1,640 +0,0 @@
|
|||||||
//! 格式转换模块
|
|
||||||
//!
|
|
||||||
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
|
||||||
//! 参考: anthropic-proxy-rs
|
|
||||||
|
|
||||||
use crate::provider::Provider;
|
|
||||||
use crate::proxy::error::ProxyError;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
/// 从 Provider 配置中获取模型映射
|
|
||||||
fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String {
|
|
||||||
let env = provider.settings_config.get("env");
|
|
||||||
let model_lower = model.to_lowercase();
|
|
||||||
|
|
||||||
// 检测 thinking 参数
|
|
||||||
let has_thinking = body
|
|
||||||
.get("thinking")
|
|
||||||
.and_then(|v| v.as_object())
|
|
||||||
.and_then(|o| o.get("type"))
|
|
||||||
.and_then(|t| t.as_str())
|
|
||||||
== Some("enabled");
|
|
||||||
|
|
||||||
if let Some(env) = env {
|
|
||||||
// 如果启用 thinking,优先使用推理模型
|
|
||||||
if has_thinking {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_REASONING_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
log::debug!("[Transform] 使用推理模型: {m}");
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据模型类型选择配置模型
|
|
||||||
if model_lower.contains("haiku") {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if model_lower.contains("opus") {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if model_lower.contains("sonnet") {
|
|
||||||
if let Some(m) = env
|
|
||||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 默认使用 ANTHROPIC_MODEL
|
|
||||||
if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) {
|
|
||||||
return m.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
model.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Anthropic 请求 → OpenAI 请求
|
|
||||||
pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result<Value, ProxyError> {
|
|
||||||
let mut result = json!({});
|
|
||||||
|
|
||||||
// 模型映射:使用 Provider 配置中的模型(支持 thinking 参数)
|
|
||||||
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
|
|
||||||
let mapped_model = get_model_from_provider(model, provider, &body);
|
|
||||||
result["model"] = json!(mapped_model);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut messages = Vec::new();
|
|
||||||
|
|
||||||
// 处理 system prompt
|
|
||||||
if let Some(system) = body.get("system") {
|
|
||||||
if let Some(text) = system.as_str() {
|
|
||||||
// 单个字符串
|
|
||||||
messages.push(json!({"role": "system", "content": text}));
|
|
||||||
} else if let Some(arr) = system.as_array() {
|
|
||||||
// 多个 system message
|
|
||||||
for msg in arr {
|
|
||||||
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
|
|
||||||
messages.push(json!({"role": "system", "content": text}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换 messages
|
|
||||||
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
|
|
||||||
for msg in msgs {
|
|
||||||
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
|
|
||||||
let content = msg.get("content");
|
|
||||||
let converted = convert_message_to_openai(role, content)?;
|
|
||||||
messages.extend(converted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result["messages"] = json!(messages);
|
|
||||||
|
|
||||||
// 转换参数
|
|
||||||
if let Some(v) = body.get("max_tokens") {
|
|
||||||
result["max_tokens"] = v.clone();
|
|
||||||
}
|
|
||||||
if let Some(v) = body.get("temperature") {
|
|
||||||
result["temperature"] = v.clone();
|
|
||||||
}
|
|
||||||
if let Some(v) = body.get("top_p") {
|
|
||||||
result["top_p"] = v.clone();
|
|
||||||
}
|
|
||||||
if let Some(v) = body.get("stop_sequences") {
|
|
||||||
result["stop"] = v.clone();
|
|
||||||
}
|
|
||||||
if let Some(v) = body.get("stream") {
|
|
||||||
result["stream"] = v.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换 tools (过滤 BatchTool)
|
|
||||||
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
|
||||||
let openai_tools: Vec<Value> = tools
|
|
||||||
.iter()
|
|
||||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
|
||||||
.map(|t| {
|
|
||||||
json!({
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
|
|
||||||
"description": t.get("description"),
|
|
||||||
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !openai_tools.is_empty() {
|
|
||||||
result["tools"] = json!(openai_tools);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(v) = body.get("tool_choice") {
|
|
||||||
result["tool_choice"] = v.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
|
||||||
fn convert_message_to_openai(
|
|
||||||
role: &str,
|
|
||||||
content: Option<&Value>,
|
|
||||||
) -> Result<Vec<Value>, ProxyError> {
|
|
||||||
let mut result = Vec::new();
|
|
||||||
|
|
||||||
let content = match content {
|
|
||||||
Some(c) => c,
|
|
||||||
None => {
|
|
||||||
result.push(json!({"role": role, "content": null}));
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 字符串内容
|
|
||||||
if let Some(text) = content.as_str() {
|
|
||||||
result.push(json!({"role": role, "content": text}));
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 数组内容(多模态/工具调用)
|
|
||||||
if let Some(blocks) = content.as_array() {
|
|
||||||
let mut content_parts = Vec::new();
|
|
||||||
let mut tool_calls = Vec::new();
|
|
||||||
|
|
||||||
for block in blocks {
|
|
||||||
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
|
||||||
|
|
||||||
match block_type {
|
|
||||||
"text" => {
|
|
||||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
|
||||||
content_parts.push(json!({"type": "text", "text": text}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"image" => {
|
|
||||||
if let Some(source) = block.get("source") {
|
|
||||||
let media_type = source
|
|
||||||
.get("media_type")
|
|
||||||
.and_then(|m| m.as_str())
|
|
||||||
.unwrap_or("image/png");
|
|
||||||
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
|
|
||||||
content_parts.push(json!({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"tool_use" => {
|
|
||||||
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
|
||||||
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
|
||||||
let input = block.get("input").cloned().unwrap_or(json!({}));
|
|
||||||
tool_calls.push(json!({
|
|
||||||
"id": id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": name,
|
|
||||||
"arguments": serde_json::to_string(&input).unwrap_or_default()
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
"tool_result" => {
|
|
||||||
// tool_result 变成单独的 tool role 消息
|
|
||||||
let tool_use_id = block
|
|
||||||
.get("tool_use_id")
|
|
||||||
.and_then(|i| i.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let content_val = block.get("content");
|
|
||||||
let content_str = match content_val {
|
|
||||||
Some(Value::String(s)) => s.clone(),
|
|
||||||
Some(v) => serde_json::to_string(v).unwrap_or_default(),
|
|
||||||
None => String::new(),
|
|
||||||
};
|
|
||||||
result.push(json!({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tool_use_id,
|
|
||||||
"content": content_str
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
"thinking" => {
|
|
||||||
// 跳过 thinking blocks
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加带内容和/或工具调用的消息
|
|
||||||
if !content_parts.is_empty() || !tool_calls.is_empty() {
|
|
||||||
let mut msg = json!({"role": role});
|
|
||||||
|
|
||||||
// 内容处理
|
|
||||||
if content_parts.is_empty() {
|
|
||||||
msg["content"] = Value::Null;
|
|
||||||
} else if content_parts.len() == 1 {
|
|
||||||
if let Some(text) = content_parts[0].get("text") {
|
|
||||||
msg["content"] = text.clone();
|
|
||||||
} else {
|
|
||||||
msg["content"] = json!(content_parts);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
msg["content"] = json!(content_parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具调用
|
|
||||||
if !tool_calls.is_empty() {
|
|
||||||
msg["tool_calls"] = json!(tool_calls);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他情况直接透传
|
|
||||||
result.push(json!({"role": role, "content": content}));
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清理 JSON schema(移除不支持的 format)
|
|
||||||
fn clean_schema(mut schema: Value) -> Value {
|
|
||||||
if let Some(obj) = schema.as_object_mut() {
|
|
||||||
// 移除 "format": "uri"
|
|
||||||
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
|
|
||||||
obj.remove("format");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归清理嵌套 schema
|
|
||||||
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
|
|
||||||
for (_, value) in properties.iter_mut() {
|
|
||||||
*value = clean_schema(value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(items) = obj.get_mut("items") {
|
|
||||||
*items = clean_schema(items.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
schema
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OpenAI 响应 → Anthropic 响应
|
|
||||||
pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
|
||||||
let choices = body
|
|
||||||
.get("choices")
|
|
||||||
.and_then(|c| c.as_array())
|
|
||||||
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
|
|
||||||
|
|
||||||
let choice = choices
|
|
||||||
.first()
|
|
||||||
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
|
|
||||||
|
|
||||||
let message = choice
|
|
||||||
.get("message")
|
|
||||||
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
|
|
||||||
|
|
||||||
let mut content = Vec::new();
|
|
||||||
|
|
||||||
// 文本内容
|
|
||||||
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
|
|
||||||
if !text.is_empty() {
|
|
||||||
content.push(json!({"type": "text", "text": text}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工具调用
|
|
||||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
|
|
||||||
for tc in tool_calls {
|
|
||||||
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
|
||||||
let empty_obj = json!({});
|
|
||||||
let func = tc.get("function").unwrap_or(&empty_obj);
|
|
||||||
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
|
||||||
let args_str = func
|
|
||||||
.get("arguments")
|
|
||||||
.and_then(|a| a.as_str())
|
|
||||||
.unwrap_or("{}");
|
|
||||||
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
|
|
||||||
|
|
||||||
content.push(json!({
|
|
||||||
"type": "tool_use",
|
|
||||||
"id": id,
|
|
||||||
"name": name,
|
|
||||||
"input": input
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 映射 finish_reason → stop_reason
|
|
||||||
let stop_reason = choice
|
|
||||||
.get("finish_reason")
|
|
||||||
.and_then(|r| r.as_str())
|
|
||||||
.map(|r| match r {
|
|
||||||
"stop" => "end_turn",
|
|
||||||
"length" => "max_tokens",
|
|
||||||
"tool_calls" => "tool_use",
|
|
||||||
other => other,
|
|
||||||
});
|
|
||||||
|
|
||||||
// usage
|
|
||||||
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
|
||||||
let input_tokens = usage
|
|
||||||
.get("prompt_tokens")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(0) as u32;
|
|
||||||
let output_tokens = usage
|
|
||||||
.get("completion_tokens")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(0) as u32;
|
|
||||||
|
|
||||||
let result = json!({
|
|
||||||
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": content,
|
|
||||||
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
|
|
||||||
"stop_reason": stop_reason,
|
|
||||||
"stop_sequence": null,
|
|
||||||
"usage": {
|
|
||||||
"input_tokens": input_tokens,
|
|
||||||
"output_tokens": output_tokens
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn create_provider(env_config: Value) -> Provider {
|
|
||||||
Provider {
|
|
||||||
id: "test".to_string(),
|
|
||||||
name: "Test Provider".to_string(),
|
|
||||||
settings_config: json!({"env": env_config}),
|
|
||||||
website_url: None,
|
|
||||||
category: None,
|
|
||||||
created_at: None,
|
|
||||||
sort_index: None,
|
|
||||||
notes: None,
|
|
||||||
meta: None,
|
|
||||||
icon: None,
|
|
||||||
icon_color: None,
|
|
||||||
in_failover_queue: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_openrouter_provider() -> Provider {
|
|
||||||
create_provider(json!({
|
|
||||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
|
||||||
"ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5",
|
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5",
|
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5",
|
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5"
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_anthropic_to_openai_simple() {
|
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-3-opus",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"messages": [{"role": "user", "content": "Hello"}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
// opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL
|
|
||||||
assert_eq!(result["model"], "anthropic/claude-opus-4.5");
|
|
||||||
assert_eq!(result["max_tokens"], 1024);
|
|
||||||
assert_eq!(result["messages"][0]["role"], "user");
|
|
||||||
assert_eq!(result["messages"][0]["content"], "Hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_anthropic_to_openai_with_system() {
|
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-3-sonnet",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"system": "You are a helpful assistant.",
|
|
||||||
"messages": [{"role": "user", "content": "Hello"}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
assert_eq!(result["messages"][0]["role"], "system");
|
|
||||||
assert_eq!(
|
|
||||||
result["messages"][0]["content"],
|
|
||||||
"You are a helpful assistant."
|
|
||||||
);
|
|
||||||
assert_eq!(result["messages"][1]["role"], "user");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_anthropic_to_openai_with_tools() {
|
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-3-opus",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"messages": [{"role": "user", "content": "What's the weather?"}],
|
|
||||||
"tools": [{
|
|
||||||
"name": "get_weather",
|
|
||||||
"description": "Get weather info",
|
|
||||||
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
assert_eq!(result["tools"][0]["type"], "function");
|
|
||||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_anthropic_to_openai_tool_use() {
|
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-3-opus",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"messages": [{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": "Let me check"},
|
|
||||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
let msg = &result["messages"][0];
|
|
||||||
assert_eq!(msg["role"], "assistant");
|
|
||||||
assert!(msg.get("tool_calls").is_some());
|
|
||||||
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_anthropic_to_openai_tool_result() {
|
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-3-opus",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"messages": [{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
let msg = &result["messages"][0];
|
|
||||||
assert_eq!(msg["role"], "tool");
|
|
||||||
assert_eq!(msg["tool_call_id"], "call_123");
|
|
||||||
assert_eq!(msg["content"], "Sunny, 25°C");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_openai_to_anthropic_simple() {
|
|
||||||
let input = json!({
|
|
||||||
"id": "chatcmpl-123",
|
|
||||||
"object": "chat.completion",
|
|
||||||
"created": 1234567890,
|
|
||||||
"model": "gpt-4",
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": {"role": "assistant", "content": "Hello!"},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}],
|
|
||||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = openai_to_anthropic(input).unwrap();
|
|
||||||
assert_eq!(result["id"], "chatcmpl-123");
|
|
||||||
assert_eq!(result["type"], "message");
|
|
||||||
assert_eq!(result["content"][0]["type"], "text");
|
|
||||||
assert_eq!(result["content"][0]["text"], "Hello!");
|
|
||||||
assert_eq!(result["stop_reason"], "end_turn");
|
|
||||||
assert_eq!(result["usage"]["input_tokens"], 10);
|
|
||||||
assert_eq!(result["usage"]["output_tokens"], 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_openai_to_anthropic_with_tool_calls() {
|
|
||||||
let input = json!({
|
|
||||||
"id": "chatcmpl-123",
|
|
||||||
"object": "chat.completion",
|
|
||||||
"created": 1234567890,
|
|
||||||
"model": "gpt-4",
|
|
||||||
"choices": [{
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": null,
|
|
||||||
"tool_calls": [{
|
|
||||||
"id": "call_123",
|
|
||||||
"type": "function",
|
|
||||||
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"finish_reason": "tool_calls"
|
|
||||||
}],
|
|
||||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = openai_to_anthropic(input).unwrap();
|
|
||||||
assert_eq!(result["content"][0]["type"], "tool_use");
|
|
||||||
assert_eq!(result["content"][0]["id"], "call_123");
|
|
||||||
assert_eq!(result["content"][0]["name"], "get_weather");
|
|
||||||
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
|
|
||||||
assert_eq!(result["stop_reason"], "tool_use");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_model_mapping_from_provider() {
|
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let body = json!({"model": "test"});
|
|
||||||
|
|
||||||
// sonnet 模型
|
|
||||||
assert_eq!(
|
|
||||||
get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body),
|
|
||||||
"anthropic/claude-sonnet-4.5"
|
|
||||||
);
|
|
||||||
|
|
||||||
// haiku 模型
|
|
||||||
assert_eq!(
|
|
||||||
get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body),
|
|
||||||
"anthropic/claude-haiku-4.5"
|
|
||||||
);
|
|
||||||
|
|
||||||
// opus 模型
|
|
||||||
assert_eq!(
|
|
||||||
get_model_from_provider("claude-opus-4-5", &provider, &body),
|
|
||||||
"anthropic/claude-opus-4.5"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_anthropic_to_openai_model_mapping() {
|
|
||||||
let provider = create_openrouter_provider();
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-sonnet-4-5-20250929",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"messages": [{"role": "user", "content": "Hello"}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_thinking_parameter_detection() {
|
|
||||||
let mut provider = create_openrouter_provider();
|
|
||||||
// 添加推理模型配置
|
|
||||||
if let Some(env) = provider.settings_config.get_mut("env") {
|
|
||||||
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"thinking": {"type": "enabled"},
|
|
||||||
"messages": [{"role": "user", "content": "Solve this problem"}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
// 应该使用推理模型
|
|
||||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_thinking_parameter_disabled() {
|
|
||||||
let mut provider = create_openrouter_provider();
|
|
||||||
if let Some(env) = provider.settings_config.get_mut("env") {
|
|
||||||
env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended");
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = json!({
|
|
||||||
"model": "claude-sonnet-4-5",
|
|
||||||
"max_tokens": 1024,
|
|
||||||
"thinking": {"type": "disabled"},
|
|
||||||
"messages": [{"role": "user", "content": "Hello"}]
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = anthropic_to_openai(input, &provider).unwrap();
|
|
||||||
// 应该使用普通模型
|
|
||||||
assert_eq!(result["model"], "anthropic/claude-sonnet-4.5");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//! Anthropic ↔ OpenAI 格式转换模块
|
||||||
|
//!
|
||||||
|
//! 提供 Anthropic Messages API 和 OpenAI Chat Completions API 之间的双向转换
|
||||||
|
|
||||||
|
mod request;
|
||||||
|
mod response;
|
||||||
|
pub mod streaming;
|
||||||
|
|
||||||
|
pub use request::AnthropicToOpenAITransformer;
|
||||||
|
pub use response::OpenAIToAnthropicTransformer;
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
//! Anthropic → OpenAI 请求转换器
|
||||||
|
//!
|
||||||
|
//! 将 Anthropic Messages API 请求转换为 OpenAI Chat Completions API 格式
|
||||||
|
|
||||||
|
use crate::proxy::error::ProxyError;
|
||||||
|
use crate::proxy::transform::{format::ApiFormat, traits::FormatTransformer};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::stream::Stream;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
/// Anthropic → OpenAI 请求转换器
|
||||||
|
pub struct AnthropicToOpenAITransformer;
|
||||||
|
|
||||||
|
impl AnthropicToOpenAITransformer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AnthropicToOpenAITransformer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormatTransformer for AnthropicToOpenAITransformer {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Anthropic→OpenAI"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_format(&self) -> ApiFormat {
|
||||||
|
ApiFormat::Anthropic
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_format(&self) -> ApiFormat {
|
||||||
|
ApiFormat::OpenAI
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_request(&self, body: Value) -> Result<Value, ProxyError> {
|
||||||
|
anthropic_to_openai(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
|
||||||
|
// 请求转换器不处理响应,直接透传
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_stream(
|
||||||
|
&self,
|
||||||
|
_stream: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
|
||||||
|
) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
|
||||||
|
// 请求转换器不处理流
|
||||||
|
Box::pin(futures::stream::empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_endpoint(&self, endpoint: &str) -> String {
|
||||||
|
// /v1/messages → /v1/chat/completions
|
||||||
|
if endpoint == "/v1/messages" {
|
||||||
|
"/v1/chat/completions".to_string()
|
||||||
|
} else {
|
||||||
|
endpoint.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic 请求 → OpenAI 请求
|
||||||
|
fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||||
|
let mut result = json!({});
|
||||||
|
|
||||||
|
// 模型直接透传(模型映射由 model_mapper 模块独立处理)
|
||||||
|
if let Some(model) = body.get("model") {
|
||||||
|
result["model"] = model.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
|
// 处理 system prompt
|
||||||
|
if let Some(system) = body.get("system") {
|
||||||
|
if let Some(text) = system.as_str() {
|
||||||
|
// 单个字符串
|
||||||
|
messages.push(json!({"role": "system", "content": text}));
|
||||||
|
} else if let Some(arr) = system.as_array() {
|
||||||
|
// 多个 system message
|
||||||
|
for msg in arr {
|
||||||
|
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
|
||||||
|
messages.push(json!({"role": "system", "content": text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 messages
|
||||||
|
if let Some(msgs) = body.get("messages").and_then(|m| m.as_array()) {
|
||||||
|
for msg in msgs {
|
||||||
|
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
|
||||||
|
let content = msg.get("content");
|
||||||
|
let converted = convert_message_to_openai(role, content)?;
|
||||||
|
messages.extend(converted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result["messages"] = json!(messages);
|
||||||
|
|
||||||
|
// 转换参数
|
||||||
|
if let Some(v) = body.get("max_tokens") {
|
||||||
|
result["max_tokens"] = v.clone();
|
||||||
|
}
|
||||||
|
if let Some(v) = body.get("temperature") {
|
||||||
|
result["temperature"] = v.clone();
|
||||||
|
}
|
||||||
|
if let Some(v) = body.get("top_p") {
|
||||||
|
result["top_p"] = v.clone();
|
||||||
|
}
|
||||||
|
if let Some(v) = body.get("stop_sequences") {
|
||||||
|
result["stop"] = v.clone();
|
||||||
|
}
|
||||||
|
if let Some(v) = body.get("stream") {
|
||||||
|
result["stream"] = v.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 tools (过滤 BatchTool)
|
||||||
|
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||||
|
let openai_tools: Vec<Value> = tools
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
||||||
|
.map(|t| {
|
||||||
|
json!({
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
|
||||||
|
"description": t.get("description"),
|
||||||
|
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !openai_tools.is_empty() {
|
||||||
|
result["tools"] = json!(openai_tools);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(v) = body.get("tool_choice") {
|
||||||
|
result["tool_choice"] = v.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换单条消息到 OpenAI 格式(可能产生多条消息)
|
||||||
|
fn convert_message_to_openai(
|
||||||
|
role: &str,
|
||||||
|
content: Option<&Value>,
|
||||||
|
) -> Result<Vec<Value>, ProxyError> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
let content = match content {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
result.push(json!({"role": role, "content": null}));
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 字符串内容
|
||||||
|
if let Some(text) = content.as_str() {
|
||||||
|
result.push(json!({"role": role, "content": text}));
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数组内容(多模态/工具调用)
|
||||||
|
if let Some(blocks) = content.as_array() {
|
||||||
|
let mut content_parts = Vec::new();
|
||||||
|
let mut tool_calls = Vec::new();
|
||||||
|
|
||||||
|
for block in blocks {
|
||||||
|
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
match block_type {
|
||||||
|
"text" => {
|
||||||
|
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||||
|
content_parts.push(json!({"type": "text", "text": text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"image" => {
|
||||||
|
if let Some(source) = block.get("source") {
|
||||||
|
let media_type = source
|
||||||
|
.get("media_type")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.unwrap_or("image/png");
|
||||||
|
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
|
||||||
|
content_parts.push(json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"tool_use" => {
|
||||||
|
let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||||
|
let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||||
|
let input = block.get("input").cloned().unwrap_or(json!({}));
|
||||||
|
tool_calls.push(json!({
|
||||||
|
"id": id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": name,
|
||||||
|
"arguments": serde_json::to_string(&input).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
"tool_result" => {
|
||||||
|
// tool_result 变成单独的 tool role 消息
|
||||||
|
let tool_use_id = block
|
||||||
|
.get("tool_use_id")
|
||||||
|
.and_then(|i| i.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let content_val = block.get("content");
|
||||||
|
let content_str = match content_val {
|
||||||
|
Some(Value::String(s)) => s.clone(),
|
||||||
|
Some(v) => serde_json::to_string(v).unwrap_or_default(),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
result.push(json!({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_use_id,
|
||||||
|
"content": content_str
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
"thinking" => {
|
||||||
|
// 跳过 thinking blocks
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加带内容和/或工具调用的消息
|
||||||
|
if !content_parts.is_empty() || !tool_calls.is_empty() {
|
||||||
|
let mut msg = json!({"role": role});
|
||||||
|
|
||||||
|
// 内容处理
|
||||||
|
if content_parts.is_empty() {
|
||||||
|
msg["content"] = Value::Null;
|
||||||
|
} else if content_parts.len() == 1 {
|
||||||
|
if let Some(text) = content_parts[0].get("text") {
|
||||||
|
msg["content"] = text.clone();
|
||||||
|
} else {
|
||||||
|
msg["content"] = json!(content_parts);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msg["content"] = json!(content_parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用
|
||||||
|
if !tool_calls.is_empty() {
|
||||||
|
msg["tool_calls"] = json!(tool_calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他情况直接透传
|
||||||
|
result.push(json!({"role": role, "content": content}));
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理 JSON schema(移除不支持的 format)
|
||||||
|
fn clean_schema(mut schema: Value) -> Value {
|
||||||
|
if let Some(obj) = schema.as_object_mut() {
|
||||||
|
// 移除 "format": "uri"
|
||||||
|
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
|
||||||
|
obj.remove("format");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归清理嵌套 schema
|
||||||
|
if let Some(properties) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
|
||||||
|
for (_, value) in properties.iter_mut() {
|
||||||
|
*value = clean_schema(value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(items) = obj.get_mut("items") {
|
||||||
|
*items = clean_schema(items.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schema
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_simple() {
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
|
assert_eq!(result["model"], "claude-3-opus");
|
||||||
|
assert_eq!(result["max_tokens"], 1024);
|
||||||
|
assert_eq!(result["messages"][0]["role"], "user");
|
||||||
|
assert_eq!(result["messages"][0]["content"], "Hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_with_system() {
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-sonnet",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"system": "You are a helpful assistant.",
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
|
assert_eq!(result["messages"][0]["role"], "system");
|
||||||
|
assert_eq!(
|
||||||
|
result["messages"][0]["content"],
|
||||||
|
"You are a helpful assistant."
|
||||||
|
);
|
||||||
|
assert_eq!(result["messages"][1]["role"], "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_with_tools() {
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{"role": "user", "content": "What's the weather?"}],
|
||||||
|
"tools": [{
|
||||||
|
"name": "get_weather",
|
||||||
|
"description": "Get weather info",
|
||||||
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
|
assert_eq!(result["tools"][0]["type"], "function");
|
||||||
|
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_tool_use() {
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Let me check"},
|
||||||
|
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
|
let msg = &result["messages"][0];
|
||||||
|
assert_eq!(msg["role"], "assistant");
|
||||||
|
assert!(msg.get("tool_calls").is_some());
|
||||||
|
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_tool_result() {
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": "call_123", "content": "Sunny, 25°C"}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
|
let msg = &result["messages"][0];
|
||||||
|
assert_eq!(msg["role"], "tool");
|
||||||
|
assert_eq!(msg["tool_call_id"], "call_123");
|
||||||
|
assert_eq!(msg["content"], "Sunny, 25°C");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_transform_endpoint() {
|
||||||
|
let transformer = AnthropicToOpenAITransformer::new();
|
||||||
|
assert_eq!(
|
||||||
|
transformer.transform_endpoint("/v1/messages"),
|
||||||
|
"/v1/chat/completions"
|
||||||
|
);
|
||||||
|
assert_eq!(transformer.transform_endpoint("/v1/other"), "/v1/other");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
//! OpenAI → Anthropic 响应转换器
|
||||||
|
//!
|
||||||
|
//! 将 OpenAI Chat Completions API 响应转换为 Anthropic Messages API 格式
|
||||||
|
|
||||||
|
use crate::proxy::error::ProxyError;
|
||||||
|
use crate::proxy::transform::{format::ApiFormat, traits::FormatTransformer};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::stream::Stream;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use super::streaming::create_anthropic_sse_stream;
|
||||||
|
|
||||||
|
/// OpenAI → Anthropic 响应转换器
|
||||||
|
pub struct OpenAIToAnthropicTransformer;
|
||||||
|
|
||||||
|
impl OpenAIToAnthropicTransformer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OpenAIToAnthropicTransformer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormatTransformer for OpenAIToAnthropicTransformer {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"OpenAI→Anthropic"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_format(&self) -> ApiFormat {
|
||||||
|
ApiFormat::OpenAI
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_format(&self) -> ApiFormat {
|
||||||
|
ApiFormat::Anthropic
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_request(&self, body: Value) -> Result<Value, ProxyError> {
|
||||||
|
// 响应转换器不处理请求,直接透传
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
|
||||||
|
openai_to_anthropic(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_stream(
|
||||||
|
&self,
|
||||||
|
stream: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
|
||||||
|
) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
|
||||||
|
Box::pin(create_anthropic_sse_stream(stream))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 响应 → Anthropic 响应
|
||||||
|
fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||||
|
let choices = body
|
||||||
|
.get("choices")
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.ok_or_else(|| ProxyError::TransformError("No choices in response".to_string()))?;
|
||||||
|
|
||||||
|
let choice = choices
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| ProxyError::TransformError("Empty choices array".to_string()))?;
|
||||||
|
|
||||||
|
let message = choice
|
||||||
|
.get("message")
|
||||||
|
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
|
||||||
|
|
||||||
|
let mut content = Vec::new();
|
||||||
|
|
||||||
|
// 文本内容
|
||||||
|
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
|
||||||
|
if !text.is_empty() {
|
||||||
|
content.push(json!({"type": "text", "text": text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用
|
||||||
|
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
|
||||||
|
for tc in tool_calls {
|
||||||
|
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||||
|
let empty_obj = json!({});
|
||||||
|
let func = tc.get("function").unwrap_or(&empty_obj);
|
||||||
|
let name = func.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||||
|
let args_str = func
|
||||||
|
.get("arguments")
|
||||||
|
.and_then(|a| a.as_str())
|
||||||
|
.unwrap_or("{}");
|
||||||
|
|
||||||
|
// 解析 arguments JSON,失败时返回错误而不是静默使用空对象
|
||||||
|
let input: Value = serde_json::from_str(args_str).map_err(|e| {
|
||||||
|
log::error!("[Transform] tool_calls.arguments 解析失败: {e}, 原始内容: {args_str}");
|
||||||
|
ProxyError::TransformError(format!(
|
||||||
|
"Failed to parse tool_calls.arguments: {e}, content: {args_str}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
content.push(json!({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
"input": input
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 映射 finish_reason → stop_reason
|
||||||
|
let stop_reason = choice
|
||||||
|
.get("finish_reason")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.map(|r| match r {
|
||||||
|
"stop" => "end_turn",
|
||||||
|
"length" => "max_tokens",
|
||||||
|
"tool_calls" => "tool_use",
|
||||||
|
other => other,
|
||||||
|
});
|
||||||
|
|
||||||
|
// usage
|
||||||
|
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
||||||
|
let input_tokens = usage
|
||||||
|
.get("prompt_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
let output_tokens = usage
|
||||||
|
.get("completion_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0) as u32;
|
||||||
|
|
||||||
|
let result = json!({
|
||||||
|
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content,
|
||||||
|
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
|
||||||
|
"stop_reason": stop_reason,
|
||||||
|
"stop_sequence": null,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": input_tokens,
|
||||||
|
"output_tokens": output_tokens
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_openai_to_anthropic_simple() {
|
||||||
|
let input = json!({
|
||||||
|
"id": "chatcmpl-123",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1234567890,
|
||||||
|
"model": "gpt-4",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "Hello!"},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = openai_to_anthropic(input).unwrap();
|
||||||
|
assert_eq!(result["id"], "chatcmpl-123");
|
||||||
|
assert_eq!(result["type"], "message");
|
||||||
|
assert_eq!(result["content"][0]["type"], "text");
|
||||||
|
assert_eq!(result["content"][0]["text"], "Hello!");
|
||||||
|
assert_eq!(result["stop_reason"], "end_turn");
|
||||||
|
assert_eq!(result["usage"]["input_tokens"], 10);
|
||||||
|
assert_eq!(result["usage"]["output_tokens"], 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_openai_to_anthropic_with_tool_calls() {
|
||||||
|
let input = json!({
|
||||||
|
"id": "chatcmpl-123",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 1234567890,
|
||||||
|
"model": "gpt-4",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": null,
|
||||||
|
"tool_calls": [{
|
||||||
|
"id": "call_123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "get_weather", "arguments": "{\"location\": \"Tokyo\"}"}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls"
|
||||||
|
}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = openai_to_anthropic(input).unwrap();
|
||||||
|
assert_eq!(result["content"][0]["type"], "tool_use");
|
||||||
|
assert_eq!(result["content"][0]["id"], "call_123");
|
||||||
|
assert_eq!(result["content"][0]["name"], "get_weather");
|
||||||
|
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
|
||||||
|
assert_eq!(result["stop_reason"], "tool_use");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stop_reason_mapping() {
|
||||||
|
// stop → end_turn
|
||||||
|
let input = json!({
|
||||||
|
"choices": [{"message": {"content": "Hi"}, "finish_reason": "stop"}],
|
||||||
|
"usage": {}
|
||||||
|
});
|
||||||
|
let result = openai_to_anthropic(input).unwrap();
|
||||||
|
assert_eq!(result["stop_reason"], "end_turn");
|
||||||
|
|
||||||
|
// length → max_tokens
|
||||||
|
let input = json!({
|
||||||
|
"choices": [{"message": {"content": "Hi"}, "finish_reason": "length"}],
|
||||||
|
"usage": {}
|
||||||
|
});
|
||||||
|
let result = openai_to_anthropic(input).unwrap();
|
||||||
|
assert_eq!(result["stop_reason"], "max_tokens");
|
||||||
|
|
||||||
|
// tool_calls → tool_use
|
||||||
|
let input = json!({
|
||||||
|
"choices": [{"message": {"content": null, "tool_calls": []}, "finish_reason": "tool_calls"}],
|
||||||
|
"usage": {}
|
||||||
|
});
|
||||||
|
let result = openai_to_anthropic(input).unwrap();
|
||||||
|
assert_eq!(result["stop_reason"], "tool_use");
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-9
@@ -6,6 +6,7 @@ use bytes::Bytes;
|
|||||||
use futures::stream::{Stream, StreamExt};
|
use futures::stream::{Stream, StreamExt};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// OpenAI 流式响应数据结构
|
/// OpenAI 流式响应数据结构
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -73,7 +74,8 @@ pub fn create_anthropic_sse_stream(
|
|||||||
let mut content_index = 0;
|
let mut content_index = 0;
|
||||||
let mut has_sent_message_start = false;
|
let mut has_sent_message_start = false;
|
||||||
let mut current_block_type: Option<String> = None;
|
let mut current_block_type: Option<String> = None;
|
||||||
let mut tool_call_id = None;
|
// 使用 HashMap 按 index 管理多个工具调用的 ID 和 content_index
|
||||||
|
let mut tool_calls_map: HashMap<usize, (String, usize)> = HashMap::new();
|
||||||
|
|
||||||
tokio::pin!(stream);
|
tokio::pin!(stream);
|
||||||
|
|
||||||
@@ -94,17 +96,17 @@ pub fn create_anthropic_sse_stream(
|
|||||||
for l in line.lines() {
|
for l in line.lines() {
|
||||||
if let Some(data) = l.strip_prefix("data: ") {
|
if let Some(data) = l.strip_prefix("data: ") {
|
||||||
if data.trim() == "[DONE]" {
|
if data.trim() == "[DONE]" {
|
||||||
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
|
log::debug!("[Transform] <<< OpenAI SSE: [DONE]");
|
||||||
let event = json!({"type": "message_stop"});
|
let event = json!({"type": "message_stop"});
|
||||||
let sse_data = format!("event: message_stop\ndata: {}\n\n",
|
let sse_data = format!("event: message_stop\ndata: {}\n\n",
|
||||||
serde_json::to_string(&event).unwrap_or_default());
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
|
log::debug!("[Transform] >>> Anthropic SSE: message_stop");
|
||||||
yield Ok(Bytes::from(sse_data));
|
yield Ok(Bytes::from(sse_data));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
||||||
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
|
log::debug!("[Transform] <<< SSE chunk received");
|
||||||
|
|
||||||
if message_id.is_none() {
|
if message_id.is_none() {
|
||||||
message_id = Some(chunk.id.clone());
|
message_id = Some(chunk.id.clone());
|
||||||
@@ -210,7 +212,11 @@ pub fn create_anthropic_sse_stream(
|
|||||||
// 处理工具调用
|
// 处理工具调用
|
||||||
if let Some(tool_calls) = &choice.delta.tool_calls {
|
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||||
for tool_call in tool_calls {
|
for tool_call in tool_calls {
|
||||||
|
let tc_index = tool_call.index;
|
||||||
|
|
||||||
|
// 检查是否是新的工具调用(有 id 表示开始新的工具调用)
|
||||||
if let Some(id) = &tool_call.id {
|
if let Some(id) = &tool_call.id {
|
||||||
|
// 关闭当前的 content block(如果有)
|
||||||
if current_block_type.is_some() {
|
if current_block_type.is_some() {
|
||||||
let event = json!({
|
let event = json!({
|
||||||
"type": "content_block_stop",
|
"type": "content_block_stop",
|
||||||
@@ -222,30 +228,44 @@ pub fn create_anthropic_sse_stream(
|
|||||||
content_index += 1;
|
content_index += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
tool_call_id = Some(id.clone());
|
// 记录这个工具调用的 ID 和对应的 content_index
|
||||||
|
tool_calls_map.insert(tc_index, (id.clone(), content_index));
|
||||||
|
current_block_type = Some("tool_use".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取当前工具调用的信息
|
||||||
|
let (tool_id, tool_content_index) = tool_calls_map
|
||||||
|
.get(&tc_index)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
log::warn!(
|
||||||
|
"[Transform] 收到未知 index 的工具调用 delta: {tc_index}"
|
||||||
|
);
|
||||||
|
(String::new(), content_index)
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(function) = &tool_call.function {
|
if let Some(function) = &tool_call.function {
|
||||||
|
// 如果有 name,发送 content_block_start
|
||||||
if let Some(name) = &function.name {
|
if let Some(name) = &function.name {
|
||||||
let event = json!({
|
let event = json!({
|
||||||
"type": "content_block_start",
|
"type": "content_block_start",
|
||||||
"index": content_index,
|
"index": tool_content_index,
|
||||||
"content_block": {
|
"content_block": {
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": tool_call_id.clone().unwrap_or_default(),
|
"id": tool_id,
|
||||||
"name": name
|
"name": name
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||||
serde_json::to_string(&event).unwrap_or_default());
|
serde_json::to_string(&event).unwrap_or_default());
|
||||||
yield Ok(Bytes::from(sse_data));
|
yield Ok(Bytes::from(sse_data));
|
||||||
current_block_type = Some("tool_use".to_string());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果有 arguments,发送 content_block_delta
|
||||||
if let Some(args) = &function.arguments {
|
if let Some(args) = &function.arguments {
|
||||||
let event = json!({
|
let event = json!({
|
||||||
"type": "content_block_delta",
|
"type": "content_block_delta",
|
||||||
"index": content_index,
|
"index": tool_content_index,
|
||||||
"delta": {
|
"delta": {
|
||||||
"type": "input_json_delta",
|
"type": "input_json_delta",
|
||||||
"partial_json": args
|
"partial_json": args
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
//! 格式转换配置
|
||||||
|
//!
|
||||||
|
//! 从 Provider 配置中提取格式转换设置
|
||||||
|
|
||||||
|
use super::format::ApiFormat;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 格式转换配置
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TransformConfig {
|
||||||
|
/// 是否启用格式转换
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 源格式(客户端发送的格式)
|
||||||
|
pub source_format: ApiFormat,
|
||||||
|
/// 目标格式(上游服务期望的格式)
|
||||||
|
pub target_format: ApiFormat,
|
||||||
|
/// 是否转换流式响应
|
||||||
|
pub transform_streaming: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TransformConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
source_format: ApiFormat::Anthropic,
|
||||||
|
target_format: ApiFormat::OpenAI,
|
||||||
|
transform_streaming: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransformConfig {
|
||||||
|
/// 从 Provider 配置中提取转换配置
|
||||||
|
///
|
||||||
|
/// 优先级:
|
||||||
|
/// 1. ProviderMeta.format_transform(新配置格式,通过前端 UI 设置)
|
||||||
|
/// 2. settings_config.format_transform(兼容旧配置)
|
||||||
|
/// 3. settings_config.openrouter_compat_mode(兼容旧配置)
|
||||||
|
///
|
||||||
|
/// 注意:如果格式解析失败,将禁用转换并记录警告,而不是静默回退到默认值
|
||||||
|
pub fn from_provider(provider: &Provider) -> Self {
|
||||||
|
// 1. 优先从 ProviderMeta 读取(前端 UI 设置的配置)
|
||||||
|
if let Some(meta) = &provider.meta {
|
||||||
|
if let Some(ft) = &meta.format_transform {
|
||||||
|
if ft.enabled {
|
||||||
|
let source_str = ft.source_format.as_deref();
|
||||||
|
let target_str = ft.target_format.as_deref();
|
||||||
|
|
||||||
|
let source_format = source_str.and_then(ApiFormat::from_str);
|
||||||
|
let target_format = target_str.and_then(ApiFormat::from_str);
|
||||||
|
|
||||||
|
// 如果格式解析失败,禁用转换并记录警告
|
||||||
|
if source_str.is_some() && source_format.is_none() {
|
||||||
|
log::warn!(
|
||||||
|
"[TransformConfig] 无法解析 source_format: {source_str:?},禁用格式转换"
|
||||||
|
);
|
||||||
|
return Self::default();
|
||||||
|
}
|
||||||
|
if target_str.is_some() && target_format.is_none() {
|
||||||
|
log::warn!(
|
||||||
|
"[TransformConfig] 无法解析 target_format: {target_str:?},禁用格式转换"
|
||||||
|
);
|
||||||
|
return Self::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let transform_streaming = ft.transform_streaming.unwrap_or(true);
|
||||||
|
|
||||||
|
return Self {
|
||||||
|
enabled: true,
|
||||||
|
source_format: source_format.unwrap_or(ApiFormat::Anthropic),
|
||||||
|
target_format: target_format.unwrap_or(ApiFormat::OpenAI),
|
||||||
|
transform_streaming,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = &provider.settings_config;
|
||||||
|
|
||||||
|
// 2. 检查是否显式启用格式转换(settings_config 中的配置)
|
||||||
|
let format_transform = settings.get("format_transform").and_then(|v| v.as_object());
|
||||||
|
|
||||||
|
if let Some(config) = format_transform {
|
||||||
|
let enabled = config
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
let source_str = config.get("source_format").and_then(|v| v.as_str());
|
||||||
|
let target_str = config.get("target_format").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
let source_format = source_str.and_then(ApiFormat::from_str);
|
||||||
|
let target_format = target_str.and_then(ApiFormat::from_str);
|
||||||
|
|
||||||
|
// 如果格式解析失败,禁用转换并记录警告
|
||||||
|
if source_str.is_some() && source_format.is_none() {
|
||||||
|
log::warn!(
|
||||||
|
"[TransformConfig] 无法解析 source_format: {source_str:?},禁用格式转换"
|
||||||
|
);
|
||||||
|
return Self::default();
|
||||||
|
}
|
||||||
|
if target_str.is_some() && target_format.is_none() {
|
||||||
|
log::warn!(
|
||||||
|
"[TransformConfig] 无法解析 target_format: {target_str:?},禁用格式转换"
|
||||||
|
);
|
||||||
|
return Self::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let transform_streaming = config
|
||||||
|
.get("transform_streaming")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
return Self {
|
||||||
|
enabled,
|
||||||
|
source_format: source_format.unwrap_or(ApiFormat::Anthropic),
|
||||||
|
target_format: target_format.unwrap_or(ApiFormat::OpenAI),
|
||||||
|
transform_streaming,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 兼容旧配置:检查 openrouter_compat_mode
|
||||||
|
let legacy_enabled = settings
|
||||||
|
.get("openrouter_compat_mode")
|
||||||
|
.and_then(|v| match v {
|
||||||
|
serde_json::Value::Bool(b) => Some(*b),
|
||||||
|
serde_json::Value::Number(n) => Some(n.as_i64().unwrap_or(0) != 0),
|
||||||
|
serde_json::Value::String(s) => {
|
||||||
|
let normalized = s.trim().to_lowercase();
|
||||||
|
Some(normalized == "true" || normalized == "1")
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if legacy_enabled {
|
||||||
|
return Self {
|
||||||
|
enabled: true,
|
||||||
|
source_format: ApiFormat::Anthropic,
|
||||||
|
target_format: ApiFormat::OpenAI,
|
||||||
|
transform_streaming: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否需要转换
|
||||||
|
pub fn needs_transform(&self) -> bool {
|
||||||
|
self.enabled && self.source_format != self.target_format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn create_provider(settings: serde_json::Value) -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test Provider".to_string(),
|
||||||
|
settings_config: settings,
|
||||||
|
website_url: None,
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let provider = create_provider(json!({}));
|
||||||
|
let config = TransformConfig::from_provider(&provider);
|
||||||
|
assert!(!config.enabled);
|
||||||
|
assert!(!config.needs_transform());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_format_config() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"format_transform": {
|
||||||
|
"enabled": true,
|
||||||
|
"source_format": "anthropic",
|
||||||
|
"target_format": "openai",
|
||||||
|
"transform_streaming": true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let config = TransformConfig::from_provider(&provider);
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert_eq!(config.source_format, ApiFormat::Anthropic);
|
||||||
|
assert_eq!(config.target_format, ApiFormat::OpenAI);
|
||||||
|
assert!(config.transform_streaming);
|
||||||
|
assert!(config.needs_transform());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_legacy_openrouter_compat_mode_bool() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"openrouter_compat_mode": true
|
||||||
|
}));
|
||||||
|
let config = TransformConfig::from_provider(&provider);
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert_eq!(config.source_format, ApiFormat::Anthropic);
|
||||||
|
assert_eq!(config.target_format, ApiFormat::OpenAI);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_legacy_openrouter_compat_mode_string() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"openrouter_compat_mode": "true"
|
||||||
|
}));
|
||||||
|
let config = TransformConfig::from_provider(&provider);
|
||||||
|
assert!(config.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_legacy_openrouter_compat_mode_number() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"openrouter_compat_mode": 1
|
||||||
|
}));
|
||||||
|
let config = TransformConfig::from_provider(&provider);
|
||||||
|
assert!(config.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_same_format_no_transform() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"format_transform": {
|
||||||
|
"enabled": true,
|
||||||
|
"source_format": "anthropic",
|
||||||
|
"target_format": "anthropic"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let config = TransformConfig::from_provider(&provider);
|
||||||
|
assert!(config.enabled);
|
||||||
|
assert!(!config.needs_transform()); // 相同格式不需要转换
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//! API 格式枚举定义
|
||||||
|
//!
|
||||||
|
//! 定义支持的 API 格式类型,用于格式转换配置
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// API 格式枚举
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ApiFormat {
|
||||||
|
/// Anthropic Messages API
|
||||||
|
Anthropic,
|
||||||
|
/// OpenAI Chat Completions API
|
||||||
|
OpenAI,
|
||||||
|
/// Google Gemini API (预留)
|
||||||
|
Gemini,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiFormat {
|
||||||
|
/// 从字符串解析
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"anthropic" | "claude" => Some(Self::Anthropic),
|
||||||
|
"openai" | "codex" => Some(Self::OpenAI),
|
||||||
|
"gemini" | "google" => Some(Self::Gemini),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换为字符串
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Anthropic => "anthropic",
|
||||||
|
Self::OpenAI => "openai",
|
||||||
|
Self::Gemini => "gemini",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ApiFormat {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_str() {
|
||||||
|
assert_eq!(ApiFormat::from_str("anthropic"), Some(ApiFormat::Anthropic));
|
||||||
|
assert_eq!(ApiFormat::from_str("claude"), Some(ApiFormat::Anthropic));
|
||||||
|
assert_eq!(ApiFormat::from_str("openai"), Some(ApiFormat::OpenAI));
|
||||||
|
assert_eq!(ApiFormat::from_str("codex"), Some(ApiFormat::OpenAI));
|
||||||
|
assert_eq!(ApiFormat::from_str("gemini"), Some(ApiFormat::Gemini));
|
||||||
|
assert_eq!(ApiFormat::from_str("google"), Some(ApiFormat::Gemini));
|
||||||
|
assert_eq!(ApiFormat::from_str("unknown"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_as_str() {
|
||||||
|
assert_eq!(ApiFormat::Anthropic.as_str(), "anthropic");
|
||||||
|
assert_eq!(ApiFormat::OpenAI.as_str(), "openai");
|
||||||
|
assert_eq!(ApiFormat::Gemini.as_str(), "gemini");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//! 通用格式转换模块
|
||||||
|
//!
|
||||||
|
//! 提供 API 格式之间的双向转换,支持:
|
||||||
|
//! - Anthropic ↔ OpenAI
|
||||||
|
//! - Gemini ↔ OpenAI(预留)
|
||||||
|
//!
|
||||||
|
//! ## 使用方式
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use crate::proxy::transform::{config::TransformConfig, registry::get_transformer};
|
||||||
|
//!
|
||||||
|
//! let config = TransformConfig::from_provider(&provider);
|
||||||
|
//! if config.needs_transform() {
|
||||||
|
//! if let Some(transformer) = get_transformer(config.source_format, config.target_format) {
|
||||||
|
//! let transformed = transformer.transform_request(body)?;
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod anthropic_openai;
|
||||||
|
pub mod config;
|
||||||
|
pub mod format;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod traits;
|
||||||
|
|
||||||
|
// 公开导出
|
||||||
|
pub use config::TransformConfig;
|
||||||
|
pub use registry::get_transformer;
|
||||||
|
|
||||||
|
// 以下导出供外部模块使用(如需扩展转换器)
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use format::ApiFormat;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use registry::TRANSFORMER_REGISTRY;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use traits::{BidirectionalTransformer, FormatTransformer};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! 转换器注册表
|
||||||
|
//!
|
||||||
|
//! 管理和获取格式转换器
|
||||||
|
|
||||||
|
use super::{format::ApiFormat, traits::FormatTransformer};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
/// 转换器注册表
|
||||||
|
pub struct TransformerRegistry {
|
||||||
|
transformers: HashMap<(ApiFormat, ApiFormat), Arc<dyn FormatTransformer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransformerRegistry {
|
||||||
|
/// 创建新的注册表
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut registry = Self {
|
||||||
|
transformers: HashMap::new(),
|
||||||
|
};
|
||||||
|
registry.register_defaults();
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册默认转换器
|
||||||
|
fn register_defaults(&mut self) {
|
||||||
|
use super::anthropic_openai::{AnthropicToOpenAITransformer, OpenAIToAnthropicTransformer};
|
||||||
|
|
||||||
|
// Anthropic → OpenAI
|
||||||
|
self.register(Arc::new(AnthropicToOpenAITransformer::new()));
|
||||||
|
|
||||||
|
// OpenAI → Anthropic
|
||||||
|
self.register(Arc::new(OpenAIToAnthropicTransformer::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注册转换器
|
||||||
|
pub fn register(&mut self, transformer: Arc<dyn FormatTransformer>) {
|
||||||
|
let key = (transformer.source_format(), transformer.target_format());
|
||||||
|
self.transformers.insert(key, transformer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取转换器
|
||||||
|
pub fn get(&self, source: ApiFormat, target: ApiFormat) -> Option<Arc<dyn FormatTransformer>> {
|
||||||
|
self.transformers.get(&(source, target)).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否支持指定的转换
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn supports(&self, source: ApiFormat, target: ApiFormat) -> bool {
|
||||||
|
self.transformers.contains_key(&(source, target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TransformerRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全局转换器注册表
|
||||||
|
pub static TRANSFORMER_REGISTRY: LazyLock<TransformerRegistry> =
|
||||||
|
LazyLock::new(TransformerRegistry::new);
|
||||||
|
|
||||||
|
/// 获取转换器的便捷函数
|
||||||
|
pub fn get_transformer(source: ApiFormat, target: ApiFormat) -> Option<Arc<dyn FormatTransformer>> {
|
||||||
|
TRANSFORMER_REGISTRY.get(source, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_registry_has_default_transformers() {
|
||||||
|
let registry = TransformerRegistry::new();
|
||||||
|
|
||||||
|
// Anthropic → OpenAI
|
||||||
|
assert!(registry.supports(ApiFormat::Anthropic, ApiFormat::OpenAI));
|
||||||
|
|
||||||
|
// OpenAI → Anthropic
|
||||||
|
assert!(registry.supports(ApiFormat::OpenAI, ApiFormat::Anthropic));
|
||||||
|
|
||||||
|
// 不支持的转换
|
||||||
|
assert!(!registry.supports(ApiFormat::Gemini, ApiFormat::OpenAI));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_transformer() {
|
||||||
|
let transformer = get_transformer(ApiFormat::Anthropic, ApiFormat::OpenAI);
|
||||||
|
assert!(transformer.is_some());
|
||||||
|
|
||||||
|
let t = transformer.unwrap();
|
||||||
|
assert_eq!(t.source_format(), ApiFormat::Anthropic);
|
||||||
|
assert_eq!(t.target_format(), ApiFormat::OpenAI);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! 格式转换器 Trait 定义
|
||||||
|
//!
|
||||||
|
//! 定义通用的格式转换器接口
|
||||||
|
|
||||||
|
use super::format::ApiFormat;
|
||||||
|
use crate::proxy::error::ProxyError;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::stream::Stream;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
/// 格式转换器 Trait
|
||||||
|
pub trait FormatTransformer: Send + Sync {
|
||||||
|
/// 转换器名称(用于日志)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// 源格式
|
||||||
|
fn source_format(&self) -> ApiFormat;
|
||||||
|
|
||||||
|
/// 目标格式
|
||||||
|
fn target_format(&self) -> ApiFormat;
|
||||||
|
|
||||||
|
/// 转换请求体
|
||||||
|
fn transform_request(&self, body: Value) -> Result<Value, ProxyError>;
|
||||||
|
|
||||||
|
/// 转换非流式响应体
|
||||||
|
fn transform_response(&self, body: Value) -> Result<Value, ProxyError>;
|
||||||
|
|
||||||
|
/// 转换流式响应
|
||||||
|
fn transform_stream(
|
||||||
|
&self,
|
||||||
|
stream: Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>,
|
||||||
|
) -> Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>;
|
||||||
|
|
||||||
|
/// 获取转换后的端点路径
|
||||||
|
fn transform_endpoint(&self, endpoint: &str) -> String {
|
||||||
|
endpoint.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 双向转换器 Trait(可选实现)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub trait BidirectionalTransformer: FormatTransformer {
|
||||||
|
/// 获取反向转换器
|
||||||
|
fn reverse(&self) -> Box<dyn FormatTransformer>;
|
||||||
|
}
|
||||||
@@ -36,10 +36,22 @@ impl PromptService {
|
|||||||
|
|
||||||
state.db.save_prompt(app.as_str(), &prompt)?;
|
state.db.save_prompt(app.as_str(), &prompt)?;
|
||||||
|
|
||||||
// 如果是已启用的提示词,同步更新到对应的文件
|
|
||||||
if is_enabled {
|
if is_enabled {
|
||||||
|
// 启用提示词:写入内容到文件
|
||||||
let target_path = prompt_file_path(&app)?;
|
let target_path = prompt_file_path(&app)?;
|
||||||
write_text_file(&target_path, &prompt.content)?;
|
write_text_file(&target_path, &prompt.content)?;
|
||||||
|
} else {
|
||||||
|
// 禁用提示词:检查是否还有其他已启用的提示词
|
||||||
|
let prompts = state.db.get_prompts(app.as_str())?;
|
||||||
|
let any_enabled = prompts.values().any(|p| p.enabled);
|
||||||
|
|
||||||
|
if !any_enabled {
|
||||||
|
// 所有提示词都已禁用,清空文件
|
||||||
|
let target_path = prompt_file_path(&app)?;
|
||||||
|
if target_path.exists() {
|
||||||
|
write_text_file(&target_path, "")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+158
-14
@@ -21,6 +21,19 @@ use crate::error::format_skill_error;
|
|||||||
|
|
||||||
// ========== 数据结构 ==========
|
// ========== 数据结构 ==========
|
||||||
|
|
||||||
|
/// Skill 同步方式
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SyncMethod {
|
||||||
|
/// 自动选择:优先 symlink,失败时回退到 copy
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// 符号链接(推荐,节省磁盘空间)
|
||||||
|
Symlink,
|
||||||
|
/// 文件复制(兼容模式)
|
||||||
|
Copy,
|
||||||
|
}
|
||||||
|
|
||||||
/// 可发现的技能(来自仓库)
|
/// 可发现的技能(来自仓库)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DiscoverableSkill {
|
pub struct DiscoverableSkill {
|
||||||
@@ -239,6 +252,50 @@ impl SkillService {
|
|||||||
.map(|s| s.to_string_lossy().to_string())
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|| skill.directory.clone());
|
.unwrap_or_else(|| skill.directory.clone());
|
||||||
|
|
||||||
|
// 检查数据库中是否已有同名 directory 的 skill(来自其他仓库)
|
||||||
|
let existing_skills = db.get_all_installed_skills()?;
|
||||||
|
for existing in existing_skills.values() {
|
||||||
|
if existing.directory.eq_ignore_ascii_case(&install_name) {
|
||||||
|
// 检查是否来自同一仓库
|
||||||
|
let same_repo = existing.repo_owner.as_deref() == Some(&skill.repo_owner)
|
||||||
|
&& existing.repo_name.as_deref() == Some(&skill.repo_name);
|
||||||
|
if same_repo {
|
||||||
|
// 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态)
|
||||||
|
let mut updated = existing.clone();
|
||||||
|
updated.apps.set_enabled_for(current_app, true);
|
||||||
|
db.save_skill(&updated)?;
|
||||||
|
Self::sync_to_app_dir(&updated.directory, current_app)?;
|
||||||
|
log::info!(
|
||||||
|
"Skill {} 已存在,更新 {:?} 启用状态",
|
||||||
|
updated.name,
|
||||||
|
current_app
|
||||||
|
);
|
||||||
|
return Ok(updated);
|
||||||
|
} else {
|
||||||
|
// 不同仓库的同名 skill,报错
|
||||||
|
return Err(anyhow!(format_skill_error(
|
||||||
|
"SKILL_DIRECTORY_CONFLICT",
|
||||||
|
&[
|
||||||
|
("directory", &install_name),
|
||||||
|
(
|
||||||
|
"existing_repo",
|
||||||
|
&format!(
|
||||||
|
"{}/{}",
|
||||||
|
existing.repo_owner.as_deref().unwrap_or("unknown"),
|
||||||
|
existing.repo_name.as_deref().unwrap_or("unknown")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"new_repo",
|
||||||
|
&format!("{}/{}", skill.repo_owner, skill.repo_name)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Some("uninstallFirst"),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let dest = ssot_dir.join(&install_name);
|
let dest = ssot_dir.join(&install_name);
|
||||||
|
|
||||||
// 如果已存在则跳过下载
|
// 如果已存在则跳过下载
|
||||||
@@ -305,7 +362,7 @@ impl SkillService {
|
|||||||
db.save_skill(&installed_skill)?;
|
db.save_skill(&installed_skill)?;
|
||||||
|
|
||||||
// 同步到当前应用目录
|
// 同步到当前应用目录
|
||||||
Self::copy_to_app(&install_name, current_app)?;
|
Self::sync_to_app_dir(&install_name, current_app)?;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Skill {} 安装成功,已启用 {:?}",
|
"Skill {} 安装成功,已启用 {:?}",
|
||||||
@@ -368,7 +425,7 @@ impl SkillService {
|
|||||||
|
|
||||||
// 同步文件
|
// 同步文件
|
||||||
if enabled {
|
if enabled {
|
||||||
Self::copy_to_app(&skill.directory, app)?;
|
Self::sync_to_app_dir(&skill.directory, app)?;
|
||||||
} else {
|
} else {
|
||||||
Self::remove_from_app(&skill.directory, app)?;
|
Self::remove_from_app(&skill.directory, app)?;
|
||||||
}
|
}
|
||||||
@@ -566,8 +623,41 @@ impl SkillService {
|
|||||||
|
|
||||||
// ========== 文件同步方法 ==========
|
// ========== 文件同步方法 ==========
|
||||||
|
|
||||||
/// 复制 Skill 到应用目录
|
/// 创建符号链接(跨平台)
|
||||||
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
|
///
|
||||||
|
/// - Unix: 使用 std::os::unix::fs::symlink
|
||||||
|
/// - Windows: 使用 std::os::windows::fs::symlink_dir
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
std::os::unix::fs::symlink(src, dest)
|
||||||
|
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn create_symlink(src: &Path, dest: &Path) -> Result<()> {
|
||||||
|
std::os::windows::fs::symlink_dir(src, dest)
|
||||||
|
.with_context(|| format!("创建符号链接失败: {} -> {}", src.display(), dest.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查路径是否为符号链接
|
||||||
|
fn is_symlink(path: &Path) -> bool {
|
||||||
|
path.symlink_metadata()
|
||||||
|
.map(|m| m.file_type().is_symlink())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前同步方式配置
|
||||||
|
fn get_sync_method() -> SyncMethod {
|
||||||
|
crate::settings::get_skill_sync_method()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 同步 Skill 到应用目录(使用 symlink 或 copy)
|
||||||
|
///
|
||||||
|
/// 根据配置和平台选择最佳同步方式:
|
||||||
|
/// - Auto: 优先尝试 symlink,失败时回退到 copy
|
||||||
|
/// - Symlink: 仅使用 symlink
|
||||||
|
/// - Copy: 仅使用文件复制
|
||||||
|
pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {
|
||||||
let ssot_dir = Self::get_ssot_dir()?;
|
let ssot_dir = Self::get_ssot_dir()?;
|
||||||
let source = ssot_dir.join(directory);
|
let source = ssot_dir.join(directory);
|
||||||
|
|
||||||
@@ -580,25 +670,77 @@ impl SkillService {
|
|||||||
|
|
||||||
let dest = app_dir.join(directory);
|
let dest = app_dir.join(directory);
|
||||||
|
|
||||||
// 如果已存在则先删除
|
// 如果已存在则先删除(无论是 symlink 还是真实目录)
|
||||||
if dest.exists() {
|
if dest.exists() || Self::is_symlink(&dest) {
|
||||||
fs::remove_dir_all(&dest)?;
|
Self::remove_path(&dest)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::copy_dir_recursive(&source, &dest)?;
|
let sync_method = Self::get_sync_method();
|
||||||
|
|
||||||
log::debug!("Skill {directory} 已复制到 {app:?}");
|
match sync_method {
|
||||||
|
SyncMethod::Auto => {
|
||||||
|
// 优先尝试 symlink
|
||||||
|
match Self::create_symlink(&source, &dest) {
|
||||||
|
Ok(()) => {
|
||||||
|
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"Symlink 创建失败,将回退到文件复制: {} -> {}. 错误: {err:#}",
|
||||||
|
source.display(),
|
||||||
|
dest.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback 到 copy
|
||||||
|
Self::copy_dir_recursive(&source, &dest)?;
|
||||||
|
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
|
||||||
|
}
|
||||||
|
SyncMethod::Symlink => {
|
||||||
|
Self::create_symlink(&source, &dest)?;
|
||||||
|
log::debug!("Skill {directory} 已通过 symlink 同步到 {app:?}");
|
||||||
|
}
|
||||||
|
SyncMethod::Copy => {
|
||||||
|
Self::copy_dir_recursive(&source, &dest)?;
|
||||||
|
log::debug!("Skill {directory} 已通过复制同步到 {app:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从应用目录删除 Skill
|
/// 复制 Skill 到应用目录(保留用于向后兼容)
|
||||||
|
#[deprecated(note = "请使用 sync_to_app_dir() 代替")]
|
||||||
|
pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> {
|
||||||
|
Self::sync_to_app_dir(directory, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除路径(支持 symlink 和真实目录)
|
||||||
|
fn remove_path(path: &Path) -> Result<()> {
|
||||||
|
if Self::is_symlink(path) {
|
||||||
|
// 符号链接:仅删除链接本身,不影响源文件
|
||||||
|
#[cfg(unix)]
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
#[cfg(windows)]
|
||||||
|
fs::remove_dir(path)?; // Windows 的目录 symlink 需要用 remove_dir
|
||||||
|
} else if path.is_dir() {
|
||||||
|
// 真实目录:递归删除
|
||||||
|
fs::remove_dir_all(path)?;
|
||||||
|
} else if path.exists() {
|
||||||
|
// 普通文件
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
|
||||||
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
||||||
let app_dir = Self::get_app_skills_dir(app)?;
|
let app_dir = Self::get_app_skills_dir(app)?;
|
||||||
let skill_path = app_dir.join(directory);
|
let skill_path = app_dir.join(directory);
|
||||||
|
|
||||||
if skill_path.exists() {
|
if skill_path.exists() || Self::is_symlink(&skill_path) {
|
||||||
fs::remove_dir_all(&skill_path)?;
|
Self::remove_path(&skill_path)?;
|
||||||
log::debug!("Skill {directory} 已从 {app:?} 删除");
|
log::debug!("Skill {directory} 已从 {app:?} 删除");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,7 +753,7 @@ impl SkillService {
|
|||||||
|
|
||||||
for skill in skills.values() {
|
for skill in skills.values() {
|
||||||
if skill.apps.is_enabled_for(app) {
|
if skill.apps.is_enabled_for(app) {
|
||||||
Self::copy_to_app(&skill.directory, app)?;
|
Self::sync_to_app_dir(&skill.directory, app)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,10 +977,12 @@ impl SkillService {
|
|||||||
Ok(meta)
|
Ok(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 去重技能列表
|
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
|
||||||
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
||||||
let mut seen = HashMap::new();
|
let mut seen = HashMap::new();
|
||||||
skills.retain(|skill| {
|
skills.retain(|skill| {
|
||||||
|
// 使用完整 key(owner/repo:directory)作为唯一标识
|
||||||
|
// 这样不同仓库的同名 skill 会分开显示
|
||||||
let unique_key = skill.key.to_lowercase();
|
let unique_key = skill.key.to_lowercase();
|
||||||
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
|
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) {
|
||||||
e.insert(true);
|
e.insert(true);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::sync::{OnceLock, RwLock};
|
|||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::services::skill::SyncMethod;
|
||||||
|
|
||||||
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints)
|
/// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -108,6 +109,11 @@ pub struct AppSettings {
|
|||||||
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
/// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub current_provider_opencode: Option<String>,
|
pub current_provider_opencode: Option<String>,
|
||||||
|
|
||||||
|
// ===== Skill 同步设置 =====
|
||||||
|
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||||
|
#[serde(default)]
|
||||||
|
pub skill_sync_method: SyncMethod,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_show_in_tray() -> bool {
|
fn default_show_in_tray() -> bool {
|
||||||
@@ -136,6 +142,7 @@ impl Default for AppSettings {
|
|||||||
current_provider_codex: None,
|
current_provider_codex: None,
|
||||||
current_provider_gemini: None,
|
current_provider_gemini: None,
|
||||||
current_provider_opencode: None,
|
current_provider_opencode: None,
|
||||||
|
skill_sync_method: SyncMethod::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,3 +389,16 @@ pub fn get_effective_current_provider(
|
|||||||
// Fallback 到数据库的 is_current
|
// Fallback 到数据库的 is_current
|
||||||
db.get_current_provider(app_type.as_str())
|
db.get_current_provider(app_type.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Skill 同步方式管理函数 =====
|
||||||
|
|
||||||
|
/// 获取 Skill 同步方式配置
|
||||||
|
pub fn get_skill_sync_method() -> SyncMethod {
|
||||||
|
settings_store()
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
log::warn!("设置锁已毒化,使用恢复值: {e}");
|
||||||
|
e.into_inner()
|
||||||
|
})
|
||||||
|
.skill_sync_method
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CC Switch",
|
"productName": "CC Switch",
|
||||||
"version": "3.10.1",
|
"version": "3.10.2",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+6
-7
@@ -750,13 +750,6 @@ function App() {
|
|||||||
>
|
>
|
||||||
CC Switch
|
CC Switch
|
||||||
</a>
|
</a>
|
||||||
<UpdateBadge
|
|
||||||
onClick={() => {
|
|
||||||
setSettingsDefaultTab("about");
|
|
||||||
setCurrentView("settings");
|
|
||||||
}}
|
|
||||||
className="absolute -top-4 -right-4"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -770,6 +763,12 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<UpdateBadge
|
||||||
|
onClick={() => {
|
||||||
|
setSettingsDefaultTab("about");
|
||||||
|
setCurrentView("settings");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{isCurrentAppTakeoverActive && (
|
{isCurrentAppTakeoverActive && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useUpdate } from "@/contexts/UpdateContext";
|
import { useUpdate } from "@/contexts/UpdateContext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowUpCircle } from "lucide-react";
|
||||||
|
|
||||||
interface UpdateBadgeProps {
|
interface UpdateBadgeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -30,17 +31,12 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
|||||||
aria-label={title}
|
aria-label={title}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
relative h-6 w-6 rounded-full
|
relative h-8 w-8 rounded-full
|
||||||
${isActive ? "text-blue-600 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-500/10" : "text-muted-foreground hover:bg-muted/60"}
|
${isActive ? "text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-500/10" : "text-muted-foreground hover:bg-muted/60"}
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<span
|
<ArrowUpCircle className="h-5 w-5" />
|
||||||
className={`
|
|
||||||
absolute inset-0 m-auto h-2 w-2 rounded-full ring-1 ring-background
|
|
||||||
${isActive ? "bg-blue-500 dark:bg-blue-400" : "bg-blue-300/70 dark:bg-blue-300/60"}
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,33 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
X,
|
X,
|
||||||
|
ArrowLeftRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ProviderTestConfig, ProviderProxyConfig } from "@/types";
|
import type {
|
||||||
|
ProviderTestConfig,
|
||||||
|
ProviderProxyConfig,
|
||||||
|
FormatTransformConfig,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
interface ProviderAdvancedConfigProps {
|
interface ProviderAdvancedConfigProps {
|
||||||
testConfig: ProviderTestConfig;
|
testConfig: ProviderTestConfig;
|
||||||
proxyConfig: ProviderProxyConfig;
|
proxyConfig: ProviderProxyConfig;
|
||||||
|
formatTransform?: FormatTransformConfig;
|
||||||
onTestConfigChange: (config: ProviderTestConfig) => void;
|
onTestConfigChange: (config: ProviderTestConfig) => void;
|
||||||
onProxyConfigChange: (config: ProviderProxyConfig) => void;
|
onProxyConfigChange: (config: ProviderProxyConfig) => void;
|
||||||
|
onFormatTransformChange?: (config: FormatTransformConfig) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 ProviderProxyConfig 构建完整 URL */
|
/** 从 ProviderProxyConfig 构建完整 URL */
|
||||||
@@ -71,14 +85,19 @@ function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
|
|||||||
export function ProviderAdvancedConfig({
|
export function ProviderAdvancedConfig({
|
||||||
testConfig,
|
testConfig,
|
||||||
proxyConfig,
|
proxyConfig,
|
||||||
|
formatTransform,
|
||||||
onTestConfigChange,
|
onTestConfigChange,
|
||||||
onProxyConfigChange,
|
onProxyConfigChange,
|
||||||
|
onFormatTransformChange,
|
||||||
}: ProviderAdvancedConfigProps) {
|
}: ProviderAdvancedConfigProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
|
||||||
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
|
||||||
proxyConfig.enabled,
|
proxyConfig.enabled,
|
||||||
);
|
);
|
||||||
|
const [isFormatTransformOpen, setIsFormatTransformOpen] = useState(
|
||||||
|
formatTransform?.enabled ?? false,
|
||||||
|
);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
// 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建)
|
// 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建)
|
||||||
@@ -97,6 +116,11 @@ export function ProviderAdvancedConfig({
|
|||||||
setIsProxyConfigOpen(proxyConfig.enabled);
|
setIsProxyConfigOpen(proxyConfig.enabled);
|
||||||
}, [proxyConfig.enabled]);
|
}, [proxyConfig.enabled]);
|
||||||
|
|
||||||
|
// 同步外部 formatTransform.enabled 变化到展开状态
|
||||||
|
useEffect(() => {
|
||||||
|
setIsFormatTransformOpen(formatTransform?.enabled ?? false);
|
||||||
|
}, [formatTransform?.enabled]);
|
||||||
|
|
||||||
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
|
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUserTyping) {
|
if (!isUserTyping) {
|
||||||
@@ -450,6 +474,135 @@ export function ProviderAdvancedConfig({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 格式转换配置 */}
|
||||||
|
{onFormatTransformChange && (
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
|
||||||
|
onClick={() => setIsFormatTransformOpen(!isFormatTransformOpen)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ArrowLeftRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("providerAdvanced.formatTransform")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
htmlFor="format-transform-enabled"
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("providerAdvanced.enableFormatTransform")}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="format-transform-enabled"
|
||||||
|
checked={formatTransform?.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onFormatTransformChange({
|
||||||
|
...(formatTransform ?? { enabled: false }),
|
||||||
|
enabled: checked,
|
||||||
|
});
|
||||||
|
if (checked) setIsFormatTransformOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isFormatTransformOpen ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden transition-all duration-200",
|
||||||
|
isFormatTransformOpen
|
||||||
|
? "max-h-[500px] opacity-100"
|
||||||
|
: "max-h-0 opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="border-t border-border/50 p-4 space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("providerAdvanced.formatTransformDesc")}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="source-format">
|
||||||
|
{t("providerAdvanced.sourceFormat")}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formatTransform?.sourceFormat ?? "anthropic"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onFormatTransformChange({
|
||||||
|
...(formatTransform ?? { enabled: false }),
|
||||||
|
sourceFormat: value as "anthropic" | "openai",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!formatTransform?.enabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="source-format">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="anthropic">
|
||||||
|
Anthropic (Claude)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="openai">OpenAI</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="target-format">
|
||||||
|
{t("providerAdvanced.targetFormat")}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formatTransform?.targetFormat ?? "openai"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onFormatTransformChange({
|
||||||
|
...(formatTransform ?? { enabled: false }),
|
||||||
|
targetFormat: value as "anthropic" | "openai",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!formatTransform?.enabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="target-format">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="anthropic">
|
||||||
|
Anthropic (Claude)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="openai">OpenAI</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="transform-streaming"
|
||||||
|
checked={formatTransform?.transformStreaming ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onFormatTransformChange({
|
||||||
|
...(formatTransform ?? { enabled: false }),
|
||||||
|
transformStreaming: checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!formatTransform?.enabled}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="transform-streaming" className="text-sm">
|
||||||
|
{t("providerAdvanced.transformStreaming")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ProviderMeta,
|
ProviderMeta,
|
||||||
ProviderTestConfig,
|
ProviderTestConfig,
|
||||||
ProviderProxyConfig,
|
ProviderProxyConfig,
|
||||||
|
FormatTransformConfig,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import {
|
import {
|
||||||
providerPresets,
|
providerPresets,
|
||||||
@@ -168,6 +169,9 @@ export function ProviderForm({
|
|||||||
const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(
|
const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(
|
||||||
() => initialData?.meta?.proxyConfig ?? { enabled: false },
|
() => initialData?.meta?.proxyConfig ?? { enabled: false },
|
||||||
);
|
);
|
||||||
|
const [formatTransform, setFormatTransform] = useState<FormatTransformConfig>(
|
||||||
|
() => initialData?.meta?.formatTransform ?? { enabled: false },
|
||||||
|
);
|
||||||
|
|
||||||
// 使用 category hook
|
// 使用 category hook
|
||||||
const { category } = useProviderCategory({
|
const { category } = useProviderCategory({
|
||||||
@@ -940,6 +944,7 @@ export function ProviderForm({
|
|||||||
// 添加高级配置
|
// 添加高级配置
|
||||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||||
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
|
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
|
||||||
|
formatTransform: formatTransform.enabled ? formatTransform : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
@@ -1464,8 +1469,10 @@ export function ProviderForm({
|
|||||||
<ProviderAdvancedConfig
|
<ProviderAdvancedConfig
|
||||||
testConfig={testConfig}
|
testConfig={testConfig}
|
||||||
proxyConfig={proxyConfig}
|
proxyConfig={proxyConfig}
|
||||||
|
formatTransform={formatTransform}
|
||||||
onTestConfigChange={setTestConfig}
|
onTestConfigChange={setTestConfig}
|
||||||
onProxyConfigChange={setProxyConfig}
|
onProxyConfigChange={setProxyConfig}
|
||||||
|
onFormatTransformChange={setFormatTransform}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showButtons && (
|
{showButtons && (
|
||||||
|
|||||||
@@ -5,6 +5,42 @@ interface UseModelStateProps {
|
|||||||
onConfigChange: (config: string) => void;
|
onConfigChange: (config: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse model values from settings config JSON
|
||||||
|
*/
|
||||||
|
function parseModelsFromConfig(settingsConfig: string) {
|
||||||
|
try {
|
||||||
|
const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};
|
||||||
|
const env = cfg?.env || {};
|
||||||
|
const model =
|
||||||
|
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
|
||||||
|
const reasoning =
|
||||||
|
typeof env.ANTHROPIC_REASONING_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_REASONING_MODEL
|
||||||
|
: "";
|
||||||
|
const small =
|
||||||
|
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_SMALL_FAST_MODEL
|
||||||
|
: "";
|
||||||
|
const haiku =
|
||||||
|
typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||||
|
: small || model;
|
||||||
|
const sonnet =
|
||||||
|
typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||||
|
: model || small;
|
||||||
|
const opus =
|
||||||
|
typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||||
|
: model || small;
|
||||||
|
|
||||||
|
return { model, reasoning, haiku, sonnet, opus };
|
||||||
|
} catch {
|
||||||
|
return { model: "", reasoning: "", haiku: "", sonnet: "", opus: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理模型选择状态
|
* 管理模型选择状态
|
||||||
* 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型
|
* 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型
|
||||||
@@ -13,11 +49,22 @@ export function useModelState({
|
|||||||
settingsConfig,
|
settingsConfig,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
}: UseModelStateProps) {
|
}: UseModelStateProps) {
|
||||||
const [claudeModel, setClaudeModel] = useState("");
|
// Initialize state by parsing config directly (fixes edit mode backfill)
|
||||||
const [reasoningModel, setReasoningModel] = useState("");
|
const [claudeModel, setClaudeModel] = useState(
|
||||||
const [defaultHaikuModel, setDefaultHaikuModel] = useState("");
|
() => parseModelsFromConfig(settingsConfig).model,
|
||||||
const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
|
);
|
||||||
const [defaultOpusModel, setDefaultOpusModel] = useState("");
|
const [reasoningModel, setReasoningModel] = useState(
|
||||||
|
() => parseModelsFromConfig(settingsConfig).reasoning,
|
||||||
|
);
|
||||||
|
const [defaultHaikuModel, setDefaultHaikuModel] = useState(
|
||||||
|
() => parseModelsFromConfig(settingsConfig).haiku,
|
||||||
|
);
|
||||||
|
const [defaultSonnetModel, setDefaultSonnetModel] = useState(
|
||||||
|
() => parseModelsFromConfig(settingsConfig).sonnet,
|
||||||
|
);
|
||||||
|
const [defaultOpusModel, setDefaultOpusModel] = useState(
|
||||||
|
() => parseModelsFromConfig(settingsConfig).opus,
|
||||||
|
);
|
||||||
|
|
||||||
const isUserEditingRef = useRef(false);
|
const isUserEditingRef = useRef(false);
|
||||||
const lastConfigRef = useRef(settingsConfig);
|
const lastConfigRef = useRef(settingsConfig);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { LanguageSettings } from "@/components/settings/LanguageSettings";
|
|||||||
import { ThemeSettings } from "@/components/settings/ThemeSettings";
|
import { ThemeSettings } from "@/components/settings/ThemeSettings";
|
||||||
import { WindowSettings } from "@/components/settings/WindowSettings";
|
import { WindowSettings } from "@/components/settings/WindowSettings";
|
||||||
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
|
import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings";
|
||||||
|
import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings";
|
||||||
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
import { DirectorySettings } from "@/components/settings/DirectorySettings";
|
||||||
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||||
import { AboutSection } from "@/components/settings/AboutSection";
|
import { AboutSection } from "@/components/settings/AboutSection";
|
||||||
@@ -249,6 +250,12 @@ export function SettingsPage({
|
|||||||
settings={settings}
|
settings={settings}
|
||||||
onChange={handleAutoSave}
|
onChange={handleAutoSave}
|
||||||
/>
|
/>
|
||||||
|
<SkillSyncMethodSettings
|
||||||
|
value={settings.skillSyncMethod ?? "auto"}
|
||||||
|
onChange={(method) =>
|
||||||
|
handleAutoSave({ skillSyncMethod: method })
|
||||||
|
}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { SkillSyncMethod } from "@/types";
|
||||||
|
|
||||||
|
export interface SkillSyncMethodSettingsProps {
|
||||||
|
value: SkillSyncMethod;
|
||||||
|
onChange: (value: SkillSyncMethod) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillSyncMethodSettings({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: SkillSyncMethodSettingsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Handle default values: undefined or "auto" defaults to symlink display
|
||||||
|
const displayValue = value === "copy" ? "copy" : "symlink";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium">{t("settings.skillSync.title")}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.skillSync.description")}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1">
|
||||||
|
<SyncMethodButton
|
||||||
|
active={displayValue === "symlink"}
|
||||||
|
onClick={() => onChange("symlink")}
|
||||||
|
>
|
||||||
|
{t("settings.skillSync.symlink")}
|
||||||
|
</SyncMethodButton>
|
||||||
|
<SyncMethodButton
|
||||||
|
active={displayValue === "copy"}
|
||||||
|
onClick={() => onChange("copy")}
|
||||||
|
>
|
||||||
|
{t("settings.skillSync.copy")}
|
||||||
|
</SyncMethodButton>
|
||||||
|
</div>
|
||||||
|
{displayValue === "symlink" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.skillSync.symlinkHint")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncMethodButtonProps {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncMethodButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: SyncMethodButtonProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
size="sm"
|
||||||
|
variant={active ? "default" : "ghost"}
|
||||||
|
className={cn(
|
||||||
|
"min-w-[96px]",
|
||||||
|
active
|
||||||
|
? "shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,10 +65,17 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
const addRepoMutation = useAddSkillRepo();
|
const addRepoMutation = useAddSkillRepo();
|
||||||
const removeRepoMutation = useRemoveSkillRepo();
|
const removeRepoMutation = useRemoveSkillRepo();
|
||||||
|
|
||||||
// 已安装的 directory 集合
|
// 已安装的 skill key 集合(使用 directory + repoOwner + repoName 组合判断)
|
||||||
const installedDirs = useMemo(() => {
|
const installedKeys = useMemo(() => {
|
||||||
if (!installedSkills) return new Set<string>();
|
if (!installedSkills) return new Set<string>();
|
||||||
return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
|
return new Set(
|
||||||
|
installedSkills.map((s) => {
|
||||||
|
// 构建唯一 key:directory + repoOwner + repoName
|
||||||
|
const owner = s.repoOwner?.toLowerCase() || "";
|
||||||
|
const name = s.repoName?.toLowerCase() || "";
|
||||||
|
return `${s.directory.toLowerCase()}:${owner}:${name}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
}, [installedSkills]);
|
}, [installedSkills]);
|
||||||
|
|
||||||
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
|
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
|
||||||
@@ -80,12 +87,14 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
|||||||
const installName =
|
const installName =
|
||||||
d.directory.split("/").pop()?.toLowerCase() ||
|
d.directory.split("/").pop()?.toLowerCase() ||
|
||||||
d.directory.toLowerCase();
|
d.directory.toLowerCase();
|
||||||
|
// 使用 directory + repoOwner + repoName 组合判断是否已安装
|
||||||
|
const key = `${installName}:${d.repoOwner.toLowerCase()}:${d.repoName.toLowerCase()}`;
|
||||||
return {
|
return {
|
||||||
...d,
|
...d,
|
||||||
installed: installedDirs.has(installName),
|
installed: installedKeys.has(key),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [discoverableSkills, installedDirs]);
|
}, [discoverableSkills, installedKeys]);
|
||||||
|
|
||||||
const loading = loadingDiscoverable || fetchingDiscoverable;
|
const loading = loadingDiscoverable || fetchingDiscoverable;
|
||||||
|
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
name: "RightCode",
|
name: "RightCode",
|
||||||
websiteUrl: "https://www.right.codes",
|
websiteUrl: "https://www.right.codes",
|
||||||
apiKeyUrl: "https://www.right.codes/register?aff=0bdf9bfa",
|
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: "https://www.right.codes/claude",
|
ANTHROPIC_BASE_URL: "https://www.right.codes/claude",
|
||||||
@@ -460,6 +460,8 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "third_party",
|
category: "third_party",
|
||||||
|
isPartner: true,
|
||||||
|
partnerPromotionKey: "rightcode",
|
||||||
icon: "rc",
|
icon: "rc",
|
||||||
iconColor: "#E96B2C",
|
iconColor: "#E96B2C",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ requires_openai_auth = true`,
|
|||||||
{
|
{
|
||||||
name: "RightCode",
|
name: "RightCode",
|
||||||
websiteUrl: "https://www.right.codes",
|
websiteUrl: "https://www.right.codes",
|
||||||
apiKeyUrl: "https://www.right.codes/register?aff=0bdf9bfa",
|
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
|
||||||
auth: generateThirdPartyAuth(""),
|
auth: generateThirdPartyAuth(""),
|
||||||
config: generateThirdPartyConfig(
|
config: generateThirdPartyConfig(
|
||||||
"rightcode",
|
"rightcode",
|
||||||
@@ -203,6 +203,8 @@ requires_openai_auth = true`,
|
|||||||
"gpt-5.2",
|
"gpt-5.2",
|
||||||
),
|
),
|
||||||
category: "third_party",
|
category: "third_party",
|
||||||
|
isPartner: true,
|
||||||
|
partnerPromotionKey: "rightcode",
|
||||||
icon: "rc",
|
icon: "rc",
|
||||||
iconColor: "#E96B2C",
|
iconColor: "#E96B2C",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
name: "RightCode",
|
name: "RightCode",
|
||||||
websiteUrl: "https://www.right.codes",
|
websiteUrl: "https://www.right.codes",
|
||||||
apiKeyUrl: "https://www.right.codes/register?aff=0bdf9bfa",
|
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
npm: "@ai-sdk/openai",
|
npm: "@ai-sdk/openai",
|
||||||
name: "RightCode",
|
name: "RightCode",
|
||||||
@@ -663,6 +663,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "third_party",
|
category: "third_party",
|
||||||
|
isPartner: true,
|
||||||
|
partnerPromotionKey: "rightcode",
|
||||||
icon: "rc",
|
icon: "rc",
|
||||||
iconColor: "#E96B2C",
|
iconColor: "#E96B2C",
|
||||||
templateValues: {
|
templateValues: {
|
||||||
|
|||||||
@@ -280,6 +280,13 @@
|
|||||||
"geminiDesc": "Google Gemini CLI",
|
"geminiDesc": "Google Gemini CLI",
|
||||||
"opencodeDesc": "OpenCode CLI"
|
"opencodeDesc": "OpenCode CLI"
|
||||||
},
|
},
|
||||||
|
"skillSync": {
|
||||||
|
"title": "Skill Sync Method",
|
||||||
|
"description": "Choose how to sync Skills files",
|
||||||
|
"symlink": "Symlink",
|
||||||
|
"copy": "Copy Files",
|
||||||
|
"symlinkHint": "Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows"
|
||||||
|
},
|
||||||
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
||||||
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
|
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
|
||||||
"appConfigDir": "CC Switch Configuration Directory",
|
"appConfigDir": "CC Switch Configuration Directory",
|
||||||
@@ -393,7 +400,8 @@
|
|||||||
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
|
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
|
||||||
"dmxapi": "Claude Code exclusive model 66% OFF now!",
|
"dmxapi": "Claude Code exclusive model 66% OFF now!",
|
||||||
"cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up",
|
"cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up",
|
||||||
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!"
|
"aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!",
|
||||||
|
"rightcode": "RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!"
|
||||||
},
|
},
|
||||||
"parameterConfig": "Parameter Config - {{name}} *",
|
"parameterConfig": "Parameter Config - {{name}} *",
|
||||||
"mainModel": "Main Model (optional)",
|
"mainModel": "Main Model (optional)",
|
||||||
@@ -472,7 +480,13 @@
|
|||||||
"useCustomProxy": "Use separate proxy",
|
"useCustomProxy": "Use separate proxy",
|
||||||
"proxyConfigDesc": "Configure separate network proxy for this provider. Uses system proxy or global settings when disabled.",
|
"proxyConfigDesc": "Configure separate network proxy for this provider. Uses system proxy or global settings when disabled.",
|
||||||
"proxyUsername": "Username (optional)",
|
"proxyUsername": "Username (optional)",
|
||||||
"proxyPassword": "Password (optional)"
|
"proxyPassword": "Password (optional)",
|
||||||
|
"formatTransform": "Format Transform",
|
||||||
|
"enableFormatTransform": "Enable Transform",
|
||||||
|
"formatTransformDesc": "Transform requests and responses between different API formats. Useful for providers using OpenAI-compatible interfaces.",
|
||||||
|
"sourceFormat": "Source Format (Client)",
|
||||||
|
"targetFormat": "Target Format (Upstream)",
|
||||||
|
"transformStreaming": "Transform Streaming Responses"
|
||||||
},
|
},
|
||||||
"codexConfig": {
|
"codexConfig": {
|
||||||
"authJson": "auth.json (JSON) *",
|
"authJson": "auth.json (JSON) *",
|
||||||
@@ -964,6 +978,7 @@
|
|||||||
"downloadTimeoutHint": "Please check network connection or retry later",
|
"downloadTimeoutHint": "Please check network connection or retry later",
|
||||||
"skillPathNotFound": "Skill path '{{path}}' not found in repository {{owner}}/{{name}}",
|
"skillPathNotFound": "Skill path '{{path}}' not found in repository {{owner}}/{{name}}",
|
||||||
"skillDirNotFound": "Skill directory not found: {{path}}",
|
"skillDirNotFound": "Skill directory not found: {{path}}",
|
||||||
|
"directoryConflict": "Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}",
|
||||||
"emptyArchive": "Downloaded archive is empty",
|
"emptyArchive": "Downloaded archive is empty",
|
||||||
"downloadFailed": "Download failed: HTTP {{status}}",
|
"downloadFailed": "Download failed: HTTP {{status}}",
|
||||||
"allBranchesFailed": "All branches failed, tried: {{branches}}",
|
"allBranchesFailed": "All branches failed, tried: {{branches}}",
|
||||||
@@ -982,7 +997,8 @@
|
|||||||
"retryLater": "Please retry later",
|
"retryLater": "Please retry later",
|
||||||
"checkRepoUrl": "Please check repository URL and branch name",
|
"checkRepoUrl": "Please check repository URL and branch name",
|
||||||
"checkDiskSpace": "Please check disk space",
|
"checkDiskSpace": "Please check disk space",
|
||||||
"checkPermission": "Please check directory permissions"
|
"checkPermission": "Please check directory permissions",
|
||||||
|
"uninstallFirst": "Please uninstall the existing skill with the same name first"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"repo": {
|
"repo": {
|
||||||
|
|||||||
@@ -280,6 +280,13 @@
|
|||||||
"geminiDesc": "Google Gemini CLI",
|
"geminiDesc": "Google Gemini CLI",
|
||||||
"opencodeDesc": "OpenCode CLI"
|
"opencodeDesc": "OpenCode CLI"
|
||||||
},
|
},
|
||||||
|
"skillSync": {
|
||||||
|
"title": "スキル同期方式",
|
||||||
|
"description": "スキルファイルの同期方法を選択",
|
||||||
|
"symlink": "シンボリックリンク",
|
||||||
|
"copy": "ファイルコピー",
|
||||||
|
"symlinkHint": "シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります"
|
||||||
|
},
|
||||||
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
|
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
|
||||||
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
|
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
|
||||||
"appConfigDir": "CC Switch 設定ディレクトリ",
|
"appConfigDir": "CC Switch 設定ディレクトリ",
|
||||||
@@ -393,7 +400,8 @@
|
|||||||
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)",
|
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)",
|
||||||
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!",
|
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!",
|
||||||
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ",
|
"cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ",
|
||||||
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!"
|
"aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!",
|
||||||
|
"rightcode": "RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!"
|
||||||
},
|
},
|
||||||
"parameterConfig": "パラメーター設定 - {{name}} *",
|
"parameterConfig": "パラメーター設定 - {{name}} *",
|
||||||
"mainModel": "メインモデル(任意)",
|
"mainModel": "メインモデル(任意)",
|
||||||
@@ -472,7 +480,13 @@
|
|||||||
"useCustomProxy": "個別プロキシを使用",
|
"useCustomProxy": "個別プロキシを使用",
|
||||||
"proxyConfigDesc": "このプロバイダーに個別のネットワークプロキシを設定します。無効の場合はシステムプロキシまたはグローバル設定を使用します。",
|
"proxyConfigDesc": "このプロバイダーに個別のネットワークプロキシを設定します。無効の場合はシステムプロキシまたはグローバル設定を使用します。",
|
||||||
"proxyUsername": "ユーザー名(任意)",
|
"proxyUsername": "ユーザー名(任意)",
|
||||||
"proxyPassword": "パスワード(任意)"
|
"proxyPassword": "パスワード(任意)",
|
||||||
|
"formatTransform": "フォーマット変換",
|
||||||
|
"enableFormatTransform": "変換を有効化",
|
||||||
|
"formatTransformDesc": "リクエストとレスポンスを異なる API フォーマット間で変換します。OpenAI 互換インターフェースを使用するプロバイダーに適しています。",
|
||||||
|
"sourceFormat": "ソースフォーマット(クライアント)",
|
||||||
|
"targetFormat": "ターゲットフォーマット(上流)",
|
||||||
|
"transformStreaming": "ストリーミングレスポンスを変換"
|
||||||
},
|
},
|
||||||
"codexConfig": {
|
"codexConfig": {
|
||||||
"authJson": "auth.json (JSON) *",
|
"authJson": "auth.json (JSON) *",
|
||||||
@@ -964,6 +978,7 @@
|
|||||||
"downloadTimeoutHint": "ネットワークを確認するか、時間をおいて再試行してください",
|
"downloadTimeoutHint": "ネットワークを確認するか、時間をおいて再試行してください",
|
||||||
"skillPathNotFound": "リポジトリ {{owner}}/{{name}} にスキルパス '{{path}}' がありません",
|
"skillPathNotFound": "リポジトリ {{owner}}/{{name}} にスキルパス '{{path}}' がありません",
|
||||||
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
|
"skillDirNotFound": "スキルディレクトリが見つかりません: {{path}}",
|
||||||
|
"directoryConflict": "スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません",
|
||||||
"emptyArchive": "ダウンロードしたアーカイブが空です",
|
"emptyArchive": "ダウンロードしたアーカイブが空です",
|
||||||
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
|
"downloadFailed": "ダウンロードに失敗しました: HTTP {{status}}",
|
||||||
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
|
"allBranchesFailed": "すべてのブランチで失敗しました。試行: {{branches}}",
|
||||||
@@ -982,7 +997,8 @@
|
|||||||
"retryLater": "時間をおいて再試行してください",
|
"retryLater": "時間をおいて再試行してください",
|
||||||
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
|
"checkRepoUrl": "リポジトリ URL とブランチ名を確認してください",
|
||||||
"checkDiskSpace": "ディスク容量を確認してください",
|
"checkDiskSpace": "ディスク容量を確認してください",
|
||||||
"checkPermission": "ディレクトリの権限を確認してください"
|
"checkPermission": "ディレクトリの権限を確認してください",
|
||||||
|
"uninstallFirst": "同名のスキルを先にアンインストールしてください"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"repo": {
|
"repo": {
|
||||||
|
|||||||
@@ -280,6 +280,13 @@
|
|||||||
"geminiDesc": "Google Gemini CLI",
|
"geminiDesc": "Google Gemini CLI",
|
||||||
"opencodeDesc": "OpenCode CLI"
|
"opencodeDesc": "OpenCode CLI"
|
||||||
},
|
},
|
||||||
|
"skillSync": {
|
||||||
|
"title": "Skill 同步方式",
|
||||||
|
"description": "选择 Skills 的文件同步策略",
|
||||||
|
"symlink": "软连接",
|
||||||
|
"copy": "文件复制",
|
||||||
|
"symlinkHint": "软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式"
|
||||||
|
},
|
||||||
"configDirectoryOverride": "配置目录覆盖(高级)",
|
"configDirectoryOverride": "配置目录覆盖(高级)",
|
||||||
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
|
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
|
||||||
"appConfigDir": "CC Switch 配置目录",
|
"appConfigDir": "CC Switch 配置目录",
|
||||||
@@ -393,7 +400,8 @@
|
|||||||
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
|
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
|
||||||
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!",
|
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!",
|
||||||
"cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠",
|
"cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠",
|
||||||
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!"
|
"aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!",
|
||||||
|
"rightcode": "RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!"
|
||||||
},
|
},
|
||||||
"parameterConfig": "参数配置 - {{name}} *",
|
"parameterConfig": "参数配置 - {{name}} *",
|
||||||
"mainModel": "主模型 (可选)",
|
"mainModel": "主模型 (可选)",
|
||||||
@@ -472,7 +480,13 @@
|
|||||||
"useCustomProxy": "使用单独代理",
|
"useCustomProxy": "使用单独代理",
|
||||||
"proxyConfigDesc": "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。",
|
"proxyConfigDesc": "为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。",
|
||||||
"proxyUsername": "用户名(可选)",
|
"proxyUsername": "用户名(可选)",
|
||||||
"proxyPassword": "密码(可选)"
|
"proxyPassword": "密码(可选)",
|
||||||
|
"formatTransform": "格式转换",
|
||||||
|
"enableFormatTransform": "启用转换",
|
||||||
|
"formatTransformDesc": "将请求和响应在不同 API 格式之间转换。适用于使用 OpenAI 兼容接口的供应商。",
|
||||||
|
"sourceFormat": "源格式(客户端)",
|
||||||
|
"targetFormat": "目标格式(上游)",
|
||||||
|
"transformStreaming": "转换流式响应"
|
||||||
},
|
},
|
||||||
"codexConfig": {
|
"codexConfig": {
|
||||||
"authJson": "auth.json (JSON) *",
|
"authJson": "auth.json (JSON) *",
|
||||||
@@ -964,6 +978,7 @@
|
|||||||
"downloadTimeoutHint": "请检查网络连接或稍后重试",
|
"downloadTimeoutHint": "请检查网络连接或稍后重试",
|
||||||
"skillPathNotFound": "仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'",
|
"skillPathNotFound": "仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'",
|
||||||
"skillDirNotFound": "技能目录不存在:{{path}}",
|
"skillDirNotFound": "技能目录不存在:{{path}}",
|
||||||
|
"directoryConflict": "技能目录 '{{directory}}' 已被 {{existing_repo}} 占用,无法从 {{new_repo}} 安装",
|
||||||
"emptyArchive": "下载的压缩包为空",
|
"emptyArchive": "下载的压缩包为空",
|
||||||
"downloadFailed": "下载失败:HTTP {{status}}",
|
"downloadFailed": "下载失败:HTTP {{status}}",
|
||||||
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
|
"allBranchesFailed": "所有分支下载失败,尝试了:{{branches}}",
|
||||||
@@ -982,7 +997,8 @@
|
|||||||
"retryLater": "请稍后重试",
|
"retryLater": "请稍后重试",
|
||||||
"checkRepoUrl": "请检查仓库地址和分支名称",
|
"checkRepoUrl": "请检查仓库地址和分支名称",
|
||||||
"checkDiskSpace": "请检查磁盘空间",
|
"checkDiskSpace": "请检查磁盘空间",
|
||||||
"checkPermission": "请检查目录权限"
|
"checkPermission": "请检查目录权限",
|
||||||
|
"uninstallFirst": "请先卸载已安装的同名技能"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"repo": {
|
"repo": {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function getErrorI18nKey(code: string): string {
|
|||||||
DOWNLOAD_TIMEOUT: "skills.error.downloadTimeout",
|
DOWNLOAD_TIMEOUT: "skills.error.downloadTimeout",
|
||||||
DOWNLOAD_FAILED: "skills.error.downloadFailed",
|
DOWNLOAD_FAILED: "skills.error.downloadFailed",
|
||||||
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
|
SKILL_DIR_NOT_FOUND: "skills.error.skillDirNotFound",
|
||||||
|
SKILL_DIRECTORY_CONFLICT: "skills.error.directoryConflict",
|
||||||
EMPTY_ARCHIVE: "skills.error.emptyArchive",
|
EMPTY_ARCHIVE: "skills.error.emptyArchive",
|
||||||
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
|
GET_HOME_DIR_FAILED: "skills.error.getHomeDirFailed",
|
||||||
};
|
};
|
||||||
@@ -52,6 +53,7 @@ function getSuggestionI18nKey(suggestion: string): string {
|
|||||||
retryLater: "skills.error.suggestion.retryLater",
|
retryLater: "skills.error.suggestion.retryLater",
|
||||||
checkRepoUrl: "skills.error.suggestion.checkRepoUrl",
|
checkRepoUrl: "skills.error.suggestion.checkRepoUrl",
|
||||||
checkPermission: "skills.error.suggestion.checkPermission",
|
checkPermission: "skills.error.suggestion.checkPermission",
|
||||||
|
uninstallFirst: "skills.error.suggestion.uninstallFirst",
|
||||||
http403: "skills.error.http403",
|
http403: "skills.error.http403",
|
||||||
http404: "skills.error.http404",
|
http404: "skills.error.http404",
|
||||||
http429: "skills.error.http429",
|
http429: "skills.error.http429",
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export const settingsSchema = z.object({
|
|||||||
currentProviderClaude: z.string().optional(),
|
currentProviderClaude: z.string().optional(),
|
||||||
currentProviderCodex: z.string().optional(),
|
currentProviderCodex: z.string().optional(),
|
||||||
currentProviderGemini: z.string().optional(),
|
currentProviderGemini: z.string().optional(),
|
||||||
|
|
||||||
|
// Skill 同步设置
|
||||||
|
skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SettingsFormData = z.infer<typeof settingsSchema>;
|
export type SettingsFormData = z.infer<typeof settingsSchema>;
|
||||||
|
|||||||
@@ -119,6 +119,18 @@ export interface ProviderProxyConfig {
|
|||||||
proxyPassword?: string;
|
proxyPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
|
||||||
|
export interface FormatTransformConfig {
|
||||||
|
// 是否启用格式转换
|
||||||
|
enabled: boolean;
|
||||||
|
// 源格式:anthropic, openai, gemini
|
||||||
|
sourceFormat?: "anthropic" | "openai" | "gemini";
|
||||||
|
// 目标格式:anthropic, openai, gemini
|
||||||
|
targetFormat?: "anthropic" | "openai" | "gemini";
|
||||||
|
// 是否转换流式响应(默认 true)
|
||||||
|
transformStreaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
||||||
export interface ProviderMeta {
|
export interface ProviderMeta {
|
||||||
// 自定义端点:以 URL 为键,值为端点信息
|
// 自定义端点:以 URL 为键,值为端点信息
|
||||||
@@ -135,8 +147,13 @@ export interface ProviderMeta {
|
|||||||
testConfig?: ProviderTestConfig;
|
testConfig?: ProviderTestConfig;
|
||||||
// 供应商单独的代理配置
|
// 供应商单独的代理配置
|
||||||
proxyConfig?: ProviderProxyConfig;
|
proxyConfig?: ProviderProxyConfig;
|
||||||
|
// 格式转换配置(用于 OpenRouter 等需要 API 格式转换的供应商)
|
||||||
|
formatTransform?: FormatTransformConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skill 同步方式
|
||||||
|
export type SkillSyncMethod = "auto" | "symlink" | "copy";
|
||||||
|
|
||||||
// 主页面显示的应用配置
|
// 主页面显示的应用配置
|
||||||
export interface VisibleApps {
|
export interface VisibleApps {
|
||||||
claude: boolean;
|
claude: boolean;
|
||||||
@@ -182,6 +199,10 @@ export interface Settings {
|
|||||||
currentProviderCodex?: string;
|
currentProviderCodex?: string;
|
||||||
// 当前 Gemini 供应商 ID(优先于数据库 is_current)
|
// 当前 Gemini 供应商 ID(优先于数据库 is_current)
|
||||||
currentProviderGemini?: string;
|
currentProviderGemini?: string;
|
||||||
|
|
||||||
|
// ===== Skill 同步设置 =====
|
||||||
|
// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
|
||||||
|
skillSyncMethod?: SkillSyncMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCP 服务器连接参数(宽松:允许扩展字段)
|
// MCP 服务器连接参数(宽松:允许扩展字段)
|
||||||
@@ -310,6 +331,8 @@ export interface OpenCodeModel {
|
|||||||
output?: number;
|
output?: number;
|
||||||
};
|
};
|
||||||
options?: Record<string, unknown>; // 模型级别额外选项(provider 路由等)
|
options?: Record<string, unknown>; // 模型级别额外选项(provider 路由等)
|
||||||
|
// 支持任意额外字段(cost、modalities、thinking、variants 等)
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenCode 供应商选项
|
// OpenCode 供应商选项
|
||||||
|
|||||||
Reference in New Issue
Block a user