mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
Merge branch 'main' into feat/gemini-proxy-integration
This commit is contained in:
@@ -133,6 +133,7 @@ pub async fn stream_check_all_providers(
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
error_category: None,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ use super::{
|
||||
},
|
||||
response_processor::{
|
||||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||||
strip_entity_headers_for_rebuilt_body, SseUsageCollector,
|
||||
strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers,
|
||||
SseUsageCollector,
|
||||
},
|
||||
server::ProxyState,
|
||||
types::*,
|
||||
@@ -227,10 +228,6 @@ async fn handle_claude_transform(
|
||||
"Cache-Control",
|
||||
axum::http::HeaderValue::from_static("no-cache"),
|
||||
);
|
||||
headers.insert(
|
||||
"Connection",
|
||||
axum::http::HeaderValue::from_static("keep-alive"),
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
return Ok((headers, body).into_response());
|
||||
@@ -306,6 +303,7 @@ async fn handle_claude_transform(
|
||||
// 构建响应
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
strip_entity_headers_for_rebuilt_body(&mut response_headers);
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
|
||||
for (key, value) in response_headers.iter() {
|
||||
builder = builder.header(key, value);
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::{
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::header::HeaderMap;
|
||||
use axum::http::{header::HeaderMap, HeaderName};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
@@ -68,6 +68,41 @@ fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
|
||||
.filter(|s| !s.is_empty() && s != "identity")
|
||||
}
|
||||
|
||||
/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。
|
||||
const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
/// 移除响应侧 hop-by-hop 头,以及 `Connection` 中点名的扩展头。
|
||||
pub(crate) fn strip_hop_by_hop_response_headers(headers: &mut HeaderMap) {
|
||||
let connection_listed_headers: Vec<HeaderName> = headers
|
||||
.get_all(axum::http::header::CONNECTION)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.flat_map(|value| value.split(','))
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.filter_map(|name| HeaderName::from_bytes(name.as_bytes()).ok())
|
||||
.collect();
|
||||
|
||||
for name in HOP_BY_HOP_RESPONSE_HEADERS {
|
||||
headers.remove(*name);
|
||||
}
|
||||
|
||||
for name in connection_listed_headers {
|
||||
headers.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除在重建响应体后会失真的实体头。
|
||||
pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) {
|
||||
headers.remove(axum::http::header::CONTENT_ENCODING);
|
||||
@@ -163,10 +198,13 @@ pub async fn handle_streaming(
|
||||
);
|
||||
}
|
||||
|
||||
let mut response_headers = response.headers().clone();
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
|
||||
// 复制响应头
|
||||
for (key, value) in response.headers() {
|
||||
for (key, value) in &response_headers {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
|
||||
@@ -207,8 +245,9 @@ pub async fn handle_non_streaming(
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
let (response_headers, status, body_bytes) =
|
||||
let (mut response_headers, status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
strip_hop_by_hop_response_headers(&mut response_headers);
|
||||
|
||||
log::debug!(
|
||||
"[{}] 上游响应体内容: {}",
|
||||
@@ -713,6 +752,90 @@ mod tests {
|
||||
assert_eq!(super::strip_sse_field("id:1", "data"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_hop_by_hop_response_headers_removes_standard_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::CONNECTION,
|
||||
axum::http::HeaderValue::from_static("keep-alive"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::HeaderName::from_static("keep-alive"),
|
||||
axum::http::HeaderValue::from_static("timeout=5"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::TRANSFER_ENCODING,
|
||||
axum::http::HeaderValue::from_static("chunked"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::HeaderName::from_static("proxy-connection"),
|
||||
axum::http::HeaderValue::from_static("keep-alive"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_LENGTH,
|
||||
axum::http::HeaderValue::from_static("12"),
|
||||
);
|
||||
|
||||
strip_hop_by_hop_response_headers(&mut headers);
|
||||
|
||||
assert!(!headers.contains_key(axum::http::header::CONNECTION));
|
||||
assert!(!headers.contains_key("keep-alive"));
|
||||
assert!(!headers.contains_key(axum::http::header::TRANSFER_ENCODING));
|
||||
assert!(!headers.contains_key("proxy-connection"));
|
||||
assert_eq!(
|
||||
headers.get(axum::http::header::CONTENT_TYPE),
|
||||
Some(&axum::http::HeaderValue::from_static("application/json"))
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get(axum::http::header::CONTENT_LENGTH),
|
||||
Some(&axum::http::HeaderValue::from_static("12"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_hop_by_hop_response_headers_removes_connection_listed_extensions() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(
|
||||
axum::http::header::CONNECTION,
|
||||
axum::http::HeaderValue::from_static("x-trace-hop, x-debug-hop"),
|
||||
);
|
||||
headers.append(
|
||||
axum::http::header::CONNECTION,
|
||||
axum::http::HeaderValue::from_static("upgrade"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::HeaderName::from_static("x-trace-hop"),
|
||||
axum::http::HeaderValue::from_static("trace"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::HeaderName::from_static("x-debug-hop"),
|
||||
axum::http::HeaderValue::from_static("debug"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::UPGRADE,
|
||||
axum::http::HeaderValue::from_static("websocket"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
|
||||
strip_hop_by_hop_response_headers(&mut headers);
|
||||
|
||||
assert!(!headers.contains_key(axum::http::header::CONNECTION));
|
||||
assert!(!headers.contains_key("x-trace-hop"));
|
||||
assert!(!headers.contains_key("x-debug-hop"));
|
||||
assert!(!headers.contains_key(axum::http::header::UPGRADE));
|
||||
assert_eq!(
|
||||
headers.get(axum::http::header::CONTENT_TYPE),
|
||||
Some(&axum::http::HeaderValue::from_static("text/event-stream"))
|
||||
);
|
||||
}
|
||||
|
||||
fn build_state(db: Arc<Database>) -> ProxyState {
|
||||
ProxyState {
|
||||
db: db.clone(),
|
||||
|
||||
@@ -57,8 +57,8 @@ impl Default for StreamCheckConfig {
|
||||
max_retries: 2,
|
||||
degraded_threshold_ms: 6000,
|
||||
claude_model: "claude-haiku-4-5-20251001".to_string(),
|
||||
codex_model: "gpt-5.1-codex@low".to_string(),
|
||||
gemini_model: "gemini-3-pro-preview".to_string(),
|
||||
codex_model: "gpt-5.4@low".to_string(),
|
||||
gemini_model: "gemini-3-flash-preview".to_string(),
|
||||
test_prompt: default_test_prompt(),
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,9 @@ pub struct StreamCheckResult {
|
||||
pub model_used: String,
|
||||
pub tested_at: i64,
|
||||
pub retry_count: u32,
|
||||
/// 细粒度错误分类(如 "modelNotFound"),前端据此渲染专门的文案
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_category: Option<String>,
|
||||
}
|
||||
|
||||
/// 流式健康检查服务
|
||||
@@ -145,6 +148,7 @@ impl StreamCheckService {
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: effective_config.max_retries,
|
||||
error_category: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -277,6 +281,7 @@ impl StreamCheckService {
|
||||
result,
|
||||
response_time,
|
||||
config.degraded_threshold_ms,
|
||||
&model_to_test,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -458,8 +463,7 @@ impl StreamCheckService {
|
||||
.header("x-stainless-retry-count", "0")
|
||||
.header("x-stainless-timeout", "600")
|
||||
// Other headers
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("connection", "keep-alive");
|
||||
.header("sec-fetch-mode", "cors");
|
||||
}
|
||||
|
||||
// 供应商自定义 headers 最后追加,允许覆盖内置默认值(例如 user-agent)
|
||||
@@ -701,16 +705,21 @@ impl StreamCheckService {
|
||||
result,
|
||||
response_time,
|
||||
config.degraded_threshold_ms,
|
||||
&model_to_test,
|
||||
))
|
||||
}
|
||||
|
||||
/// 将 check_*_stream 的原始结果包装成 StreamCheckResult
|
||||
///
|
||||
/// 抽取自 check_once 的末尾逻辑,以便 OpenCode/OpenClaw 的独立分支复用。
|
||||
///
|
||||
/// `model_tested` 是本次探测使用的模型名,用于在失败场景下仍能把模型信息透传给前端,
|
||||
/// 方便针对"模型不存在 / 已下架"这类错误渲染专门的提示。
|
||||
fn build_stream_check_result(
|
||||
result: Result<(u16, String), AppError>,
|
||||
response_time: u64,
|
||||
degraded_threshold_ms: u64,
|
||||
model_tested: &str,
|
||||
) -> StreamCheckResult {
|
||||
let tested_at = chrono::Utc::now().timestamp();
|
||||
match result {
|
||||
@@ -723,14 +732,19 @@ impl StreamCheckService {
|
||||
model_used: model,
|
||||
tested_at,
|
||||
retry_count: 0,
|
||||
error_category: None,
|
||||
},
|
||||
Err(e) => {
|
||||
let (http_status, message) = match &e {
|
||||
AppError::HttpStatus { status, .. } => (
|
||||
Some(*status),
|
||||
Self::classify_http_status(*status).to_string(),
|
||||
),
|
||||
_ => (None, e.to_string()),
|
||||
let (http_status, message, error_category) = match &e {
|
||||
AppError::HttpStatus { status, body } => {
|
||||
let category = Self::detect_error_category(*status, body);
|
||||
(
|
||||
Some(*status),
|
||||
Self::classify_http_status(*status).to_string(),
|
||||
category.map(|s| s.to_string()),
|
||||
)
|
||||
}
|
||||
_ => (None, e.to_string(), None),
|
||||
};
|
||||
StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
@@ -738,14 +752,47 @@ impl StreamCheckService {
|
||||
message,
|
||||
response_time_ms: Some(response_time),
|
||||
http_status,
|
||||
model_used: String::new(),
|
||||
model_used: model_tested.to_string(),
|
||||
tested_at,
|
||||
retry_count: 0,
|
||||
error_category,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于 HTTP 状态码和响应体识别细粒度错误分类。
|
||||
///
|
||||
/// 目前仅识别"模型不存在 / 已下架":各厂商该类错误通常返回 4xx,body 中会包含
|
||||
/// 如 `model_not_found`(OpenAI)、`does not exist`、`invalid model`、`not_found_error`
|
||||
/// + `model` 字样(Anthropic)等标记。
|
||||
pub(crate) fn detect_error_category(status: u16, body: &str) -> Option<&'static str> {
|
||||
// 只检查 4xx;5xx 的错误信息里可能巧合出现"model"之类的词,容易误判
|
||||
if !(400..500).contains(&status) {
|
||||
return None;
|
||||
}
|
||||
let lower = body.to_lowercase();
|
||||
// 必须提到 "model",避免通用 404 / 400 被误判
|
||||
if !lower.contains("model") {
|
||||
return None;
|
||||
}
|
||||
let indicators = [
|
||||
"model_not_found",
|
||||
"model not found",
|
||||
"does not exist",
|
||||
"invalid_model",
|
||||
"invalid model",
|
||||
"unknown_model",
|
||||
"unknown model",
|
||||
"is not a valid model",
|
||||
"not_found_error", // Anthropic 的 type 字段
|
||||
];
|
||||
if indicators.iter().any(|s| lower.contains(s)) {
|
||||
return Some("modelNotFound");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// OpenClaw 流式检查分发器
|
||||
///
|
||||
/// 根据 `settings_config.api` 字段分发到对应协议的检查器。
|
||||
@@ -1511,6 +1558,51 @@ mod tests {
|
||||
assert_eq!(effort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_model_not_found() {
|
||||
// OpenAI 典型响应:404 + model_not_found 错误码
|
||||
let openai_404 = r#"{"error":{"message":"The model `gpt-5.1-codex` does not exist or you do not have access to it","type":"invalid_request_error","param":null,"code":"model_not_found"}}"#;
|
||||
assert_eq!(
|
||||
StreamCheckService::detect_error_category(404, openai_404),
|
||||
Some("modelNotFound")
|
||||
);
|
||||
|
||||
// Anthropic 典型响应:404 + not_found_error + 提到 model
|
||||
let anthropic_404 = r#"{"type":"error","error":{"type":"not_found_error","message":"model: claude-deprecated"}}"#;
|
||||
assert_eq!(
|
||||
StreamCheckService::detect_error_category(404, anthropic_404),
|
||||
Some("modelNotFound")
|
||||
);
|
||||
|
||||
// 400 + invalid model 也算
|
||||
let bad_req = r#"{"error":{"message":"invalid model specified"}}"#;
|
||||
assert_eq!(
|
||||
StreamCheckService::detect_error_category(400, bad_req),
|
||||
Some("modelNotFound")
|
||||
);
|
||||
|
||||
// 通用 404(比如 Base URL 错误),body 里没有 model 字样 → 不应误判
|
||||
let generic_404 = r#"{"error":"Not Found"}"#;
|
||||
assert_eq!(
|
||||
StreamCheckService::detect_error_category(404, generic_404),
|
||||
None
|
||||
);
|
||||
|
||||
// 5xx 就算 body 里有 "model does not exist" 也不分类(避免误判)
|
||||
let server_error = r#"{"error":"model does not exist"}"#;
|
||||
assert_eq!(
|
||||
StreamCheckService::detect_error_category(500, server_error),
|
||||
None
|
||||
);
|
||||
|
||||
// 401 鉴权错误(body 里没有 model 字样)
|
||||
let auth_err = r#"{"error":"Invalid API key"}"#;
|
||||
assert_eq!(
|
||||
StreamCheckService::detect_error_category(401, auth_err),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_os_name() {
|
||||
let os_name = StreamCheckService::get_os_name();
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ModelTestConfigPanel() {
|
||||
degradedThresholdMs: "6000",
|
||||
claudeModel: "claude-haiku-4-5-20251001",
|
||||
codexModel: "gpt-5.4@low",
|
||||
geminiModel: "gemini-3-pro-preview",
|
||||
geminiModel: "gemini-3-flash-preview",
|
||||
testPrompt: "Who are you?",
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,22 @@ export function useStreamCheck(appId: AppId) {
|
||||
|
||||
// 降级状态也重置熔断器,因为至少能通信
|
||||
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||
} else if (result.errorCategory === "modelNotFound") {
|
||||
// 专门处理"模型不存在/已下架":指向配置入口,比通用 404 文案更有指导性
|
||||
toast.error(
|
||||
t("streamCheck.modelNotFound", {
|
||||
providerName: providerName,
|
||||
model: result.modelUsed,
|
||||
defaultValue: `${providerName} 测试模型 ${result.modelUsed} 不存在或已下架`,
|
||||
}),
|
||||
{
|
||||
description: t("streamCheck.modelNotFoundHint", {
|
||||
defaultValue: "",
|
||||
}),
|
||||
duration: 10000,
|
||||
closeButton: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const httpStatus = result.httpStatus;
|
||||
const hintKey = httpStatus
|
||||
|
||||
@@ -2134,6 +2134,8 @@
|
||||
"failed": "{{providerName}} check failed: {{message}}",
|
||||
"rejected": "{{providerName}} check rejected: {{message}}",
|
||||
"error": "{{providerName}} check error: {{error}}",
|
||||
"modelNotFound": "{{providerName}} test model {{model}} does not exist or has been deprecated",
|
||||
"modelNotFoundHint": "This model may have been retired by the provider. Update the default test model in \"Model Test Config\".",
|
||||
"httpHint": {
|
||||
"400": "Provider rejected request format. Health check probe may differ from actual usage.",
|
||||
"401": "API key may be invalid, or provider uses OAuth auth. Check failure doesn't mean it's unusable.",
|
||||
|
||||
@@ -2134,6 +2134,8 @@
|
||||
"failed": "{{providerName}} のチェックに失敗しました: {{message}}",
|
||||
"rejected": "{{providerName}} のチェックが拒否されました: {{message}}",
|
||||
"error": "{{providerName}} のチェックでエラーが発生しました: {{error}}",
|
||||
"modelNotFound": "{{providerName}} のテストモデル {{model}} は存在しないか廃止されています",
|
||||
"modelNotFoundHint": "このモデルはプロバイダーにより廃止された可能性があります。「モデルテスト設定」でデフォルトのテストモデルを更新してください。",
|
||||
"httpHint": {
|
||||
"400": "リクエスト形式が拒否されました。ヘルスチェックの形式は実際の使用と異なる場合があります。",
|
||||
"401": "APIキーが無効か、OAuthなどの認証方式を使用しています。チェック失敗は実際に使えないことを意味しません。",
|
||||
|
||||
@@ -2135,6 +2135,8 @@
|
||||
"failed": "{{providerName}} 检查失败: {{message}}",
|
||||
"rejected": "{{providerName}} 检查被拒: {{message}}",
|
||||
"error": "{{providerName}} 检查出错: {{error}}",
|
||||
"modelNotFound": "{{providerName}} 测试模型 {{model}} 不存在或已下架",
|
||||
"modelNotFoundHint": "该模型可能已被供应商弃用。请在\"模型测试配置\"中更新默认测试模型。",
|
||||
"httpHint": {
|
||||
"400": "供应商拒绝了请求格式。健康检查的探测格式可能与实际使用不同。",
|
||||
"401": "API Key 可能无效,或供应商使用 OAuth 等认证方式。检查失败不代表实际不可用。",
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface StreamCheckResult {
|
||||
modelUsed: string;
|
||||
testedAt: number;
|
||||
retryCount: number;
|
||||
/** 细粒度错误分类,如 "modelNotFound" */
|
||||
errorCategory?: string;
|
||||
}
|
||||
|
||||
// ===== 流式健康检查 API =====
|
||||
|
||||
Reference in New Issue
Block a user