mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-01 21:46:13 +08:00
merge(main): integrate copilot provider support and streaming responses
Merge main into hyper header-case-preservation branch. Conflicts resolved: - forwarder.rs: keep ProxyResponse return type (branch core refactor) - useProviderActions.ts: combine fine-grained proxy reasons (HEAD) with Copilot provider detection (main), add proxyReasonCopilot - i18n (en/zh/ja): retain both reason keys and hint keys, add Copilot reason translations
This commit is contained in:
@@ -26,8 +26,22 @@ pub async fn stream_check_provider(
|
|||||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||||
|
|
||||||
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
||||||
let result =
|
let claude_api_format_override = resolve_claude_api_format_override(
|
||||||
StreamCheckService::check_with_retry(&app_type, provider, &config, auth_override).await?;
|
&app_type,
|
||||||
|
provider,
|
||||||
|
&config,
|
||||||
|
&copilot_state,
|
||||||
|
auth_override.as_ref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let result = StreamCheckService::check_with_retry(
|
||||||
|
&app_type,
|
||||||
|
provider,
|
||||||
|
&config,
|
||||||
|
auth_override,
|
||||||
|
claude_api_format_override,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// 记录日志
|
// 记录日志
|
||||||
let _ =
|
let _ =
|
||||||
@@ -73,19 +87,40 @@ pub async fn stream_check_all_providers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
||||||
let result =
|
let claude_api_format_override = resolve_claude_api_format_override(
|
||||||
StreamCheckService::check_with_retry(&app_type, &provider, &config, auth_override)
|
&app_type,
|
||||||
.await
|
&provider,
|
||||||
.unwrap_or_else(|e| StreamCheckResult {
|
&config,
|
||||||
status: HealthStatus::Failed,
|
&copilot_state,
|
||||||
success: false,
|
auth_override.as_ref(),
|
||||||
message: e.to_string(),
|
)
|
||||||
response_time_ms: None,
|
.await
|
||||||
http_status: None,
|
.unwrap_or_else(|e| {
|
||||||
model_used: String::new(),
|
log::warn!(
|
||||||
tested_at: chrono::Utc::now().timestamp(),
|
"[StreamCheck] Failed to resolve Claude API format override for {}: {}",
|
||||||
retry_count: 0,
|
provider.id,
|
||||||
});
|
e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
});
|
||||||
|
let result = StreamCheckService::check_with_retry(
|
||||||
|
&app_type,
|
||||||
|
&provider,
|
||||||
|
&config,
|
||||||
|
auth_override,
|
||||||
|
claude_api_format_override,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| StreamCheckResult {
|
||||||
|
status: HealthStatus::Failed,
|
||||||
|
success: false,
|
||||||
|
message: e.to_string(),
|
||||||
|
response_time_ms: None,
|
||||||
|
http_status: None,
|
||||||
|
model_used: String::new(),
|
||||||
|
tested_at: chrono::Utc::now().timestamp(),
|
||||||
|
retry_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
let _ = state
|
let _ = state
|
||||||
.db
|
.db
|
||||||
@@ -154,3 +189,49 @@ async fn resolve_copilot_auth_override(
|
|||||||
crate::proxy::providers::AuthStrategy::GitHubCopilot,
|
crate::proxy::providers::AuthStrategy::GitHubCopilot,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_claude_api_format_override(
|
||||||
|
app_type: &AppType,
|
||||||
|
provider: &crate::provider::Provider,
|
||||||
|
config: &StreamCheckConfig,
|
||||||
|
copilot_state: &State<'_, CopilotAuthState>,
|
||||||
|
auth_override: Option<&crate::proxy::providers::AuthInfo>,
|
||||||
|
) -> Result<Option<String>, AppError> {
|
||||||
|
if *app_type != AppType::Claude {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_copilot = auth_override
|
||||||
|
.map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !is_copilot {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config);
|
||||||
|
let auth_manager = copilot_state.0.read().await;
|
||||||
|
let account_id = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
||||||
|
|
||||||
|
let vendor_result = match account_id.as_deref() {
|
||||||
|
Some(id) => auth_manager.get_model_vendor_for_account(id, &model_id).await,
|
||||||
|
None => auth_manager.get_model_vendor(&model_id).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
let api_format = match vendor_result {
|
||||||
|
Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses",
|
||||||
|
Ok(Some(_)) | Ok(None) => "openai_chat",
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"[StreamCheck] Failed to resolve Copilot model vendor for {}: {}. Falling back to chat/completions",
|
||||||
|
model_id,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
"openai_chat"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(api_format.to_string()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ use tokio::sync::RwLock;
|
|||||||
pub struct ForwardResult {
|
pub struct ForwardResult {
|
||||||
pub response: ProxyResponse,
|
pub response: ProxyResponse,
|
||||||
pub provider: Provider,
|
pub provider: Provider,
|
||||||
|
pub claude_api_format: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ForwardError {
|
pub struct ForwardError {
|
||||||
@@ -179,7 +180,7 @@ impl RequestForwarder {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok((response, claude_api_format)) => {
|
||||||
// 成功:记录成功并更新熔断器
|
// 成功:记录成功并更新熔断器
|
||||||
let _ = self
|
let _ = self
|
||||||
.router
|
.router
|
||||||
@@ -233,6 +234,7 @@ impl RequestForwarder {
|
|||||||
return Ok(ForwardResult {
|
return Ok(ForwardResult {
|
||||||
response,
|
response,
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
|
claude_api_format,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -308,7 +310,7 @@ impl RequestForwarder {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok((response, claude_api_format)) => {
|
||||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||||
// 记录成功
|
// 记录成功
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -367,6 +369,7 @@ impl RequestForwarder {
|
|||||||
return Ok(ForwardResult {
|
return Ok(ForwardResult {
|
||||||
response,
|
response,
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
|
claude_api_format,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(retry_err) => {
|
Err(retry_err) => {
|
||||||
@@ -506,7 +509,7 @@ impl RequestForwarder {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok((response, claude_api_format)) => {
|
||||||
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||||
let _ = self
|
let _ = self
|
||||||
.router
|
.router
|
||||||
@@ -558,6 +561,7 @@ impl RequestForwarder {
|
|||||||
return Ok(ForwardResult {
|
return Ok(ForwardResult {
|
||||||
response,
|
response,
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
|
claude_api_format,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(retry_err) => {
|
Err(retry_err) => {
|
||||||
@@ -745,15 +749,19 @@ 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 is_full_url = provider
|
let is_full_url = provider
|
||||||
.meta
|
.meta
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|meta| meta.is_full_url)
|
.and_then(|meta| meta.is_full_url)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// 应用模型映射(独立于格式转换)
|
||||||
|
let (mapped_body, _original_model, _mapped_model) =
|
||||||
|
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||||
|
|
||||||
|
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||||
|
let mapped_body = normalize_thinking_type(mapped_body);
|
||||||
|
|
||||||
// 确定有效端点
|
// 确定有效端点
|
||||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||||
let is_copilot = provider
|
let is_copilot = provider
|
||||||
@@ -762,9 +770,23 @@ impl RequestForwarder {
|
|||||||
.and_then(|m| m.provider_type.as_deref())
|
.and_then(|m| m.provider_type.as_deref())
|
||||||
== Some("github_copilot")
|
== Some("github_copilot")
|
||||||
|| base_url.contains("githubcopilot.com");
|
|| base_url.contains("githubcopilot.com");
|
||||||
|
let resolved_claude_api_format = if adapter.name() == "Claude" {
|
||||||
|
Some(
|
||||||
|
self.resolve_claude_api_format(provider, &mapped_body, is_copilot)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let needs_transform = match resolved_claude_api_format.as_deref() {
|
||||||
|
Some(api_format) => super::providers::claude_api_format_needs_transform(api_format),
|
||||||
|
None => adapter.needs_transform(provider),
|
||||||
|
};
|
||||||
let (effective_endpoint, passthrough_query) =
|
let (effective_endpoint, passthrough_query) =
|
||||||
if needs_transform && adapter.name() == "Claude" {
|
if needs_transform && adapter.name() == "Claude" {
|
||||||
let api_format = super::providers::get_claude_api_format(provider);
|
let api_format = resolved_claude_api_format
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
|
||||||
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot)
|
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
@@ -781,16 +803,20 @@ impl RequestForwarder {
|
|||||||
adapter.build_url(&base_url, &effective_endpoint)
|
adapter.build_url(&base_url, &effective_endpoint)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 应用模型映射(独立于格式转换)
|
|
||||||
let (mapped_body, _original_model, _mapped_model) =
|
|
||||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
|
||||||
|
|
||||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
|
||||||
let mapped_body = normalize_thinking_type(mapped_body);
|
|
||||||
|
|
||||||
// 转换请求体(如果需要)
|
// 转换请求体(如果需要)
|
||||||
let request_body = if needs_transform {
|
let request_body = if needs_transform {
|
||||||
adapter.transform_request(mapped_body, provider)?
|
if adapter.name() == "Claude" {
|
||||||
|
let api_format = resolved_claude_api_format
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
|
||||||
|
super::providers::transform_claude_request_for_api_format(
|
||||||
|
mapped_body,
|
||||||
|
provider,
|
||||||
|
api_format,
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
adapter.transform_request(mapped_body, provider)?
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
mapped_body
|
mapped_body
|
||||||
};
|
};
|
||||||
@@ -1140,7 +1166,7 @@ impl RequestForwarder {
|
|||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
|
||||||
if status.is_success() {
|
if status.is_success() {
|
||||||
Ok(response)
|
Ok((response, resolved_claude_api_format))
|
||||||
} else {
|
} else {
|
||||||
let status_code = status.as_u16();
|
let status_code = status.as_u16();
|
||||||
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
|
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
|
||||||
@@ -1152,6 +1178,64 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_claude_api_format(
|
||||||
|
&self,
|
||||||
|
provider: &Provider,
|
||||||
|
body: &Value,
|
||||||
|
is_copilot: bool,
|
||||||
|
) -> String {
|
||||||
|
if !is_copilot {
|
||||||
|
return super::providers::get_claude_api_format(provider).to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = body.get("model").and_then(|value| value.as_str());
|
||||||
|
if let Some(model_id) = model {
|
||||||
|
if self
|
||||||
|
.is_copilot_openai_vendor_model(provider, model_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return "openai_responses".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"openai_chat".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool {
|
||||||
|
let Some(app_handle) = &self.app_handle else {
|
||||||
|
log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let copilot_state = app_handle.state::<CopilotAuthState>();
|
||||||
|
let copilot_auth = copilot_state.0.read().await;
|
||||||
|
let account_id = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.managed_account_id_for("github_copilot"));
|
||||||
|
|
||||||
|
let vendor_result = match account_id.as_deref() {
|
||||||
|
Some(id) => copilot_auth.get_model_vendor_for_account(id, model_id).await,
|
||||||
|
None => copilot_auth.get_model_vendor(model_id).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
match vendor_result {
|
||||||
|
Ok(Some(vendor)) => vendor.eq_ignore_ascii_case("openai"),
|
||||||
|
Ok(None) => {
|
||||||
|
log::debug!(
|
||||||
|
"[Copilot] Model vendor unavailable for {model_id}, fallback to chat/completions"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"[Copilot] Failed to resolve model vendor for {model_id}, fallback to chat/completions: {err}"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
|
||||||
match error {
|
match error {
|
||||||
// 网络和上游错误:都应该尝试下一个供应商
|
// 网络和上游错误:都应该尝试下一个供应商
|
||||||
@@ -1339,7 +1423,9 @@ fn rewrite_claude_transform_endpoint(
|
|||||||
return (endpoint.to_string(), passthrough_query);
|
return (endpoint.to_string(), passthrough_query);
|
||||||
}
|
}
|
||||||
|
|
||||||
let target_path = if is_copilot {
|
let target_path = if is_copilot && api_format == "openai_responses" {
|
||||||
|
"/v1/responses"
|
||||||
|
} else if is_copilot {
|
||||||
"/chat/completions"
|
"/chat/completions"
|
||||||
} else if api_format == "openai_responses" {
|
} else if api_format == "openai_responses" {
|
||||||
"/v1/responses"
|
"/v1/responses"
|
||||||
@@ -1510,6 +1596,18 @@ mod tests {
|
|||||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rewrite_claude_transform_endpoint_uses_copilot_responses_path() {
|
||||||
|
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||||
|
"/v1/messages?beta=true&x-id=1",
|
||||||
|
"openai_responses",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(endpoint, "/v1/responses?x-id=1");
|
||||||
|
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn append_query_to_full_url_preserves_existing_query_string() {
|
fn append_query_to_full_url_preserves_existing_query_string() {
|
||||||
let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1"));
|
let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1"));
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ pub async fn handle_messages(
|
|||||||
};
|
};
|
||||||
|
|
||||||
ctx.provider = result.provider;
|
ctx.provider = result.provider;
|
||||||
|
let api_format = result
|
||||||
|
.claude_api_format
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| get_claude_api_format(&ctx.provider))
|
||||||
|
.to_string();
|
||||||
let response = result.response;
|
let response = result.response;
|
||||||
|
|
||||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||||
@@ -124,7 +129,8 @@ pub async fn handle_messages(
|
|||||||
|
|
||||||
// Claude 特有:格式转换处理
|
// Claude 特有:格式转换处理
|
||||||
if needs_transform {
|
if needs_transform {
|
||||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
return handle_claude_transform(response, &ctx, &state, &body, is_stream, &api_format)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通用响应处理(透传模式)
|
// 通用响应处理(透传模式)
|
||||||
@@ -140,9 +146,9 @@ async fn handle_claude_transform(
|
|||||||
state: &ProxyState,
|
state: &ProxyState,
|
||||||
_original_body: &Value,
|
_original_body: &Value,
|
||||||
is_stream: bool,
|
is_stream: bool,
|
||||||
|
api_format: &str,
|
||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let api_format = get_claude_api_format(&ctx.provider);
|
|
||||||
|
|
||||||
if is_stream {
|
if is_stream {
|
||||||
// 根据 api_format 选择流式转换器
|
// 根据 api_format 选择流式转换器
|
||||||
|
|||||||
@@ -65,6 +65,30 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
|
||||||
|
matches!(api_format, "openai_chat" | "openai_responses")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transform_claude_request_for_api_format(
|
||||||
|
body: serde_json::Value,
|
||||||
|
provider: &Provider,
|
||||||
|
api_format: &str,
|
||||||
|
) -> Result<serde_json::Value, ProxyError> {
|
||||||
|
let cache_key = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||||
|
.unwrap_or(&provider.id);
|
||||||
|
|
||||||
|
match api_format {
|
||||||
|
"openai_responses" => {
|
||||||
|
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||||
|
}
|
||||||
|
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||||
|
_ => Ok(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Claude 适配器
|
/// Claude 适配器
|
||||||
pub struct ClaudeAdapter;
|
pub struct ClaudeAdapter;
|
||||||
|
|
||||||
@@ -380,19 +404,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
body: serde_json::Value,
|
body: serde_json::Value,
|
||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
) -> Result<serde_json::Value, ProxyError> {
|
) -> Result<serde_json::Value, ProxyError> {
|
||||||
// Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id
|
transform_claude_request_for_api_format(body, provider, self.get_api_format(provider))
|
||||||
let cache_key = provider
|
|
||||||
.meta
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
|
||||||
.unwrap_or(&provider.id);
|
|
||||||
|
|
||||||
match self.get_api_format(provider) {
|
|
||||||
"openai_responses" => {
|
|
||||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
|
||||||
}
|
|
||||||
_ => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||||
@@ -798,4 +810,25 @@ mod tests {
|
|||||||
// GitHub Copilot always needs transform
|
// GitHub Copilot always needs transform
|
||||||
assert!(adapter.needs_transform(&copilot));
|
assert!(adapter.needs_transform(&copilot));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_transform_claude_request_for_api_format_responses() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let body = json!({
|
||||||
|
"model": "gpt-5.4",
|
||||||
|
"messages": [{ "role": "user", "content": "hello" }],
|
||||||
|
"max_tokens": 128
|
||||||
|
});
|
||||||
|
|
||||||
|
let transformed =
|
||||||
|
transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(transformed["model"], "gpt-5.4");
|
||||||
|
assert!(transformed.get("input").is_some());
|
||||||
|
assert!(transformed.get("max_output_tokens").is_some());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,6 +310,8 @@ pub struct CopilotAuthManager {
|
|||||||
refresh_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
refresh_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||||
/// Copilot Token 缓存(key = GitHub user ID,内存缓存,自动刷新)
|
/// Copilot Token 缓存(key = GitHub user ID,内存缓存,自动刷新)
|
||||||
copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>,
|
copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>,
|
||||||
|
/// Copilot Models 缓存(key = GitHub user ID,仅进程内复用)
|
||||||
|
copilot_models: Arc<RwLock<HashMap<String, Vec<CopilotModel>>>>,
|
||||||
/// HTTP 客户端
|
/// HTTP 客户端
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
/// 存储路径
|
/// 存储路径
|
||||||
@@ -330,6 +332,7 @@ impl CopilotAuthManager {
|
|||||||
default_account_id: Arc::new(RwLock::new(None)),
|
default_account_id: Arc::new(RwLock::new(None)),
|
||||||
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||||
copilot_tokens: Arc::new(RwLock::new(HashMap::new())),
|
copilot_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
copilot_models: Arc::new(RwLock::new(HashMap::new())),
|
||||||
http_client: Client::new(),
|
http_client: Client::new(),
|
||||||
storage_path,
|
storage_path,
|
||||||
pending_migration: Arc::new(RwLock::new(None)),
|
pending_migration: Arc::new(RwLock::new(None)),
|
||||||
@@ -375,6 +378,10 @@ impl CopilotAuthManager {
|
|||||||
let mut tokens = self.copilot_tokens.write().await;
|
let mut tokens = self.copilot_tokens.write().await;
|
||||||
tokens.remove(account_id);
|
tokens.remove(account_id);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let mut models = self.copilot_models.write().await;
|
||||||
|
models.remove(account_id);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let mut refresh_locks = self.refresh_locks.write().await;
|
let mut refresh_locks = self.refresh_locks.write().await;
|
||||||
refresh_locks.remove(account_id);
|
refresh_locks.remove(account_id);
|
||||||
@@ -625,6 +632,27 @@ impl CopilotAuthManager {
|
|||||||
pub async fn fetch_models_for_account(
|
pub async fn fetch_models_for_account(
|
||||||
&self,
|
&self,
|
||||||
account_id: &str,
|
account_id: &str,
|
||||||
|
) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
||||||
|
self.ensure_migration_complete().await?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let models = self.copilot_models.read().await;
|
||||||
|
if let Some(cached) = models.get(account_id) {
|
||||||
|
return Ok(cached.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let models = self.fetch_models_for_account_uncached(account_id).await?;
|
||||||
|
{
|
||||||
|
let mut cache = self.copilot_models.write().await;
|
||||||
|
cache.insert(account_id.to_string(), models.clone());
|
||||||
|
}
|
||||||
|
Ok(models)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_models_for_account_uncached(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
||||||
let copilot_token = self.get_valid_token_for_account(account_id).await?;
|
let copilot_token = self.get_valid_token_for_account(account_id).await?;
|
||||||
|
|
||||||
@@ -673,6 +701,18 @@ impl CopilotAuthManager {
|
|||||||
Ok(models)
|
Ok(models)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_model_vendor_for_account(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
model_id: &str,
|
||||||
|
) -> Result<Option<String>, CopilotAuthError> {
|
||||||
|
let models = self.fetch_models_for_account(account_id).await?;
|
||||||
|
Ok(models
|
||||||
|
.into_iter()
|
||||||
|
.find(|model| model.id == model_id)
|
||||||
|
.map(|model| model.vendor))
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号)
|
/// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号)
|
||||||
pub async fn fetch_models(&self) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
pub async fn fetch_models(&self) -> Result<Vec<CopilotModel>, CopilotAuthError> {
|
||||||
match self.resolve_default_account_id().await {
|
match self.resolve_default_account_id().await {
|
||||||
@@ -681,6 +721,16 @@ impl CopilotAuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_model_vendor(
|
||||||
|
&self,
|
||||||
|
model_id: &str,
|
||||||
|
) -> Result<Option<String>, CopilotAuthError> {
|
||||||
|
match self.resolve_default_account_id().await {
|
||||||
|
Some(id) => self.get_model_vendor_for_account(&id, model_id).await,
|
||||||
|
None => Err(CopilotAuthError::GitHubTokenInvalid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取指定账号的 Copilot 使用量信息
|
/// 获取指定账号的 Copilot 使用量信息
|
||||||
pub async fn fetch_usage_for_account(
|
pub async fn fetch_usage_for_account(
|
||||||
&self,
|
&self,
|
||||||
@@ -1136,6 +1186,7 @@ impl CopilotAuthManager {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_copilot_token_expiry() {
|
fn test_copilot_token_expiry() {
|
||||||
@@ -1308,4 +1359,59 @@ mod tests {
|
|||||||
Some("67890".to_string())
|
Some("67890".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_model_vendor_from_cache() {
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf());
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut default_account_id = manager.default_account_id.write().await;
|
||||||
|
*default_account_id = Some("12345".to_string());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut accounts = manager.accounts.write().await;
|
||||||
|
accounts.insert(
|
||||||
|
"12345".to_string(),
|
||||||
|
GitHubAccountData {
|
||||||
|
github_token: "gho_test".to_string(),
|
||||||
|
user: GitHubUser {
|
||||||
|
login: "alice".to_string(),
|
||||||
|
id: 12345,
|
||||||
|
avatar_url: None,
|
||||||
|
},
|
||||||
|
authenticated_at: 1700000000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut models = manager.copilot_models.write().await;
|
||||||
|
models.insert(
|
||||||
|
"12345".to_string(),
|
||||||
|
vec![
|
||||||
|
CopilotModel {
|
||||||
|
id: "gpt-5.4".to_string(),
|
||||||
|
name: "GPT-5.4".to_string(),
|
||||||
|
vendor: "OpenAI".to_string(),
|
||||||
|
model_picker_enabled: true,
|
||||||
|
},
|
||||||
|
CopilotModel {
|
||||||
|
id: "claude-sonnet-4".to_string(),
|
||||||
|
name: "Claude Sonnet 4".to_string(),
|
||||||
|
vendor: "Anthropic".to_string(),
|
||||||
|
model_picker_enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vendor = manager
|
||||||
|
.get_model_vendor_for_account("12345", "gpt-5.4")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(vendor.as_deref(), Some("OpenAI"));
|
||||||
|
|
||||||
|
let default_vendor = manager.get_model_vendor("claude-sonnet-4").await.unwrap();
|
||||||
|
assert_eq!(default_vendor.as_deref(), Some("Anthropic"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
// 公开导出
|
// 公开导出
|
||||||
pub use adapter::ProviderAdapter;
|
pub use adapter::ProviderAdapter;
|
||||||
pub use auth::{AuthInfo, AuthStrategy};
|
pub use auth::{AuthInfo, AuthStrategy};
|
||||||
pub use claude::{get_claude_api_format, ClaudeAdapter};
|
pub use claude::{
|
||||||
|
claude_api_format_needs_transform, get_claude_api_format,
|
||||||
|
transform_claude_request_for_api_format, ClaudeAdapter,
|
||||||
|
};
|
||||||
pub use codex::CodexAdapter;
|
pub use codex::CodexAdapter;
|
||||||
pub use gemini::GeminiAdapter;
|
pub use gemini::GeminiAdapter;
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
|||||||
let mut index_by_key: HashMap<String, u32> = HashMap::new();
|
let mut index_by_key: HashMap<String, u32> = HashMap::new();
|
||||||
let mut open_indices: HashSet<u32> = HashSet::new();
|
let mut open_indices: HashSet<u32> = HashSet::new();
|
||||||
let mut fallback_open_index: Option<u32> = None;
|
let mut fallback_open_index: Option<u32> = None;
|
||||||
|
let mut current_text_index: Option<u32> = None;
|
||||||
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
|
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
|
||||||
let mut last_tool_index: Option<u32> = None;
|
let mut last_tool_index: Option<u32> = None;
|
||||||
|
|
||||||
@@ -218,12 +219,18 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
|||||||
if let Some(part) = data.get("part") {
|
if let Some(part) = data.get("part") {
|
||||||
let part_type = part.get("type").and_then(|t| t.as_str());
|
let part_type = part.get("type").and_then(|t| t.as_str());
|
||||||
if matches!(part_type, Some("output_text") | Some("refusal")) {
|
if matches!(part_type, Some("output_text") | Some("refusal")) {
|
||||||
let index = resolve_content_index(
|
let index = if let Some(index) = current_text_index {
|
||||||
&data,
|
index
|
||||||
&mut next_content_index,
|
} else {
|
||||||
&mut index_by_key,
|
let index = resolve_content_index(
|
||||||
&mut fallback_open_index,
|
&data,
|
||||||
);
|
&mut next_content_index,
|
||||||
|
&mut index_by_key,
|
||||||
|
&mut fallback_open_index,
|
||||||
|
);
|
||||||
|
current_text_index = Some(index);
|
||||||
|
index
|
||||||
|
};
|
||||||
|
|
||||||
if open_indices.contains(&index) {
|
if open_indices.contains(&index) {
|
||||||
continue;
|
continue;
|
||||||
@@ -250,12 +257,18 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
|||||||
// ================================================
|
// ================================================
|
||||||
"response.output_text.delta" => {
|
"response.output_text.delta" => {
|
||||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||||
let index = resolve_content_index(
|
let index = if let Some(index) = current_text_index {
|
||||||
&data,
|
index
|
||||||
&mut next_content_index,
|
} else {
|
||||||
&mut index_by_key,
|
let index = resolve_content_index(
|
||||||
&mut fallback_open_index,
|
&data,
|
||||||
);
|
&mut next_content_index,
|
||||||
|
&mut index_by_key,
|
||||||
|
&mut fallback_open_index,
|
||||||
|
);
|
||||||
|
current_text_index = Some(index);
|
||||||
|
index
|
||||||
|
};
|
||||||
|
|
||||||
if !open_indices.contains(&index) {
|
if !open_indices.contains(&index) {
|
||||||
let start_event = json!({
|
let start_event = json!({
|
||||||
@@ -290,12 +303,18 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
|||||||
// ================================================
|
// ================================================
|
||||||
"response.refusal.delta" => {
|
"response.refusal.delta" => {
|
||||||
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
|
||||||
let index = resolve_content_index(
|
let index = if let Some(index) = current_text_index {
|
||||||
&data,
|
index
|
||||||
&mut next_content_index,
|
} else {
|
||||||
&mut index_by_key,
|
let index = resolve_content_index(
|
||||||
&mut fallback_open_index,
|
&data,
|
||||||
);
|
&mut next_content_index,
|
||||||
|
&mut index_by_key,
|
||||||
|
&mut fallback_open_index,
|
||||||
|
);
|
||||||
|
current_text_index = Some(index);
|
||||||
|
index
|
||||||
|
};
|
||||||
|
|
||||||
if !open_indices.contains(&index) {
|
if !open_indices.contains(&index) {
|
||||||
let start_event = json!({
|
let start_event = json!({
|
||||||
@@ -329,29 +348,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
|||||||
// ================================================
|
// ================================================
|
||||||
// response.content_part.done → content_block_stop
|
// response.content_part.done → content_block_stop
|
||||||
// ================================================
|
// ================================================
|
||||||
"response.content_part.done" => {
|
"response.content_part.done" => {}
|
||||||
let key = content_part_key(&data);
|
|
||||||
let index = if let Some(k) = key {
|
|
||||||
index_by_key.get(&k).copied()
|
|
||||||
} else {
|
|
||||||
fallback_open_index
|
|
||||||
};
|
|
||||||
if let Some(index) = index {
|
|
||||||
if !open_indices.remove(&index) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let event = json!({
|
|
||||||
"type": "content_block_stop",
|
|
||||||
"index": index
|
|
||||||
});
|
|
||||||
let sse = format!("event: content_block_stop\ndata: {}\n\n",
|
|
||||||
serde_json::to_string(&event).unwrap_or_default());
|
|
||||||
yield Ok(Bytes::from(sse));
|
|
||||||
if fallback_open_index == Some(index) {
|
|
||||||
fallback_open_index = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================
|
// ================================================
|
||||||
// response.output_item.added (function_call) → content_block_start (tool_use)
|
// response.output_item.added (function_call) → content_block_start (tool_use)
|
||||||
@@ -361,6 +358,20 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
|||||||
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||||
if item_type == "function_call" {
|
if item_type == "function_call" {
|
||||||
has_tool_use = true;
|
has_tool_use = true;
|
||||||
|
if let Some(index) = current_text_index.take() {
|
||||||
|
if open_indices.remove(&index) {
|
||||||
|
let stop_event = json!({
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": index
|
||||||
|
});
|
||||||
|
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(stop_sse));
|
||||||
|
}
|
||||||
|
if fallback_open_index == Some(index) {
|
||||||
|
fallback_open_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
// 确保 message_start 已发送
|
// 确保 message_start 已发送
|
||||||
if !has_sent_message_start {
|
if !has_sent_message_start {
|
||||||
let start_event = json!({
|
let start_event = json!({
|
||||||
@@ -521,12 +532,14 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
|||||||
// response.refusal.done → content_block_stop
|
// response.refusal.done → content_block_stop
|
||||||
// ================================================
|
// ================================================
|
||||||
"response.refusal.done" => {
|
"response.refusal.done" => {
|
||||||
let key = content_part_key(&data);
|
let index = current_text_index.take().or_else(|| {
|
||||||
let index = if let Some(k) = key {
|
let key = content_part_key(&data);
|
||||||
index_by_key.get(&k).copied()
|
if let Some(k) = key {
|
||||||
} else {
|
index_by_key.get(&k).copied()
|
||||||
fallback_open_index
|
} else {
|
||||||
};
|
fallback_open_index
|
||||||
|
}
|
||||||
|
});
|
||||||
if let Some(index) = index {
|
if let Some(index) = index {
|
||||||
if !open_indices.remove(&index) {
|
if !open_indices.remove(&index) {
|
||||||
continue;
|
continue;
|
||||||
@@ -553,6 +566,20 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
|||||||
.or_else(|| data.get("text"))
|
.or_else(|| data.get("text"))
|
||||||
.and_then(|d| d.as_str())
|
.and_then(|d| d.as_str())
|
||||||
{
|
{
|
||||||
|
if let Some(index) = current_text_index.take() {
|
||||||
|
if open_indices.remove(&index) {
|
||||||
|
let stop_event = json!({
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": index
|
||||||
|
});
|
||||||
|
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(stop_sse));
|
||||||
|
}
|
||||||
|
if fallback_open_index == Some(index) {
|
||||||
|
fallback_open_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
let index = resolve_content_index(
|
let index = resolve_content_index(
|
||||||
&data,
|
&data,
|
||||||
&mut next_content_index,
|
&mut next_content_index,
|
||||||
@@ -674,8 +701,23 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
|||||||
|
|
||||||
// Lifecycle events that don't need Anthropic counterparts.
|
// Lifecycle events that don't need Anthropic counterparts.
|
||||||
// Listed explicitly so new events trigger a match-completeness review.
|
// Listed explicitly so new events trigger a match-completeness review.
|
||||||
"response.output_text.done"
|
"response.output_text.done" => {
|
||||||
| "response.output_item.done"
|
if let Some(index) = current_text_index.take() {
|
||||||
|
if open_indices.remove(&index) {
|
||||||
|
let stop_event = json!({
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": index
|
||||||
|
});
|
||||||
|
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
|
||||||
|
serde_json::to_string(&stop_event).unwrap_or_default());
|
||||||
|
yield Ok(Bytes::from(stop_sse));
|
||||||
|
}
|
||||||
|
if fallback_open_index == Some(index) {
|
||||||
|
fallback_open_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"response.output_item.done"
|
||||||
| "response.in_progress" => {}
|
| "response.in_progress" => {}
|
||||||
|
|
||||||
// Any other unknown/future events — silently skip.
|
// Any other unknown/future events — silently skip.
|
||||||
@@ -908,4 +950,75 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(merged.contains("\"stop_reason\":\"end_turn\""));
|
assert!(merged.contains("\"stop_reason\":\"end_turn\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_streaming_text_parts_are_merged_into_one_text_block() {
|
||||||
|
let input = concat!(
|
||||||
|
"event: response.created\n",
|
||||||
|
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_merge\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
|
||||||
|
"event: response.content_part.added\n",
|
||||||
|
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":0}\n\n",
|
||||||
|
"event: response.output_text.delta\n",
|
||||||
|
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"你\",\"output_index\":0,\"content_index\":0}\n\n",
|
||||||
|
"event: response.content_part.done\n",
|
||||||
|
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":0}\n\n",
|
||||||
|
"event: response.content_part.added\n",
|
||||||
|
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":1}\n\n",
|
||||||
|
"event: response.output_text.delta\n",
|
||||||
|
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"好\",\"output_index\":0,\"content_index\":1}\n\n",
|
||||||
|
"event: response.content_part.done\n",
|
||||||
|
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":1}\n\n",
|
||||||
|
"event: response.output_text.done\n",
|
||||||
|
"data: {\"type\":\"response.output_text.done\",\"output_index\":0,\"content_index\":1}\n\n",
|
||||||
|
"event: response.completed\n",
|
||||||
|
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
|
||||||
|
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||||
|
let chunks: Vec<_> = converted.collect().await;
|
||||||
|
let events: Vec<Value> = chunks
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|chunk| {
|
||||||
|
let bytes = chunk.unwrap();
|
||||||
|
let text = String::from_utf8_lossy(bytes.as_ref()).to_string();
|
||||||
|
text.split("\n\n")
|
||||||
|
.filter_map(|block| {
|
||||||
|
block.lines().find_map(|line| {
|
||||||
|
strip_sse_field(line, "data")
|
||||||
|
.and_then(|payload| serde_json::from_str::<Value>(payload).ok())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let text_starts = events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| {
|
||||||
|
event.get("type").and_then(|v| v.as_str()) == Some("content_block_start")
|
||||||
|
&& event.pointer("/content_block/type").and_then(|v| v.as_str()) == Some("text")
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
let text_stops = events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.get("type").and_then(|v| v.as_str()) == Some("content_block_stop"))
|
||||||
|
.count();
|
||||||
|
let text_deltas: Vec<String> = events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| {
|
||||||
|
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||||
|
&& event.pointer("/delta/type").and_then(|v| v.as_str()) == Some("text_delta")
|
||||||
|
})
|
||||||
|
.filter_map(|event| {
|
||||||
|
event.pointer("/delta/text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert_eq!(text_starts, 1);
|
||||||
|
assert_eq!(text_stops, 1);
|
||||||
|
assert_eq!(text_deltas, vec!["你".to_string(), "好".to_string()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ impl StreamCheckService {
|
|||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
config: &StreamCheckConfig,
|
config: &StreamCheckConfig,
|
||||||
auth_override: Option<AuthInfo>,
|
auth_override: Option<AuthInfo>,
|
||||||
|
claude_api_format_override: Option<String>,
|
||||||
) -> Result<StreamCheckResult, AppError> {
|
) -> Result<StreamCheckResult, AppError> {
|
||||||
// 合并供应商单独配置和全局配置
|
// 合并供应商单独配置和全局配置
|
||||||
let effective_config = Self::merge_provider_config(provider, config);
|
let effective_config = Self::merge_provider_config(provider, config);
|
||||||
@@ -95,8 +96,14 @@ impl StreamCheckService {
|
|||||||
|
|
||||||
for attempt in 0..=effective_config.max_retries {
|
for attempt in 0..=effective_config.max_retries {
|
||||||
let result =
|
let result =
|
||||||
Self::check_once(app_type, provider, &effective_config, auth_override.clone())
|
Self::check_once(
|
||||||
.await;
|
app_type,
|
||||||
|
provider,
|
||||||
|
&effective_config,
|
||||||
|
auth_override.clone(),
|
||||||
|
claude_api_format_override.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
Ok(r) if r.success => {
|
Ok(r) if r.success => {
|
||||||
@@ -185,6 +192,7 @@ impl StreamCheckService {
|
|||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
config: &StreamCheckConfig,
|
config: &StreamCheckConfig,
|
||||||
auth_override: Option<AuthInfo>,
|
auth_override: Option<AuthInfo>,
|
||||||
|
claude_api_format_override: Option<String>,
|
||||||
) -> Result<StreamCheckResult, AppError> {
|
) -> Result<StreamCheckResult, AppError> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let adapter = get_adapter(app_type);
|
let adapter = get_adapter(app_type);
|
||||||
@@ -215,6 +223,7 @@ impl StreamCheckService {
|
|||||||
test_prompt,
|
test_prompt,
|
||||||
request_timeout,
|
request_timeout,
|
||||||
provider,
|
provider,
|
||||||
|
claude_api_format_override.as_deref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -303,6 +312,7 @@ impl StreamCheckService {
|
|||||||
test_prompt: &str,
|
test_prompt: &str,
|
||||||
timeout: std::time::Duration,
|
timeout: std::time::Duration,
|
||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
|
claude_api_format_override: Option<&str>,
|
||||||
) -> Result<(u16, String), AppError> {
|
) -> Result<(u16, String), AppError> {
|
||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;
|
let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;
|
||||||
@@ -320,19 +330,28 @@ impl StreamCheckService {
|
|||||||
})
|
})
|
||||||
.unwrap_or("anthropic");
|
.unwrap_or("anthropic");
|
||||||
|
|
||||||
|
let effective_api_format = claude_api_format_override.unwrap_or(api_format);
|
||||||
|
|
||||||
let is_full_url = provider
|
let is_full_url = provider
|
||||||
.meta
|
.meta
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|meta| meta.is_full_url)
|
.and_then(|meta| meta.is_full_url)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let is_openai_chat = is_github_copilot || api_format == "openai_chat";
|
let is_openai_chat = effective_api_format == "openai_chat";
|
||||||
let is_openai_responses = !is_github_copilot && api_format == "openai_responses";
|
let is_openai_responses = effective_api_format == "openai_responses";
|
||||||
let url = Self::resolve_claude_stream_url(base, auth.strategy, api_format, is_full_url);
|
let url = Self::resolve_claude_stream_url(
|
||||||
|
base,
|
||||||
|
auth.strategy,
|
||||||
|
effective_api_format,
|
||||||
|
is_full_url,
|
||||||
|
);
|
||||||
|
|
||||||
|
let max_tokens = if is_openai_responses { 16 } else { 1 };
|
||||||
|
|
||||||
// Build from Anthropic-native shape first, then convert for configured targets.
|
// Build from Anthropic-native shape first, then convert for configured targets.
|
||||||
let anthropic_body = json!({
|
let anthropic_body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 1,
|
"max_tokens": max_tokens,
|
||||||
"messages": [{ "role": "user", "content": test_prompt }],
|
"messages": [{ "role": "user", "content": test_prompt }],
|
||||||
"stream": true
|
"stream": true
|
||||||
});
|
});
|
||||||
@@ -730,7 +749,9 @@ impl StreamCheckService {
|
|||||||
let base = base_url.trim_end_matches('/');
|
let base = base_url.trim_end_matches('/');
|
||||||
let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot;
|
let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot;
|
||||||
|
|
||||||
if is_github_copilot {
|
if is_github_copilot && api_format == "openai_responses" {
|
||||||
|
format!("{base}/v1/responses")
|
||||||
|
} else if is_github_copilot {
|
||||||
format!("{base}/chat/completions")
|
format!("{base}/chat/completions")
|
||||||
} else if api_format == "openai_responses" {
|
} else if api_format == "openai_responses" {
|
||||||
if base.ends_with("/v1") {
|
if base.ends_with("/v1") {
|
||||||
@@ -764,6 +785,15 @@ impl StreamCheckService {
|
|||||||
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
|
vec![format!("{base}/responses"), format!("{base}/v1/responses")]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_effective_test_model(
|
||||||
|
app_type: &AppType,
|
||||||
|
provider: &Provider,
|
||||||
|
config: &StreamCheckConfig,
|
||||||
|
) -> String {
|
||||||
|
let effective_config = Self::merge_provider_config(provider, config);
|
||||||
|
Self::resolve_test_model(app_type, provider, &effective_config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -884,13 +914,25 @@ mod tests {
|
|||||||
let url = StreamCheckService::resolve_claude_stream_url(
|
let url = StreamCheckService::resolve_claude_stream_url(
|
||||||
"https://api.githubcopilot.com",
|
"https://api.githubcopilot.com",
|
||||||
AuthStrategy::GitHubCopilot,
|
AuthStrategy::GitHubCopilot,
|
||||||
"anthropic",
|
"openai_chat",
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(url, "https://api.githubcopilot.com/chat/completions");
|
assert_eq!(url, "https://api.githubcopilot.com/chat/completions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_claude_stream_url_for_github_copilot_responses() {
|
||||||
|
let url = StreamCheckService::resolve_claude_stream_url(
|
||||||
|
"https://api.githubcopilot.com",
|
||||||
|
AuthStrategy::GitHubCopilot,
|
||||||
|
"openai_responses",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(url, "https://api.githubcopilot.com/v1/responses");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_claude_stream_url_for_openai_chat() {
|
fn test_resolve_claude_stream_url_for_openai_chat() {
|
||||||
let url = StreamCheckService::resolve_claude_stream_url(
|
let url = StreamCheckService::resolve_claude_stream_url(
|
||||||
|
|||||||
@@ -453,10 +453,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
|||||||
websiteUrl: "https://www.kimi.com/coding/docs/",
|
websiteUrl: "https://www.kimi.com/coding/docs/",
|
||||||
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
npm: "@ai-sdk/openai-compatible",
|
npm: "@ai-sdk/anthropic",
|
||||||
name: "Kimi For Coding",
|
name: "Kimi For Coding",
|
||||||
options: {
|
options: {
|
||||||
baseURL: "https://api.kimi.com/v1",
|
baseURL: "https://api.kimi.com/coding/v1",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
setCacheKey: true,
|
setCacheKey: true,
|
||||||
},
|
},
|
||||||
@@ -470,8 +470,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
|||||||
templateValues: {
|
templateValues: {
|
||||||
baseURL: {
|
baseURL: {
|
||||||
label: "Base URL",
|
label: "Base URL",
|
||||||
placeholder: "https://api.kimi.com/v1",
|
placeholder: "https://api.kimi.com/coding/v1",
|
||||||
defaultValue: "https://api.kimi.com/v1",
|
defaultValue: "https://api.kimi.com/coding/v1",
|
||||||
editorValue: "",
|
editorValue: "",
|
||||||
},
|
},
|
||||||
apiKey: {
|
apiKey: {
|
||||||
|
|||||||
@@ -139,10 +139,18 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
|||||||
// 切换供应商
|
// 切换供应商
|
||||||
const switchProvider = useCallback(
|
const switchProvider = useCallback(
|
||||||
async (provider: Provider) => {
|
async (provider: Provider) => {
|
||||||
|
const isCopilotProvider =
|
||||||
|
activeApp === "claude" &&
|
||||||
|
provider.meta?.providerType === "github_copilot";
|
||||||
|
|
||||||
// Determine why this provider requires the proxy
|
// Determine why this provider requires the proxy
|
||||||
let proxyRequiredReason: string | null = null;
|
let proxyRequiredReason: string | null = null;
|
||||||
if (!isProxyRunning && provider.category !== "official") {
|
if (!isProxyRunning && provider.category !== "official") {
|
||||||
if (
|
if (isCopilotProvider) {
|
||||||
|
proxyRequiredReason = t("notifications.proxyReasonCopilot", {
|
||||||
|
defaultValue: "使用 GitHub Copilot 作为 Claude 供应商",
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
provider.meta?.apiFormat === "openai_chat" &&
|
provider.meta?.apiFormat === "openai_chat" &&
|
||||||
activeApp === "claude"
|
activeApp === "claude"
|
||||||
) {
|
) {
|
||||||
@@ -192,8 +200,27 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenCode/OpenClaw: show "added to config" message instead of "switched"
|
// 根据供应商类型显示不同的成功提示
|
||||||
{
|
if (
|
||||||
|
activeApp === "claude" &&
|
||||||
|
provider.category !== "official" &&
|
||||||
|
(isCopilotProvider ||
|
||||||
|
provider.meta?.apiFormat === "openai_chat" ||
|
||||||
|
provider.meta?.apiFormat === "openai_responses")
|
||||||
|
) {
|
||||||
|
// OpenAI format provider: show proxy hint
|
||||||
|
toast.info(
|
||||||
|
isCopilotProvider
|
||||||
|
? t("notifications.copilotProxyHint")
|
||||||
|
: t("notifications.openAIFormatHint"),
|
||||||
|
{
|
||||||
|
duration: 5000,
|
||||||
|
closeButton: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 普通供应商:显示切换成功
|
||||||
|
// OpenCode/OpenClaw: show "added to config" message instead of "switched"
|
||||||
const isMultiProviderApp =
|
const isMultiProviderApp =
|
||||||
activeApp === "opencode" || activeApp === "openclaw";
|
activeApp === "opencode" || activeApp === "openclaw";
|
||||||
const messageKey = isMultiProviderApp
|
const messageKey = isMultiProviderApp
|
||||||
|
|||||||
@@ -175,9 +175,12 @@
|
|||||||
"settingsSaved": "Settings saved",
|
"settingsSaved": "Settings saved",
|
||||||
"settingsSaveFailed": "Failed to save settings: {{error}}",
|
"settingsSaveFailed": "Failed to save settings: {{error}}",
|
||||||
"proxyRequiredForSwitch": "This provider {{reason}}, requires the proxy service to work properly. Start the proxy first.",
|
"proxyRequiredForSwitch": "This provider {{reason}}, requires the proxy service to work properly. Start the proxy first.",
|
||||||
|
"proxyReasonCopilot": "uses GitHub Copilot as a Claude provider",
|
||||||
"proxyReasonOpenAIChat": "uses OpenAI Chat API format",
|
"proxyReasonOpenAIChat": "uses OpenAI Chat API format",
|
||||||
"proxyReasonOpenAIResponses": "uses OpenAI Responses API format",
|
"proxyReasonOpenAIResponses": "uses OpenAI Responses API format",
|
||||||
"proxyReasonFullUrl": "has full URL connection mode enabled",
|
"proxyReasonFullUrl": "has full URL connection mode enabled",
|
||||||
|
"openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled",
|
||||||
|
"copilotProxyHint": "GitHub Copilot as a Claude provider always requires the local proxy; the proxy automatically selects Chat Completions or Responses based on the current model.",
|
||||||
"openLinkFailed": "Failed to open link",
|
"openLinkFailed": "Failed to open link",
|
||||||
"openclawModelsRegistered": "Models have been registered to /model list",
|
"openclawModelsRegistered": "Models have been registered to /model list",
|
||||||
"openclawDefaultModelSet": "Set as default model",
|
"openclawDefaultModelSet": "Set as default model",
|
||||||
|
|||||||
@@ -175,9 +175,12 @@
|
|||||||
"settingsSaved": "設定を保存しました",
|
"settingsSaved": "設定を保存しました",
|
||||||
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
|
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
|
||||||
"proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください",
|
"proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください",
|
||||||
|
"proxyReasonCopilot": "GitHub Copilot を Claude プロバイダーとして使用しており",
|
||||||
"proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており",
|
"proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており",
|
||||||
"proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており",
|
"proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており",
|
||||||
"proxyReasonFullUrl": "完全 URL 接続モードが有効になっており",
|
"proxyReasonFullUrl": "完全 URL 接続モードが有効になっており",
|
||||||
|
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です",
|
||||||
|
"copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルプロキシが常に必要です。プロキシは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。",
|
||||||
"openLinkFailed": "リンクを開けませんでした",
|
"openLinkFailed": "リンクを開けませんでした",
|
||||||
"openclawModelsRegistered": "モデルが /model リストに登録されました",
|
"openclawModelsRegistered": "モデルが /model リストに登録されました",
|
||||||
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
|
||||||
|
|||||||
@@ -175,9 +175,12 @@
|
|||||||
"settingsSaved": "设置已保存",
|
"settingsSaved": "设置已保存",
|
||||||
"settingsSaveFailed": "保存设置失败:{{error}}",
|
"settingsSaveFailed": "保存设置失败:{{error}}",
|
||||||
"proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
|
"proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
|
||||||
|
"proxyReasonCopilot": "使用 GitHub Copilot 作为 Claude 供应商",
|
||||||
"proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式",
|
"proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式",
|
||||||
"proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式",
|
"proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式",
|
||||||
"proxyReasonFullUrl": "开启了完整 URL 连接模式",
|
"proxyReasonFullUrl": "开启了完整 URL 连接模式",
|
||||||
|
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
|
||||||
|
"copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地代理;代理会根据当前模型自动选择 Chat Completions 或 Responses。",
|
||||||
"openLinkFailed": "链接打开失败",
|
"openLinkFailed": "链接打开失败",
|
||||||
"openclawModelsRegistered": "模型已注册到 /model 列表",
|
"openclawModelsRegistered": "模型已注册到 /model 列表",
|
||||||
"openclawDefaultModelSet": "已设为默认模型",
|
"openclawDefaultModelSet": "已设为默认模型",
|
||||||
|
|||||||
@@ -65,4 +65,19 @@ describe("AWS Bedrock OpenCode Provider Presets", () => {
|
|||||||
modelIds.some((id) => id.includes("anthropic.claude")),
|
modelIds.some((id) => id.includes("anthropic.claude")),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Kimi For Coding preset should use Anthropic with the coding endpoint", () => {
|
||||||
|
const kimiForCodingPreset = opencodeProviderPresets.find(
|
||||||
|
(p) => p.name === "Kimi For Coding",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(kimiForCodingPreset).toBeDefined();
|
||||||
|
expect(kimiForCodingPreset!.settingsConfig.npm).toBe("@ai-sdk/anthropic");
|
||||||
|
expect(kimiForCodingPreset!.settingsConfig.options?.baseURL).toBe(
|
||||||
|
"https://api.kimi.com/coding/v1",
|
||||||
|
);
|
||||||
|
expect(kimiForCodingPreset!.templateValues?.baseURL.defaultValue).toBe(
|
||||||
|
"https://api.kimi.com/coding/v1",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user