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:
YoVinchen
2026-03-29 15:47:11 +08:00
Unverified
14 changed files with 645 additions and 112 deletions
+96 -15
View File
@@ -26,8 +26,22 @@ pub async fn stream_check_provider(
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
let result =
StreamCheckService::check_with_retry(&app_type, provider, &config, auth_override).await?;
let claude_api_format_override = resolve_claude_api_format_override(
&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 _ =
@@ -73,19 +87,40 @@ pub async fn stream_check_all_providers(
}
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
let result =
StreamCheckService::check_with_retry(&app_type, &provider, &config, auth_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 claude_api_format_override = resolve_claude_api_format_override(
&app_type,
&provider,
&config,
&copilot_state,
auth_override.as_ref(),
)
.await
.unwrap_or_else(|e| {
log::warn!(
"[StreamCheck] Failed to resolve Claude API format override for {}: {}",
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
.db
@@ -154,3 +189,49 @@ async fn resolve_copilot_auth_override(
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()))
}
+115 -17
View File
@@ -29,6 +29,7 @@ use tokio::sync::RwLock;
pub struct ForwardResult {
pub response: ProxyResponse,
pub provider: Provider,
pub claude_api_format: Option<String>,
}
pub struct ForwardError {
@@ -179,7 +180,7 @@ impl RequestForwarder {
)
.await
{
Ok(response) => {
Ok((response, claude_api_format)) => {
// 成功:记录成功并更新熔断器
let _ = self
.router
@@ -233,6 +234,7 @@ impl RequestForwarder {
return Ok(ForwardResult {
response,
provider: provider.clone(),
claude_api_format,
});
}
Err(e) => {
@@ -308,7 +310,7 @@ impl RequestForwarder {
)
.await
{
Ok(response) => {
Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
@@ -367,6 +369,7 @@ impl RequestForwarder {
return Ok(ForwardResult {
response,
provider: provider.clone(),
claude_api_format,
});
}
Err(retry_err) => {
@@ -506,7 +509,7 @@ impl RequestForwarder {
)
.await
{
Ok(response) => {
Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
let _ = self
.router
@@ -558,6 +561,7 @@ impl RequestForwarder {
return Ok(ForwardResult {
response,
provider: provider.clone(),
claude_api_format,
});
}
Err(retry_err) => {
@@ -745,15 +749,19 @@ impl RequestForwarder {
// 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?;
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
let is_full_url = provider
.meta
.as_ref()
.and_then(|meta| meta.is_full_url)
.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 前缀)
let is_copilot = provider
@@ -762,9 +770,23 @@ impl RequestForwarder {
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot")
|| 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) =
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)
} else {
(
@@ -781,16 +803,20 @@ impl RequestForwarder {
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 {
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 {
mapped_body
};
@@ -1140,7 +1166,7 @@ impl RequestForwarder {
let status = response.status();
if status.is_success() {
Ok(response)
Ok((response, resolved_claude_api_format))
} else {
let status_code = status.as_u16();
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 {
match error {
// 网络和上游错误:都应该尝试下一个供应商
@@ -1339,7 +1423,9 @@ fn rewrite_claude_transform_endpoint(
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"
} else if api_format == "openai_responses" {
"/v1/responses"
@@ -1510,6 +1596,18 @@ mod tests {
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]
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"));
+8 -2
View File
@@ -116,6 +116,11 @@ pub async fn handle_messages(
};
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;
// 检查是否需要格式转换(OpenRouter 等中转服务)
@@ -124,7 +129,8 @@ pub async fn handle_messages(
// Claude 特有:格式转换处理
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,
_original_body: &Value,
is_stream: bool,
api_format: &str,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
let api_format = get_claude_api_format(&ctx.provider);
if is_stream {
// 根据 api_format 选择流式转换器
+46 -13
View File
@@ -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 适配器
pub struct ClaudeAdapter;
@@ -380,19 +404,7 @@ impl ProviderAdapter for ClaudeAdapter {
body: serde_json::Value,
provider: &Provider,
) -> Result<serde_json::Value, ProxyError> {
// Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id
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)),
}
transform_claude_request_for_api_format(body, provider, self.get_api_format(provider))
}
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
@@ -798,4 +810,25 @@ mod tests {
// GitHub Copilot always needs transform
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<()>>>>>,
/// Copilot Token 缓存(key = GitHub user ID,内存缓存,自动刷新)
copilot_tokens: Arc<RwLock<HashMap<String, CopilotToken>>>,
/// Copilot Models 缓存(key = GitHub user ID,仅进程内复用)
copilot_models: Arc<RwLock<HashMap<String, Vec<CopilotModel>>>>,
/// HTTP 客户端
http_client: Client,
/// 存储路径
@@ -330,6 +332,7 @@ impl CopilotAuthManager {
default_account_id: Arc::new(RwLock::new(None)),
refresh_locks: 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(),
storage_path,
pending_migration: Arc::new(RwLock::new(None)),
@@ -375,6 +378,10 @@ impl CopilotAuthManager {
let mut tokens = self.copilot_tokens.write().await;
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;
refresh_locks.remove(account_id);
@@ -625,6 +632,27 @@ impl CopilotAuthManager {
pub async fn fetch_models_for_account(
&self,
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> {
let copilot_token = self.get_valid_token_for_account(account_id).await?;
@@ -673,6 +701,18 @@ impl CopilotAuthManager {
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 可用模型列表(向后兼容:使用第一个账号)
pub async fn fetch_models(&self) -> Result<Vec<CopilotModel>, CopilotAuthError> {
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 使用量信息
pub async fn fetch_usage_for_account(
&self,
@@ -1136,6 +1186,7 @@ impl CopilotAuthManager {
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_copilot_token_expiry() {
@@ -1308,4 +1359,59 @@ mod tests {
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"));
}
}
+4 -1
View File
@@ -30,7 +30,10 @@ use serde::{Deserialize, Serialize};
// 公开导出
pub use adapter::ProviderAdapter;
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 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 open_indices: HashSet<u32> = HashSet::new();
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 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") {
let part_type = part.get("type").and_then(|t| t.as_str());
if matches!(part_type, Some("output_text") | Some("refusal")) {
let index = resolve_content_index(
&data,
&mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
let index = if let Some(index) = current_text_index {
index
} else {
let index = resolve_content_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) {
continue;
@@ -250,12 +257,18 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
// ================================================
"response.output_text.delta" => {
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
let index = resolve_content_index(
&data,
&mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
let index = if let Some(index) = current_text_index {
index
} else {
let index = resolve_content_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) {
let start_event = json!({
@@ -290,12 +303,18 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
// ================================================
"response.refusal.delta" => {
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
let index = resolve_content_index(
&data,
&mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
let index = if let Some(index) = current_text_index {
index
} else {
let index = resolve_content_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) {
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" => {
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.content_part.done" => {}
// ================================================
// 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("");
if item_type == "function_call" {
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 已发送
if !has_sent_message_start {
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" => {
let key = content_part_key(&data);
let index = if let Some(k) = key {
index_by_key.get(&k).copied()
} else {
fallback_open_index
};
let index = current_text_index.take().or_else(|| {
let key = content_part_key(&data);
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;
@@ -553,6 +566,20 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
.or_else(|| data.get("text"))
.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(
&data,
&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.
// Listed explicitly so new events trigger a match-completeness review.
"response.output_text.done"
| "response.output_item.done"
"response.output_text.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" => {}
// Any other unknown/future events — silently skip.
@@ -908,4 +950,75 @@ mod tests {
);
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()]);
}
}
+50 -8
View File
@@ -88,6 +88,7 @@ impl StreamCheckService {
provider: &Provider,
config: &StreamCheckConfig,
auth_override: Option<AuthInfo>,
claude_api_format_override: Option<String>,
) -> Result<StreamCheckResult, AppError> {
// 合并供应商单独配置和全局配置
let effective_config = Self::merge_provider_config(provider, config);
@@ -95,8 +96,14 @@ impl StreamCheckService {
for attempt in 0..=effective_config.max_retries {
let result =
Self::check_once(app_type, provider, &effective_config, auth_override.clone())
.await;
Self::check_once(
app_type,
provider,
&effective_config,
auth_override.clone(),
claude_api_format_override.clone(),
)
.await;
match &result {
Ok(r) if r.success => {
@@ -185,6 +192,7 @@ impl StreamCheckService {
provider: &Provider,
config: &StreamCheckConfig,
auth_override: Option<AuthInfo>,
claude_api_format_override: Option<String>,
) -> Result<StreamCheckResult, AppError> {
let start = Instant::now();
let adapter = get_adapter(app_type);
@@ -215,6 +223,7 @@ impl StreamCheckService {
test_prompt,
request_timeout,
provider,
claude_api_format_override.as_deref(),
)
.await
}
@@ -303,6 +312,7 @@ impl StreamCheckService {
test_prompt: &str,
timeout: std::time::Duration,
provider: &Provider,
claude_api_format_override: Option<&str>,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot;
@@ -320,19 +330,28 @@ impl StreamCheckService {
})
.unwrap_or("anthropic");
let effective_api_format = claude_api_format_override.unwrap_or(api_format);
let is_full_url = provider
.meta
.as_ref()
.and_then(|meta| meta.is_full_url)
.unwrap_or(false);
let is_openai_chat = is_github_copilot || api_format == "openai_chat";
let is_openai_responses = !is_github_copilot && api_format == "openai_responses";
let url = Self::resolve_claude_stream_url(base, auth.strategy, api_format, is_full_url);
let is_openai_chat = effective_api_format == "openai_chat";
let is_openai_responses = effective_api_format == "openai_responses";
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.
let anthropic_body = json!({
"model": model,
"max_tokens": 1,
"max_tokens": max_tokens,
"messages": [{ "role": "user", "content": test_prompt }],
"stream": true
});
@@ -730,7 +749,9 @@ impl StreamCheckService {
let base = base_url.trim_end_matches('/');
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")
} else if api_format == "openai_responses" {
if base.ends_with("/v1") {
@@ -764,6 +785,15 @@ impl StreamCheckService {
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)]
@@ -884,13 +914,25 @@ mod tests {
let url = StreamCheckService::resolve_claude_stream_url(
"https://api.githubcopilot.com",
AuthStrategy::GitHubCopilot,
"anthropic",
"openai_chat",
false,
);
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]
fn test_resolve_claude_stream_url_for_openai_chat() {
let url = StreamCheckService::resolve_claude_stream_url(
+4 -4
View File
@@ -453,10 +453,10 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
websiteUrl: "https://www.kimi.com/coding/docs/",
apiKeyUrl: "https://platform.moonshot.cn/console/api-keys",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
npm: "@ai-sdk/anthropic",
name: "Kimi For Coding",
options: {
baseURL: "https://api.kimi.com/v1",
baseURL: "https://api.kimi.com/coding/v1",
apiKey: "",
setCacheKey: true,
},
@@ -470,8 +470,8 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
templateValues: {
baseURL: {
label: "Base URL",
placeholder: "https://api.kimi.com/v1",
defaultValue: "https://api.kimi.com/v1",
placeholder: "https://api.kimi.com/coding/v1",
defaultValue: "https://api.kimi.com/coding/v1",
editorValue: "",
},
apiKey: {
+30 -3
View File
@@ -139,10 +139,18 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
// 切换供应商
const switchProvider = useCallback(
async (provider: Provider) => {
const isCopilotProvider =
activeApp === "claude" &&
provider.meta?.providerType === "github_copilot";
// Determine why this provider requires the proxy
let proxyRequiredReason: string | null = null;
if (!isProxyRunning && provider.category !== "official") {
if (
if (isCopilotProvider) {
proxyRequiredReason = t("notifications.proxyReasonCopilot", {
defaultValue: "使用 GitHub Copilot 作为 Claude 供应商",
});
} else if (
provider.meta?.apiFormat === "openai_chat" &&
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 =
activeApp === "opencode" || activeApp === "openclaw";
const messageKey = isMultiProviderApp
+3
View File
@@ -175,9 +175,12 @@
"settingsSaved": "Settings saved",
"settingsSaveFailed": "Failed to save settings: {{error}}",
"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",
"proxyReasonOpenAIResponses": "uses OpenAI Responses API format",
"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",
"openclawModelsRegistered": "Models have been registered to /model list",
"openclawDefaultModelSet": "Set as default model",
+3
View File
@@ -175,9 +175,12 @@
"settingsSaved": "設定を保存しました",
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
"proxyRequiredForSwitch": "このプロバイダーは{{reason}}、プロキシサービスが必要です。先にプロキシを起動してください",
"proxyReasonCopilot": "GitHub Copilot を Claude プロバイダーとして使用しており",
"proxyReasonOpenAIChat": "OpenAI Chat API フォーマットを使用しており",
"proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており",
"proxyReasonFullUrl": "完全 URL 接続モードが有効になっており",
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です",
"copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルプロキシが常に必要です。プロキシは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。",
"openLinkFailed": "リンクを開けませんでした",
"openclawModelsRegistered": "モデルが /model リストに登録されました",
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
+3
View File
@@ -175,9 +175,12 @@
"settingsSaved": "设置已保存",
"settingsSaveFailed": "保存设置失败:{{error}}",
"proxyRequiredForSwitch": "此供应商{{reason}},需要代理服务才能正常使用,请先启动代理",
"proxyReasonCopilot": "使用 GitHub Copilot 作为 Claude 供应商",
"proxyReasonOpenAIChat": "使用 OpenAI Chat 接口格式",
"proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式",
"proxyReasonFullUrl": "开启了完整 URL 连接模式",
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
"copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地代理;代理会根据当前模型自动选择 Chat Completions 或 Responses。",
"openLinkFailed": "链接打开失败",
"openclawModelsRegistered": "模型已注册到 /model 列表",
"openclawDefaultModelSet": "已设为默认模型",
@@ -65,4 +65,19 @@ describe("AWS Bedrock OpenCode Provider Presets", () => {
modelIds.some((id) => id.includes("anthropic.claude")),
).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",
);
});
});