mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1292a82cd | |||
| 0c4226a271 | |||
| 654e537746 | |||
| da416036e4 | |||
| 7486023dde | |||
| 227fc79e7e | |||
| 7402f1ca05 |
@@ -133,7 +133,6 @@ pub async fn stream_check_all_providers(
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
error_category: None,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -35,18 +35,26 @@ pub fn get_usage_trends(
|
||||
#[tauri::command]
|
||||
pub fn get_provider_stats(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<Vec<ProviderStats>, AppError> {
|
||||
state.db.get_provider_stats(app_type.as_deref())
|
||||
state
|
||||
.db
|
||||
.get_provider_stats(start_date, end_date, app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取模型统计
|
||||
#[tauri::command]
|
||||
pub fn get_model_stats(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
) -> Result<Vec<ModelStats>, AppError> {
|
||||
state.db.get_model_stats(app_type.as_deref())
|
||||
state
|
||||
.db
|
||||
.get_model_stats(start_date, end_date, app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取请求日志列表
|
||||
|
||||
@@ -9,10 +9,7 @@ use super::{
|
||||
failover_switch::FailoverSwitchManager,
|
||||
log_codes::fwd as log_fwd,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{
|
||||
gemini_shadow::GeminiShadowStore, get_adapter, AuthInfo, AuthStrategy, ProviderAdapter,
|
||||
ProviderType,
|
||||
},
|
||||
providers::{get_adapter, AuthInfo, AuthStrategy, ProviderAdapter, ProviderType},
|
||||
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
@@ -46,15 +43,12 @@ pub struct RequestForwarder {
|
||||
router: Arc<ProviderRouter>,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
gemini_shadow: Arc<GeminiShadowStore>,
|
||||
/// 故障转移切换管理器
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
/// AppHandle,用于发射事件和更新托盘
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||
current_provider_id_at_start: String,
|
||||
/// 代理会话 ID(用于 Gemini Native shadow replay)
|
||||
session_id: String,
|
||||
/// 整流器配置
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
@@ -72,11 +66,9 @@ impl RequestForwarder {
|
||||
non_streaming_timeout: u64,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
gemini_shadow: Arc<GeminiShadowStore>,
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
current_provider_id_at_start: String,
|
||||
session_id: String,
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
@@ -87,11 +79,9 @@ impl RequestForwarder {
|
||||
router,
|
||||
status,
|
||||
current_providers,
|
||||
gemini_shadow,
|
||||
failover_manager,
|
||||
app_handle,
|
||||
current_provider_id_at_start,
|
||||
session_id,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
@@ -928,7 +918,7 @@ impl RequestForwarder {
|
||||
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, &mapped_body)
|
||||
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot)
|
||||
} else {
|
||||
(
|
||||
endpoint.to_string(),
|
||||
@@ -938,13 +928,7 @@ impl RequestForwarder {
|
||||
)
|
||||
};
|
||||
|
||||
let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) {
|
||||
super::gemini_url::resolve_gemini_native_url(
|
||||
&base_url,
|
||||
&effective_endpoint,
|
||||
is_full_url,
|
||||
)
|
||||
} else if is_full_url {
|
||||
let url = if is_full_url {
|
||||
append_query_to_full_url(&base_url, passthrough_query.as_deref())
|
||||
} else {
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
@@ -960,8 +944,6 @@ impl RequestForwarder {
|
||||
mapped_body,
|
||||
provider,
|
||||
api_format,
|
||||
Some(&self.session_id),
|
||||
Some(self.gemini_shadow.as_ref()),
|
||||
)?
|
||||
} else {
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
@@ -1161,11 +1143,8 @@ impl RequestForwarder {
|
||||
.ok()
|
||||
.and_then(|u| u.authority().map(|a| a.to_string()));
|
||||
|
||||
let should_send_anthropic_headers = adapter.name() == "Claude"
|
||||
&& matches!(resolved_claude_api_format.as_deref(), Some("anthropic"));
|
||||
|
||||
// 预计算 anthropic-beta 值(仅 Claude)
|
||||
let anthropic_beta_value = if should_send_anthropic_headers {
|
||||
let anthropic_beta_value = if adapter.name() == "Claude" {
|
||||
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
|
||||
Some(if let Some(beta) = headers.get("anthropic-beta") {
|
||||
if let Ok(beta_str) = beta.to_str() {
|
||||
@@ -1285,10 +1264,8 @@ impl RequestForwarder {
|
||||
|
||||
// --- anthropic-version — 透传客户端值 ---
|
||||
if key_str.eq_ignore_ascii_case("anthropic-version") {
|
||||
if should_send_anthropic_headers {
|
||||
saw_anthropic_version = true;
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
}
|
||||
saw_anthropic_version = true;
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1329,7 +1306,7 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
// anthropic-version:仅在缺失时补充默认值
|
||||
if should_send_anthropic_headers && !saw_anthropic_version {
|
||||
if adapter.name() == "Claude" && !saw_anthropic_version {
|
||||
ordered_headers.append(
|
||||
"anthropic-version",
|
||||
http::HeaderValue::from_static("2023-06-01"),
|
||||
@@ -1673,7 +1650,6 @@ fn rewrite_claude_transform_endpoint(
|
||||
endpoint: &str,
|
||||
api_format: &str,
|
||||
is_copilot: bool,
|
||||
body: &Value,
|
||||
) -> (String, Option<String>) {
|
||||
let (path, query) = split_endpoint_and_query(endpoint);
|
||||
let passthrough_query = if is_claude_messages_path(path) {
|
||||
@@ -1686,36 +1662,6 @@ fn rewrite_claude_transform_endpoint(
|
||||
return (endpoint.to_string(), passthrough_query);
|
||||
}
|
||||
|
||||
if api_format == "gemini_native" {
|
||||
let model =
|
||||
super::providers::transform_gemini::extract_gemini_model(body).unwrap_or("unknown");
|
||||
// Accept both bare ids (`gemini-2.5-pro`) and the resource-name
|
||||
// form (`models/gemini-2.5-pro`) that Gemini SDKs emit. See
|
||||
// `normalize_gemini_model_id` for rationale.
|
||||
let model = super::gemini_url::normalize_gemini_model_id(model);
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
let target_path = if is_stream {
|
||||
format!("/v1beta/models/{model}:streamGenerateContent")
|
||||
} else {
|
||||
format!("/v1beta/models/{model}:generateContent")
|
||||
};
|
||||
|
||||
let rewritten_query = merge_query_params(
|
||||
passthrough_query.as_deref(),
|
||||
if is_stream { Some("alt=sse") } else { None },
|
||||
);
|
||||
|
||||
let rewritten = match rewritten_query.as_deref() {
|
||||
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
|
||||
_ => target_path,
|
||||
};
|
||||
|
||||
return (rewritten, rewritten_query);
|
||||
}
|
||||
|
||||
let target_path = if is_copilot && api_format == "openai_responses" {
|
||||
"/v1/responses"
|
||||
} else if is_copilot {
|
||||
@@ -1734,26 +1680,6 @@ fn rewrite_claude_transform_endpoint(
|
||||
(rewritten, passthrough_query)
|
||||
}
|
||||
|
||||
fn merge_query_params(base_query: Option<&str>, extra_param: Option<&str>) -> Option<String> {
|
||||
let mut params: Vec<String> = base_query
|
||||
.into_iter()
|
||||
.flat_map(|query| query.split('&'))
|
||||
.filter(|pair| !pair.is_empty())
|
||||
.filter(|pair| !pair.starts_with("alt="))
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
if let Some(extra_param) = extra_param {
|
||||
params.push(extra_param.to_string());
|
||||
}
|
||||
|
||||
if params.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(params.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(query) if !query.is_empty() => {
|
||||
@@ -1882,7 +1808,6 @@ mod tests {
|
||||
"/v1/messages?beta=true&foo=bar",
|
||||
"openai_chat",
|
||||
false,
|
||||
&json!({ "model": "gpt-5.4" }),
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/chat/completions?foo=bar");
|
||||
@@ -1895,7 +1820,6 @@ mod tests {
|
||||
"/claude/v1/messages?beta=true&x-id=1",
|
||||
"openai_responses",
|
||||
false,
|
||||
&json!({ "model": "gpt-5.4" }),
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/responses?x-id=1");
|
||||
@@ -1904,12 +1828,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_uses_copilot_path() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true&x-id=1",
|
||||
"anthropic",
|
||||
true,
|
||||
&json!({ "model": "claude-sonnet-4-6" }),
|
||||
);
|
||||
let (endpoint, passthrough_query) =
|
||||
rewrite_claude_transform_endpoint("/v1/messages?beta=true&x-id=1", "anthropic", true);
|
||||
|
||||
assert_eq!(endpoint, "/chat/completions?x-id=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
@@ -1921,60 +1841,12 @@ mod tests {
|
||||
"/v1/messages?beta=true&x-id=1",
|
||||
"openai_responses",
|
||||
true,
|
||||
&json!({ "model": "gpt-5.4" }),
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1/responses?x-id=1");
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_maps_gemini_generate_content() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true&x-id=1",
|
||||
"gemini_native",
|
||||
false,
|
||||
&json!({ "model": "gemini-2.5-pro" }),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
endpoint,
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent?x-id=1"
|
||||
);
|
||||
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
|
||||
}
|
||||
|
||||
/// Regression: body.model arriving as the resource-name form
|
||||
/// `models/gemini-2.5-pro` must not produce a doubled
|
||||
/// `/v1beta/models/models/...` path.
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_strips_gemini_model_resource_prefix() {
|
||||
let (endpoint, _) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages",
|
||||
"gemini_native",
|
||||
false,
|
||||
&json!({ "model": "models/gemini-2.5-pro" }),
|
||||
);
|
||||
|
||||
assert_eq!(endpoint, "/v1beta/models/gemini-2.5-pro:generateContent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_maps_gemini_streaming() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
"/v1/messages?beta=true",
|
||||
"gemini_native",
|
||||
false,
|
||||
&json!({ "model": "gemini-2.5-flash", "stream": true }),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
endpoint,
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
assert_eq!(passthrough_query.as_deref(), Some("alt=sse"));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
@@ -1982,43 +1854,6 @@ mod tests {
|
||||
assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_gemini_native_url_uses_origin_when_base_ends_with_v1beta() {
|
||||
let url = crate::proxy::gemini_url::build_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_gemini_native_url_uses_origin_when_base_already_contains_models_prefix() {
|
||||
let url = crate::proxy::gemini_url::build_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_gemini_native_url_keeps_opaque_full_url_as_is() {
|
||||
let url = crate::proxy::gemini_url::resolve_gemini_native_url(
|
||||
"https://relay.example/custom/generate-content",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_identity_for_stream_flag_requests() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
@@ -1,665 +0,0 @@
|
||||
//! Gemini Native URL helpers.
|
||||
//!
|
||||
//! Normalizes legacy Gemini/OpenAI-compatible base URLs into the canonical
|
||||
//! Gemini Native `models/*:generateContent` endpoints.
|
||||
|
||||
/// Normalize a Gemini model identifier to its bare form, stripping an
|
||||
/// optional leading `models/` (and any leading `/`) so that the value can
|
||||
/// be safely interpolated into a URL template like
|
||||
/// `/v1beta/models/{model}:generateContent`.
|
||||
///
|
||||
/// Gemini SDKs and documentation commonly surface model ids as
|
||||
/// `models/gemini-2.5-pro` (the resource-name form). Passing that value
|
||||
/// through to the format string would otherwise yield a doubled prefix
|
||||
/// like `/v1beta/models/models/gemini-2.5-pro:generateContent`, which is
|
||||
/// rejected by the upstream API and turns any health check or live
|
||||
/// request into a false negative.
|
||||
pub fn normalize_gemini_model_id(model: &str) -> &str {
|
||||
let trimmed = model.strip_prefix('/').unwrap_or(model);
|
||||
trimmed.strip_prefix("models/").unwrap_or(trimmed)
|
||||
}
|
||||
|
||||
pub fn resolve_gemini_native_url(base_url: &str, endpoint: &str, is_full_url: bool) -> String {
|
||||
if !is_full_url || should_normalize_gemini_full_url(base_url) {
|
||||
return build_gemini_native_url(base_url, endpoint);
|
||||
}
|
||||
|
||||
let base_url = base_url
|
||||
.split_once('#')
|
||||
.map_or(base_url, |(base, _)| base)
|
||||
.trim_end_matches('/');
|
||||
let (base_without_query, base_query) = split_query(base_url);
|
||||
let (_, endpoint_query) = split_query(endpoint);
|
||||
|
||||
let mut url = base_without_query.to_string();
|
||||
if let Some(query) = merge_queries(base_query, endpoint_query) {
|
||||
url.push('?');
|
||||
url.push_str(&query);
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
pub fn build_gemini_native_url(base_url: &str, endpoint: &str) -> String {
|
||||
let base_url = base_url
|
||||
.split_once('#')
|
||||
.map_or(base_url, |(base, _)| base)
|
||||
.trim_end_matches('/');
|
||||
let (base_without_query, base_query) = split_query(base_url);
|
||||
let (endpoint_without_query, endpoint_query) = split_query(endpoint);
|
||||
|
||||
let endpoint_path = format!("/{}", endpoint_without_query.trim_start_matches('/'));
|
||||
let (origin, raw_path) = split_origin_and_path(base_without_query);
|
||||
let prefix_path = normalize_gemini_base_path(raw_path);
|
||||
|
||||
let mut url = if prefix_path.is_empty() {
|
||||
format!("{origin}{endpoint_path}")
|
||||
} else {
|
||||
format!("{origin}{prefix_path}{endpoint_path}")
|
||||
};
|
||||
|
||||
if let Some(query) = merge_queries(base_query, endpoint_query) {
|
||||
url.push('?');
|
||||
url.push_str(&query);
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
fn should_normalize_gemini_full_url(base_url: &str) -> bool {
|
||||
let base_url = base_url
|
||||
.split_once('#')
|
||||
.map_or(base_url, |(base, _)| base)
|
||||
.trim_end_matches('/');
|
||||
let (base_without_query, _) = split_query(base_url);
|
||||
let (origin, path) = split_origin_and_path(base_without_query);
|
||||
|
||||
if path.is_empty() || path == "/" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let path = path.trim_end_matches('/');
|
||||
let on_google_host = is_google_gemini_host(extract_host(origin));
|
||||
|
||||
// Unconditional layer: only paths whose grammar is *intrinsically*
|
||||
// Gemini-specific — the `/models/...:generateContent` method-call
|
||||
// shape and the deep OpenAI-compat endpoints (`/openai/chat/completions`,
|
||||
// `/openai/responses`) that are implausibly used as a relay's fixed
|
||||
// terminal path — get rewritten regardless of host.
|
||||
if matches_structured_gemini_models_path(path)
|
||||
|| path.ends_with("/v1beta/openai/chat/completions")
|
||||
|| path.ends_with("/v1beta/openai/responses")
|
||||
|| path.ends_with("/openai/chat/completions")
|
||||
|| path.ends_with("/openai/responses")
|
||||
|| path.ends_with("/v1/openai/chat/completions")
|
||||
|| path.ends_with("/v1/openai/responses")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// All other version / resource-root suffixes — `/v1beta`, `/v1`,
|
||||
// `/models`, `/openai`, and variants — could legitimately be an
|
||||
// opaque relay's fixed endpoint (`https://relay.example/custom/v1beta`
|
||||
// is a real deployment shape, even if uncommon outside Google's
|
||||
// ecosystem). Only rewrite when the host itself is Google's Gemini
|
||||
// or Vertex AI endpoint.
|
||||
if on_google_host
|
||||
&& (path.ends_with("/v1beta")
|
||||
|| path.ends_with("/v1beta/models")
|
||||
|| path.ends_with("/v1beta/openai")
|
||||
|| path.ends_with("/v1")
|
||||
|| path.ends_with("/v1/models")
|
||||
|| path.ends_with("/models")
|
||||
|| path.ends_with("/v1/openai")
|
||||
|| path.ends_with("/openai"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract the host portion of an origin like `https://host:port` or
|
||||
/// `https://host`. Returns an empty string if no host can be found (e.g.
|
||||
/// bare `http://`).
|
||||
fn extract_host(origin: &str) -> &str {
|
||||
let after_scheme = origin.split_once("://").map_or(origin, |(_, rest)| rest);
|
||||
// authority may carry credentials (`user:pass@host`) and a port
|
||||
// (`host:port`). Strip userinfo first, then port.
|
||||
let without_userinfo = after_scheme
|
||||
.rsplit_once('@')
|
||||
.map_or(after_scheme, |(_, h)| h);
|
||||
let without_port = without_userinfo
|
||||
.split_once(':')
|
||||
.map_or(without_userinfo, |(h, _)| h);
|
||||
// Strip trailing `/` defensively (split_origin_and_path already handled
|
||||
// it, but this helper may be reused elsewhere).
|
||||
without_port.trim_end_matches('/')
|
||||
}
|
||||
|
||||
/// Returns true when `host` is one of Google's Gemini / Vertex AI endpoints.
|
||||
/// Case-insensitive. Requires exact match or a real `-aiplatform.googleapis.com`
|
||||
/// subdomain suffix — not a substring match, so lookalikes like
|
||||
/// `aiplatform.example.com` are rejected.
|
||||
fn is_google_gemini_host(host: &str) -> bool {
|
||||
if host.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
host_lower == "generativelanguage.googleapis.com"
|
||||
|| host_lower == "aiplatform.googleapis.com"
|
||||
|| host_lower.ends_with("-aiplatform.googleapis.com")
|
||||
}
|
||||
|
||||
fn split_query(input: &str) -> (&str, Option<&str>) {
|
||||
input
|
||||
.split_once('?')
|
||||
.map_or((input, None), |(path, query)| (path, Some(query)))
|
||||
}
|
||||
|
||||
fn split_origin_and_path(base_url: &str) -> (&str, &str) {
|
||||
let Some(scheme_sep) = base_url.find("://") else {
|
||||
return (base_url, "");
|
||||
};
|
||||
let authority_start = scheme_sep + 3;
|
||||
let Some(path_start_rel) = base_url[authority_start..].find('/') else {
|
||||
return (base_url, "");
|
||||
};
|
||||
let path_start = authority_start + path_start_rel;
|
||||
(&base_url[..path_start], &base_url[path_start..])
|
||||
}
|
||||
|
||||
fn normalize_gemini_base_path(path: &str) -> String {
|
||||
let path = path.trim_end_matches('/');
|
||||
if path.is_empty() || path == "/" {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
for marker in ["/v1beta/models/", "/v1/models/", "/models/"] {
|
||||
if let Some(index) = path.find(marker) {
|
||||
return normalize_prefix(&path[..index]);
|
||||
}
|
||||
}
|
||||
|
||||
for suffix in [
|
||||
"/v1beta/openai/chat/completions",
|
||||
"/v1/openai/chat/completions",
|
||||
"/openai/chat/completions",
|
||||
"/v1beta/openai/responses",
|
||||
"/v1/openai/responses",
|
||||
"/openai/responses",
|
||||
"/v1beta/openai",
|
||||
"/v1/openai",
|
||||
"/openai",
|
||||
"/v1beta/models",
|
||||
"/v1/models",
|
||||
"/models",
|
||||
"/v1beta",
|
||||
"/v1",
|
||||
] {
|
||||
if path == suffix {
|
||||
return String::new();
|
||||
}
|
||||
if let Some(prefix) = path.strip_suffix(suffix) {
|
||||
return normalize_prefix(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
fn normalize_prefix(prefix: &str) -> String {
|
||||
let prefix = prefix.trim_end_matches('/');
|
||||
if prefix.is_empty() || prefix == "/" {
|
||||
String::new()
|
||||
} else {
|
||||
prefix.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when `path` contains a `/models/` segment followed by a
|
||||
/// canonical Gemini method call (`*:generateContent` or
|
||||
/// `*:streamGenerateContent`). The `/models/` segment alone is not enough:
|
||||
/// opaque relay routes such as `/v1/models/invoke` or `/custom/models/foo`
|
||||
/// also contain `/models/` but are not Gemini-structured and must not be
|
||||
/// rewritten.
|
||||
fn matches_structured_gemini_models_path(path: &str) -> bool {
|
||||
let mut cursor = path;
|
||||
while let Some(idx) = cursor.find("/models/") {
|
||||
let after = &cursor[idx + "/models/".len()..];
|
||||
if after.contains(":generateContent") || after.contains(":streamGenerateContent") {
|
||||
return true;
|
||||
}
|
||||
// Advance past this `/models/` occurrence so a later Gemini-style
|
||||
// segment in the same path (unusual but cheap to handle) can still
|
||||
// match.
|
||||
cursor = &cursor[idx + "/models/".len()..];
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn merge_queries(base_query: Option<&str>, endpoint_query: Option<&str>) -> Option<String> {
|
||||
let parts: Vec<&str> = [base_query, endpoint_query]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|query| query.split('&'))
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect();
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_gemini_native_url, normalize_gemini_model_id, resolve_gemini_native_url};
|
||||
|
||||
#[test]
|
||||
fn strips_version_root_for_official_base() {
|
||||
let url = build_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_openai_compat_path_for_official_base() {
|
||||
let url = build_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
||||
"/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_custom_proxy_prefix_while_stripping_openai_suffix() {
|
||||
let url = build_gemini_native_url(
|
||||
"https://proxy.example.com/google/v1beta/openai/chat/completions",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://proxy.example.com/google/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_model_method_path_from_full_url_base() {
|
||||
let url = build_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_structured_full_url_by_normalizing_to_requested_method() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_opaque_full_url_without_appending_gemini_models_path() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/generate-content",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_containing_models_segment() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/models/invoke",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/models/invoke?alt=sse");
|
||||
}
|
||||
|
||||
/// Regression: a relay whose fixed path starts with `/v1/models/` is an
|
||||
/// opaque route, not a Gemini-structured endpoint. The previous
|
||||
/// heuristic matched any `contains("/v1/models/")` and rewrote it to
|
||||
/// `/v1beta/models/{model}:generateContent`, dropping the relay's own
|
||||
/// route component (`/invoke`) and sending traffic to the wrong place.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_v1_models_prefix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/v1/models/invoke",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/v1/models/invoke?alt=sse");
|
||||
}
|
||||
|
||||
/// Same regression, `/v1beta/models/` variant — relays may use Google's
|
||||
/// path layout defensively for routing while still being opaque.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_v1beta_models_prefix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/v1beta/models/route",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/v1beta/models/route?alt=sse");
|
||||
}
|
||||
|
||||
/// Counter-case: a full URL that *does* carry a genuine Gemini method
|
||||
/// segment (`:generateContent`) under `/v1/models/` should still be
|
||||
/// recognized as structured and normalized to the requested model.
|
||||
#[test]
|
||||
fn normalizes_structured_v1_models_path_with_method_segment() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/v1/models/gemini-2.5-pro:generateContent",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://relay.example/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Google-host whitelist tests (generic REST suffix handling)
|
||||
//
|
||||
// Generic REST conventions like `/v1`, `/models`, `/openai` legitimately
|
||||
// appear on opaque relays. `should_normalize_gemini_full_url` only
|
||||
// treats these as structured Gemini endpoints when the host itself is
|
||||
// Google's Gemini or Vertex AI endpoint.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Regression: a relay whose fixed path ends with `/v1` (a ubiquitous
|
||||
/// REST convention) used to be rewritten to
|
||||
/// `/v1beta/models/{model}:generateContent`, dropping the relay's own
|
||||
/// `/v1` endpoint.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_v1_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/v1?alt=sse");
|
||||
}
|
||||
|
||||
/// Companion case: bare `/models` suffix on a non-Google host is a
|
||||
/// generic REST path, not a Gemini-structured endpoint.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_models_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/models?alt=sse");
|
||||
}
|
||||
|
||||
/// Companion case: `/v1/models` — same ambiguity as `/models`, with the
|
||||
/// version prefix. Must stay as-is on non-Google hosts.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_v1_models_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/v1/models?alt=sse");
|
||||
}
|
||||
|
||||
/// Companion case: a relay that exposes an `/openai` compatibility
|
||||
/// surface without the deep `/openai/chat/completions` path. Must stay
|
||||
/// as-is on non-Google hosts.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_openai_suffix_on_non_google_host() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/openai",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/openai?alt=sse");
|
||||
}
|
||||
|
||||
/// Counter-case: `/v1` on the official Gemini host must still be
|
||||
/// normalized to the full `/v1beta/models/...` endpoint — users who
|
||||
/// paste `https://generativelanguage.googleapis.com/v1` as their base
|
||||
/// URL expect the proxy to resolve the method path.
|
||||
#[test]
|
||||
fn normalizes_google_host_with_v1_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Counter-case: `/models` on the official Gemini host is recognized
|
||||
/// and normalized.
|
||||
#[test]
|
||||
fn normalizes_google_host_with_models_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Counter-case: Vertex AI regional endpoints live under
|
||||
/// `*-aiplatform.googleapis.com`. Those should also be treated as
|
||||
/// Google-host for the whitelist.
|
||||
#[test]
|
||||
fn normalizes_vertex_aiplatform_host_with_v1_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://us-central1-aiplatform.googleapis.com/v1",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Safety: the Google-host whitelist must do an exact/suffix match, not
|
||||
/// a `contains`. A lookalike host like `aiplatform.example.com` must
|
||||
/// NOT be treated as Google.
|
||||
#[test]
|
||||
fn preserves_non_google_aiplatform_lookalike_host() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://aiplatform.example.com/v1",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://aiplatform.example.com/v1?alt=sse");
|
||||
}
|
||||
|
||||
/// Regression: `/v1beta` by itself is Google-conventional but not
|
||||
/// literally impossible on other hosts. An opaque relay fronting a
|
||||
/// non-Gemini service at `https://relay.example/custom/v1beta` would
|
||||
/// be silently rewritten if `/v1beta` were classified as unconditional
|
||||
/// structured Gemini. Require the Google-host whitelist instead.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_bare_v1beta_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1beta",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/v1beta?alt=sse");
|
||||
}
|
||||
|
||||
/// Companion case: `/v1beta/models` (no method segment) on a non-Google
|
||||
/// host stays as-is too.
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_with_v1beta_models_suffix_no_method() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1beta/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/v1beta/models?alt=sse");
|
||||
}
|
||||
|
||||
/// Counter-case: `/v1beta` on the official Gemini host must still
|
||||
/// normalize — this is the canonical base URL shape users paste from
|
||||
/// AI Studio documentation.
|
||||
#[test]
|
||||
fn normalizes_google_host_with_v1beta_suffix() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression guard: in non-full-URL mode, a versioned third-party
|
||||
/// relay base must have its `/v1beta` suffix **stripped** so the
|
||||
/// appended standard endpoint (`/v1beta/models/{model}:method`) does
|
||||
/// not produce a doubled `/v1beta/v1beta/models/...` path. Non-full
|
||||
/// mode's contract is "base URL + cc-switch appends the canonical
|
||||
/// Gemini endpoint" — a user who wants a relay's custom namespace
|
||||
/// (e.g. `/v1/models/...`) must use full-URL mode instead.
|
||||
///
|
||||
/// This test pins the intentional asymmetry with
|
||||
/// `preserves_opaque_full_url_with_bare_v1beta_suffix` (full-URL
|
||||
/// preserves, non-full strips) so nobody "fixes" one side into
|
||||
/// breaking the other.
|
||||
#[test]
|
||||
fn strips_versioned_relay_base_suffix_in_non_full_url_mode() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1beta",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://relay.example/custom/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion case: `/v1` base suffix also stripped in non-full-URL
|
||||
/// mode regardless of host.
|
||||
#[test]
|
||||
fn strips_v1_relay_base_suffix_in_non_full_url_mode() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://relay.example/custom/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Model ID normalization tests.
|
||||
//
|
||||
// Gemini SDKs and documentation commonly surface model identifiers as
|
||||
// `models/gemini-2.5-pro` (resource-name form). If that value flows
|
||||
// straight into our URL builder, the format string
|
||||
// `/v1beta/models/{model}:generateContent` produces a doubled prefix
|
||||
// `/v1beta/models/models/gemini-2.5-pro:generateContent`, which is
|
||||
// rejected upstream. `normalize_gemini_model_id` is the single source
|
||||
// of truth callers should run the model through first.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn normalize_model_id_strips_models_prefix() {
|
||||
assert_eq!(
|
||||
normalize_gemini_model_id("models/gemini-2.5-pro"),
|
||||
"gemini-2.5-pro"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_model_id_leaves_bare_id_unchanged() {
|
||||
assert_eq!(
|
||||
normalize_gemini_model_id("gemini-2.5-pro"),
|
||||
"gemini-2.5-pro"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_model_id_preserves_nested_slashes_after_prefix() {
|
||||
// e.g. tuned model resource like `models/gemini-2.5-pro/tunedModels/xxx`
|
||||
// — the caller asked for a specific tuned model resource, keep its
|
||||
// identity intact after stripping only the single leading prefix.
|
||||
assert_eq!(
|
||||
normalize_gemini_model_id("models/tunedModels/my-tuned"),
|
||||
"tunedModels/my-tuned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_model_id_tolerates_leading_slash() {
|
||||
assert_eq!(
|
||||
normalize_gemini_model_id("/models/gemini-2.5-flash"),
|
||||
"gemini-2.5-flash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_model_id_preserves_empty_input() {
|
||||
// Edge: caller has no model at all. Pass through so the URL error
|
||||
// surfaces at the request layer rather than producing a misleading
|
||||
// empty segment here.
|
||||
assert_eq!(normalize_gemini_model_id(""), "");
|
||||
}
|
||||
}
|
||||
@@ -218,11 +218,9 @@ impl RequestContext {
|
||||
non_streaming_timeout,
|
||||
state.status.clone(),
|
||||
state.current_providers.clone(),
|
||||
state.gemini_shadow.clone(),
|
||||
state.failover_manager.clone(),
|
||||
state.app_handle.clone(),
|
||||
self.current_provider_id.clone(),
|
||||
self.session_id.clone(),
|
||||
first_byte_timeout,
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
|
||||
@@ -15,9 +15,8 @@ use super::{
|
||||
handler_context::RequestContext,
|
||||
providers::{
|
||||
get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream,
|
||||
streaming_gemini::create_anthropic_sse_stream_from_gemini,
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
|
||||
transform_gemini, transform_responses,
|
||||
transform_responses,
|
||||
},
|
||||
response_processor::{
|
||||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||||
@@ -146,13 +145,11 @@ async fn handle_claude_transform(
|
||||
response: super::hyper_client::ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
original_body: &Value,
|
||||
_original_body: &Value,
|
||||
is_stream: bool,
|
||||
api_format: &str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
let tool_schema_hints = transform_gemini::extract_anthropic_tool_schema_hints(original_body);
|
||||
let tool_schema_hints = (!tool_schema_hints.is_empty()).then_some(tool_schema_hints);
|
||||
|
||||
if is_stream {
|
||||
// 根据 api_format 选择流式转换器
|
||||
@@ -161,14 +158,6 @@ async fn handle_claude_transform(
|
||||
dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin,
|
||||
> = if api_format == "openai_responses" {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream)))
|
||||
} else if api_format == "gemini_native" {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream_from_gemini(
|
||||
stream,
|
||||
Some(state.gemini_shadow.clone()),
|
||||
Some(ctx.provider.id.clone()),
|
||||
Some(ctx.session_id.clone()),
|
||||
tool_schema_hints.clone(),
|
||||
)))
|
||||
} else {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
|
||||
};
|
||||
@@ -253,14 +242,6 @@ async fn handle_claude_transform(
|
||||
// 根据 api_format 选择非流式转换器
|
||||
let anthropic_response = if api_format == "openai_responses" {
|
||||
transform_responses::responses_to_anthropic(upstream_response)
|
||||
} else if api_format == "gemini_native" {
|
||||
transform_gemini::gemini_to_anthropic_with_shadow_and_hints(
|
||||
upstream_response,
|
||||
Some(state.gemini_shadow.as_ref()),
|
||||
Some(&ctx.provider.id),
|
||||
Some(&ctx.session_id),
|
||||
tool_schema_hints.as_ref(),
|
||||
)
|
||||
} else {
|
||||
transform::openai_to_anthropic(upstream_response)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ pub mod error;
|
||||
pub mod error_mapper;
|
||||
pub(crate) mod failover_switch;
|
||||
mod forwarder;
|
||||
pub mod gemini_url;
|
||||
pub mod handler_config;
|
||||
pub mod handler_context;
|
||||
mod handlers;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||||
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||||
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
|
||||
//! - **gemini_native**: Google Gemini Native generateContent 格式,需要 Anthropic ↔ Gemini 转换
|
||||
//!
|
||||
//! ## 认证模式
|
||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
@@ -36,7 +35,6 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
"gemini_native" => "gemini_native",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
@@ -51,7 +49,6 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
"gemini_native" => "gemini_native",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
@@ -76,18 +73,13 @@ 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" | "gemini_native"
|
||||
)
|
||||
matches!(api_format, "openai_chat" | "openai_responses")
|
||||
}
|
||||
|
||||
pub fn transform_claude_request_for_api_format(
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
api_format: &str,
|
||||
session_id: Option<&str>,
|
||||
shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
// Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key
|
||||
// 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识
|
||||
@@ -146,24 +138,7 @@ pub fn transform_claude_request_for_api_format(
|
||||
is_codex_oauth,
|
||||
)
|
||||
}
|
||||
"openai_chat" => {
|
||||
let mut result = super::transform::anthropic_to_openai(body)?;
|
||||
// Inject prompt_cache_key only if explicitly configured in meta
|
||||
if let Some(key) = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
{
|
||||
result["prompt_cache_key"] = serde_json::json!(key);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
|
||||
body,
|
||||
shadow_store,
|
||||
Some(&provider.id),
|
||||
session_id,
|
||||
),
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body),
|
||||
_ => Ok(body),
|
||||
}
|
||||
}
|
||||
@@ -185,16 +160,6 @@ impl ClaudeAdapter {
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
// 检测 Gemini Native 格式
|
||||
if self.get_api_format(provider) == "gemini_native" {
|
||||
return match self.extract_key(provider) {
|
||||
Some(key) if key.starts_with("ya29.") || key.starts_with('{') => {
|
||||
ProviderType::GeminiCli
|
||||
}
|
||||
_ => ProviderType::Gemini,
|
||||
};
|
||||
}
|
||||
|
||||
// 检测 Codex OAuth (ChatGPT Plus/Pro)
|
||||
if self.is_codex_oauth(provider) {
|
||||
return ProviderType::CodexOAuth;
|
||||
@@ -297,7 +262,6 @@ impl ClaudeAdapter {
|
||||
if let Some(key) = env
|
||||
.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
|
||||
@@ -306,7 +270,6 @@ impl ClaudeAdapter {
|
||||
if let Some(key) = env
|
||||
.get("ANTHROPIC_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
|
||||
@@ -316,7 +279,6 @@ impl ClaudeAdapter {
|
||||
if let Some(key) = env
|
||||
.get("OPENROUTER_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
|
||||
@@ -326,7 +288,6 @@ impl ClaudeAdapter {
|
||||
if let Some(key) = env
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
||||
@@ -340,7 +301,6 @@ impl ClaudeAdapter {
|
||||
.get("apiKey")
|
||||
.or_else(|| provider.settings_config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 apiKey/api_key");
|
||||
@@ -428,43 +388,14 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
));
|
||||
}
|
||||
|
||||
let key = self.extract_key(provider)?;
|
||||
let strategy = match provider_type {
|
||||
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||||
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||||
_ => AuthStrategy::Anthropic,
|
||||
};
|
||||
|
||||
match provider_type {
|
||||
ProviderType::GeminiCli => {
|
||||
// Parse stored OAuth JSON and only attach access_token when
|
||||
// it's actually usable. `parse_oauth_credentials` accepts
|
||||
// refresh-token-only JSON (which is legitimate before the
|
||||
// first refresh) and also surfaces `{"access_token": "", ...}`
|
||||
// for expired credentials. In both cases we would otherwise
|
||||
// send `Authorization: Bearer ` to upstream and get a 401.
|
||||
//
|
||||
// CC Switch does not currently exchange the refresh_token for
|
||||
// a fresh access_token. Until that path exists, degrade to
|
||||
// plain GoogleOAuth strategy (which still sends the raw key
|
||||
// as a fallback) and log loudly so users know to refresh
|
||||
// their `~/.gemini/oauth_creds.json`.
|
||||
match super::gemini::GeminiAdapter::new().parse_oauth_credentials(&key) {
|
||||
Some(creds) if !creds.access_token.is_empty() => {
|
||||
Some(AuthInfo::with_access_token(key, creds.access_token))
|
||||
}
|
||||
Some(_) => {
|
||||
log::warn!(
|
||||
"[Gemini OAuth] access_token missing or empty for provider `{}`; \
|
||||
bearer auth will likely fail with 401. Refresh \
|
||||
~/.gemini/oauth_creds.json via the gemini CLI to obtain a new token.",
|
||||
provider.id
|
||||
);
|
||||
Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth))
|
||||
}
|
||||
None => Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth)),
|
||||
}
|
||||
}
|
||||
ProviderType::Gemini => Some(AuthInfo::new(key, AuthStrategy::Google)),
|
||||
ProviderType::OpenRouter => Some(AuthInfo::new(key, AuthStrategy::Bearer)),
|
||||
ProviderType::ClaudeAuth => Some(AuthInfo::new(key, AuthStrategy::ClaudeAuth)),
|
||||
_ => Some(AuthInfo::new(key, AuthStrategy::Anthropic)),
|
||||
}
|
||||
self.extract_key(provider)
|
||||
.map(|key| AuthInfo::new(key, strategy))
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
@@ -506,23 +437,6 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
}
|
||||
AuthStrategy::Google => vec![(
|
||||
HeaderName::from_static("x-goog-api-key"),
|
||||
HeaderValue::from_str(&auth.api_key).unwrap(),
|
||||
)],
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-goog-api-client"),
|
||||
HeaderValue::from_static("GeminiCLI/1.0"),
|
||||
),
|
||||
]
|
||||
}
|
||||
AuthStrategy::CodexOAuth => {
|
||||
// 注意:bearer token 由 forwarder 动态注入到 auth.api_key
|
||||
// ChatGPT-Account-Id 由 forwarder 注入额外 header
|
||||
@@ -593,6 +507,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,7 +528,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
|
||||
matches!(
|
||||
self.get_api_format(provider),
|
||||
"openai_chat" | "openai_responses" | "gemini_native"
|
||||
"openai_chat" | "openai_responses"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -622,13 +537,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
transform_claude_request_for_api_format(
|
||||
body,
|
||||
provider,
|
||||
self.get_api_format(provider),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
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> {
|
||||
@@ -637,9 +546,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// config, so we can't check api_format here. Instead we rely on the fact that
|
||||
// Responses API always returns "output" while Chat Completions returns "choices".
|
||||
// This is safe because the two formats are structurally disjoint.
|
||||
if body.get("candidates").is_some() || body.get("promptFeedback").is_some() {
|
||||
super::transform_gemini::gemini_to_anthropic(body)
|
||||
} else if body.get("output").is_some() {
|
||||
if body.get("output").is_some() {
|
||||
super::transform_responses::responses_to_anthropic(body)
|
||||
} else {
|
||||
super::transform::openai_to_anthropic(body)
|
||||
@@ -777,146 +684,6 @@ mod tests {
|
||||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||
}
|
||||
|
||||
/// Regression: a Gemini OAuth credential JSON that carries only a
|
||||
/// refresh_token (no active access_token) must not be surfaced as an
|
||||
/// `AuthInfo` whose bearer would be empty. Without the guard, downstream
|
||||
/// header injection produces `Authorization: Bearer ` and a deterministic
|
||||
/// 401 from upstream.
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_cli_refresh_only_json_does_not_expose_empty_bearer() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let refresh_only_json =
|
||||
r#"{"refresh_token":"rt-abc","client_id":"cid","client_secret":"cs"}"#;
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": refresh_only_json
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
// access_token must not be surfaced as `Some("")` — the OAuth header
|
||||
// builder uses `access_token.as_ref().unwrap_or(&api_key)`, so a
|
||||
// `Some("")` would win over the raw key and emit `Bearer `.
|
||||
assert!(
|
||||
auth.access_token.as_deref().is_none_or(|t| !t.is_empty()),
|
||||
"empty access_token leaked into AuthInfo"
|
||||
);
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
}
|
||||
|
||||
/// Companion case: a JSON credential with an empty-string `access_token`
|
||||
/// field (the shape an expired credential can take after partial writes)
|
||||
/// must degrade the same way.
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_cli_empty_access_token_degrades_to_raw_key() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let expired_json = r#"{"access_token":"","refresh_token":"rt-abc","client_id":"cid","client_secret":"cs"}"#;
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": expired_json
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert!(
|
||||
auth.access_token.as_deref().is_none_or(|t| !t.is_empty()),
|
||||
"empty access_token leaked into AuthInfo"
|
||||
);
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
}
|
||||
|
||||
/// Counter-case: a well-formed JSON credential with a non-empty
|
||||
/// access_token must still flow through the OAuth path unchanged.
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_cli_valid_json_keeps_access_token() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#;
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": valid_json
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.access_token.as_deref(), Some("ya29.valid"));
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
}
|
||||
|
||||
/// 回归:从 oauth_creds.json 复制时常带前导换行/空格。未 trim 时
|
||||
/// `starts_with('{')` 会落空,导致误分类为 `ProviderType::Gemini`,再
|
||||
/// 以 raw JSON 当 `x-goog-api-key` 发出去触发 401。trim 应在 provider
|
||||
/// 类型判定和 OAuth 解析前统一生效。
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_cli_json_with_leading_whitespace_classifies_correctly() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#;
|
||||
let key_with_whitespace = format!("\n {valid_json}\n");
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": key_with_whitespace
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.access_token.as_deref(), Some("ya29.valid"));
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
}
|
||||
|
||||
/// 回归:裸 `ya29.` access_token 若带前导换行,也应被 trim 后识别为
|
||||
/// Gemini CLI OAuth,避免前导空白把 `starts_with("ya29.")` 检查顶穿。
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_cli_access_token_with_leading_newline_classifies_correctly() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": "\nya29.raw-token-value\n"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.access_token.as_deref(), Some("ya29.raw-token-value"));
|
||||
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_type_detection() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
@@ -1083,24 +850,6 @@ mod tests {
|
||||
);
|
||||
assert!(adapter.needs_transform(&openai_responses_provider));
|
||||
|
||||
let gemini_native_provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(adapter.needs_transform(&gemini_native_provider));
|
||||
assert_eq!(
|
||||
adapter.provider_type(&gemini_native_provider),
|
||||
ProviderType::Gemini
|
||||
);
|
||||
|
||||
// meta takes precedence over legacy settings_config fields
|
||||
let meta_precedence_over_settings = create_provider_with_meta(
|
||||
json!({
|
||||
@@ -1208,106 +957,11 @@ mod tests {
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed = transform_claude_request_for_api_format(
|
||||
body,
|
||||
&provider,
|
||||
"openai_responses",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_gemini_native() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gemini-2.5-pro",
|
||||
"system": "You are helpful.",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 64
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "gemini_native", None, None)
|
||||
.unwrap();
|
||||
|
||||
assert!(transformed.get("contents").is_some());
|
||||
assert_eq!(
|
||||
transformed["systemInstruction"]["parts"][0]["text"],
|
||||
"You are helpful."
|
||||
);
|
||||
assert_eq!(transformed["generationConfig"]["maxOutputTokens"], 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_openai_chat_skips_prompt_cache_key_by_default()
|
||||
{
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_chat".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 64
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
|
||||
assert!(transformed.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_openai_chat_keeps_explicit_prompt_cache_key() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_chat".to_string()),
|
||||
prompt_cache_key: Some("claude-cache-route".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 64
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(transformed["prompt_cache_key"], "claude-cache-route");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +70,6 @@ impl GeminiAdapter {
|
||||
|
||||
/// 解析 OAuth 凭证
|
||||
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
|
||||
// 防御性 trim:前端在 input 事件中会 trim,但 JSON 编辑器 / deeplink
|
||||
// 导入 / live 回填等路径会绕过。带前导换行的 oauth_creds.json 粘贴
|
||||
// 是常见场景,此处统一兜底。
|
||||
let key = key.trim();
|
||||
|
||||
// 直接是 access_token
|
||||
if key.starts_with("ya29.") {
|
||||
return Some(OAuthCredentials {
|
||||
@@ -125,12 +120,7 @@ impl GeminiAdapter {
|
||||
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
// 使用 GEMINI_API_KEY
|
||||
if let Some(key) = env
|
||||
.get("GEMINI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
@@ -141,8 +131,6 @@ impl GeminiAdapter {
|
||||
.get("apiKey")
|
||||
.or_else(|| provider.settings_config.get("api_key"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return Some(key.to_string());
|
||||
}
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
//! Gemini tool schema helpers.
|
||||
//!
|
||||
//! Gemini `FunctionDeclaration` supports two schema channels:
|
||||
//! - `parameters`: a restricted `Schema` subset
|
||||
//! - `parametersJsonSchema`: richer JSON Schema via arbitrary JSON `Value`
|
||||
//!
|
||||
//! Anthropic tool schemas are closer to JSON Schema, so we choose the richer
|
||||
//! channel when unsupported `Schema` fields are present.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum GeminiFunctionParameters {
|
||||
Schema(Value),
|
||||
JsonSchema(Value),
|
||||
}
|
||||
|
||||
pub fn build_gemini_function_parameters(input_schema: Value) -> GeminiFunctionParameters {
|
||||
let schema = ensure_object_schema(normalize_json_schema(input_schema));
|
||||
|
||||
if requires_parameters_json_schema(&schema) {
|
||||
GeminiFunctionParameters::JsonSchema(schema)
|
||||
} else {
|
||||
GeminiFunctionParameters::Schema(to_gemini_schema(schema))
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertex AI rejects FunctionDeclarations whose `parameters` schema lacks an
|
||||
/// explicit `type: "object"`, returning:
|
||||
///
|
||||
/// > functionDeclaration parameters schema should be of type OBJECT.
|
||||
///
|
||||
/// Anthropic tools sometimes arrive with empty or type-less `input_schema`
|
||||
/// (e.g. no-argument tools like Claude Code's `TodoRead`). Normalize those to
|
||||
/// `{type: "object", properties: {}}` so the Gemini upstream accepts them.
|
||||
///
|
||||
/// References: google-gemini/generative-ai-python#423, BerriAI/litellm#5055.
|
||||
fn ensure_object_schema(schema: Value) -> Value {
|
||||
match schema {
|
||||
Value::Object(mut obj) => {
|
||||
obj.entry("type".to_string())
|
||||
.or_insert_with(|| json!("object"));
|
||||
if obj.get("type").and_then(|v| v.as_str()) == Some("object") {
|
||||
obj.entry("properties".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
}
|
||||
Value::Object(obj)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_json_schema(schema: Value) -> Value {
|
||||
match schema {
|
||||
Value::Object(mut obj) => {
|
||||
obj.remove("$schema");
|
||||
obj.remove("$id");
|
||||
|
||||
if let Some(properties) = obj
|
||||
.get_mut("properties")
|
||||
.and_then(|value| value.as_object_mut())
|
||||
{
|
||||
for value in properties.values_mut() {
|
||||
*value = normalize_json_schema(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(items) = obj.get_mut("items") {
|
||||
*items = normalize_json_schema(items.clone());
|
||||
}
|
||||
|
||||
for key in ["anyOf", "oneOf", "allOf", "prefixItems"] {
|
||||
if let Some(values) = obj.get_mut(key).and_then(|value| value.as_array_mut()) {
|
||||
for value in values.iter_mut() {
|
||||
*value = normalize_json_schema(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key in ["not", "if", "then", "else", "additionalProperties"] {
|
||||
if let Some(value) = obj.get_mut(key) {
|
||||
*value = normalize_json_schema(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(obj)
|
||||
}
|
||||
Value::Array(values) => {
|
||||
Value::Array(values.into_iter().map(normalize_json_schema).collect())
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_parameters_json_schema(schema: &Value) -> bool {
|
||||
match schema {
|
||||
Value::Object(obj) => object_requires_parameters_json_schema(obj),
|
||||
Value::Array(values) => values.iter().any(requires_parameters_json_schema),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn object_requires_parameters_json_schema(obj: &Map<String, Value>) -> bool {
|
||||
for (key, value) in obj {
|
||||
match key.as_str() {
|
||||
"type" => {
|
||||
if value.is_array() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"format" | "title" | "description" | "nullable" | "enum" | "maxItems" | "minItems"
|
||||
| "required" | "minProperties" | "maxProperties" | "minLength" | "maxLength"
|
||||
| "pattern" | "example" | "propertyOrdering" | "default" | "minimum" | "maximum" => {}
|
||||
"properties" => {
|
||||
let Some(properties) = value.as_object() else {
|
||||
return true;
|
||||
};
|
||||
if properties.values().any(requires_parameters_json_schema) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"items" => {
|
||||
if !value.is_object() || requires_parameters_json_schema(value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
"anyOf" => {
|
||||
let Some(values) = value.as_array() else {
|
||||
return true;
|
||||
};
|
||||
if values.iter().any(requires_parameters_json_schema) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// JSON Schema keywords that Gemini `parameters` does not accept.
|
||||
"$ref"
|
||||
| "$defs"
|
||||
| "definitions"
|
||||
| "additionalProperties"
|
||||
| "unevaluatedProperties"
|
||||
| "patternProperties"
|
||||
| "oneOf"
|
||||
| "allOf"
|
||||
| "const"
|
||||
| "not"
|
||||
| "if"
|
||||
| "then"
|
||||
| "else"
|
||||
| "dependentRequired"
|
||||
| "dependentSchemas"
|
||||
| "contains"
|
||||
| "minContains"
|
||||
| "maxContains"
|
||||
| "prefixItems"
|
||||
| "exclusiveMinimum"
|
||||
| "exclusiveMaximum"
|
||||
| "multipleOf"
|
||||
| "examples" => return true,
|
||||
// Be conservative for unknown keywords.
|
||||
_ => return true,
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn to_gemini_schema(schema: Value) -> Value {
|
||||
match schema {
|
||||
Value::Object(obj) => {
|
||||
let mut result = Map::new();
|
||||
|
||||
for (key, value) in obj {
|
||||
match key.as_str() {
|
||||
"type" | "format" | "title" | "description" | "nullable" | "enum"
|
||||
| "maxItems" | "minItems" | "required" | "minProperties" | "maxProperties"
|
||||
| "minLength" | "maxLength" | "pattern" | "example" | "propertyOrdering"
|
||||
| "default" | "minimum" | "maximum" => {
|
||||
result.insert(key, value);
|
||||
}
|
||||
"properties" => {
|
||||
if let Some(properties) = value.as_object() {
|
||||
let converted = properties
|
||||
.iter()
|
||||
.map(|(name, property_schema)| {
|
||||
(name.clone(), to_gemini_schema(property_schema.clone()))
|
||||
})
|
||||
.collect();
|
||||
result.insert("properties".to_string(), Value::Object(converted));
|
||||
}
|
||||
}
|
||||
"items" if value.is_object() => {
|
||||
result.insert("items".to_string(), to_gemini_schema(value));
|
||||
}
|
||||
"anyOf" => {
|
||||
if let Some(values) = value.as_array() {
|
||||
result.insert(
|
||||
"anyOf".to_string(),
|
||||
Value::Array(
|
||||
values
|
||||
.iter()
|
||||
.map(|value| to_gemini_schema(value.clone()))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(result)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_gemini_function_declaration(
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
input_schema: Value,
|
||||
) -> Value {
|
||||
let mut declaration = Map::new();
|
||||
declaration.insert("name".to_string(), json!(name));
|
||||
declaration.insert("description".to_string(), json!(description.unwrap_or("")));
|
||||
|
||||
match build_gemini_function_parameters(input_schema) {
|
||||
GeminiFunctionParameters::Schema(schema) => {
|
||||
declaration.insert("parameters".to_string(), schema);
|
||||
}
|
||||
GeminiFunctionParameters::JsonSchema(schema) => {
|
||||
declaration.insert("parametersJsonSchema".to_string(), schema);
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(declaration)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn uses_schema_for_simple_openapi_subset() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string", "description": "Target city" }
|
||||
},
|
||||
"required": ["city"]
|
||||
});
|
||||
|
||||
let result = build_gemini_function_declaration("weather", Some("Weather lookup"), schema);
|
||||
|
||||
assert!(result.get("parameters").is_some());
|
||||
assert!(result.get("parametersJsonSchema").is_none());
|
||||
assert_eq!(result["parameters"]["properties"]["city"]["type"], "string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_parameters_json_schema_for_additional_properties() {
|
||||
let schema = json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string" }
|
||||
},
|
||||
"required": ["city"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
|
||||
let result = build_gemini_function_declaration("weather", Some("Weather lookup"), schema);
|
||||
|
||||
assert!(result.get("parameters").is_none());
|
||||
assert!(result.get("parametersJsonSchema").is_some());
|
||||
assert!(result["parametersJsonSchema"].get("$schema").is_none());
|
||||
assert_eq!(
|
||||
result["parametersJsonSchema"]["additionalProperties"],
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_parameters_json_schema_for_one_of() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"oneOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = build_gemini_function_declaration("search", Some("Search"), schema);
|
||||
|
||||
assert!(result.get("parameters").is_none());
|
||||
assert!(result.get("parametersJsonSchema").is_some());
|
||||
}
|
||||
|
||||
/// Regression for P2 (Vertex AI rejecting empty schemas): zero-argument
|
||||
/// Anthropic tools (no `input_schema`) must produce `parameters` with an
|
||||
/// explicit `type: "object"` and an empty `properties` map so the Gemini
|
||||
/// upstream does not return `schema should be of type OBJECT`.
|
||||
#[test]
|
||||
fn empty_input_schema_produces_explicit_object_type() {
|
||||
let result = build_gemini_function_declaration("ping", Some("no-arg"), json!({}));
|
||||
|
||||
assert_eq!(result["parameters"]["type"], "object");
|
||||
assert!(result["parameters"]["properties"].is_object());
|
||||
}
|
||||
|
||||
/// A schema that carries descriptive fields but no `type` is still a
|
||||
/// zero-arg object for Gemini purposes — promote it explicitly.
|
||||
#[test]
|
||||
fn input_schema_missing_type_is_promoted_to_object() {
|
||||
let result = build_gemini_function_declaration(
|
||||
"noop",
|
||||
None,
|
||||
json!({ "description": "does nothing" }),
|
||||
);
|
||||
|
||||
assert_eq!(result["parameters"]["type"], "object");
|
||||
assert!(result["parameters"]["properties"].is_object());
|
||||
}
|
||||
|
||||
/// Defensive: an atomic (non-object) schema is left untouched, because
|
||||
/// forcing `type: "object"` here would corrupt primitive parameter types
|
||||
/// that happen to flow through this path.
|
||||
#[test]
|
||||
fn non_object_schema_is_not_mutated() {
|
||||
let result = build_gemini_function_declaration("bare", None, json!({ "type": "string" }));
|
||||
|
||||
assert_eq!(result["parameters"]["type"], "string");
|
||||
assert!(result["parameters"].get("properties").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
//! Gemini Native shadow state
|
||||
//!
|
||||
//! Keeps provider/session-scoped assistant content snapshots and tool call metadata
|
||||
//! so Gemini thought signatures and tool turns can be replayed without bloating
|
||||
//! the main proxy files.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// Composite key for a Gemini shadow session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct GeminiShadowKey {
|
||||
pub provider_id: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
impl GeminiShadowKey {
|
||||
pub fn new(provider_id: impl Into<String>, session_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
provider_id: provider_id.into(),
|
||||
session_id: session_id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gemini function call metadata captured from an assistant turn.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GeminiToolCallMeta {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
pub args: Value,
|
||||
pub thought_signature: Option<String>,
|
||||
}
|
||||
|
||||
impl GeminiToolCallMeta {
|
||||
pub fn new(
|
||||
id: Option<impl Into<String>>,
|
||||
name: impl Into<String>,
|
||||
args: Value,
|
||||
thought_signature: Option<impl Into<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.map(Into::into),
|
||||
name: name.into(),
|
||||
args,
|
||||
thought_signature: thought_signature.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored assistant turn snapshot.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GeminiAssistantTurn {
|
||||
pub assistant_content: Value,
|
||||
pub tool_calls: Vec<GeminiToolCallMeta>,
|
||||
}
|
||||
|
||||
impl GeminiAssistantTurn {
|
||||
pub fn new(assistant_content: Value, tool_calls: Vec<GeminiToolCallMeta>) -> Self {
|
||||
Self {
|
||||
assistant_content,
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session snapshot returned by read APIs.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GeminiShadowSessionSnapshot {
|
||||
pub provider_id: String,
|
||||
pub session_id: String,
|
||||
pub turns: Vec<GeminiAssistantTurn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GeminiShadowSession {
|
||||
turns: VecDeque<GeminiAssistantTurn>,
|
||||
}
|
||||
|
||||
impl GeminiShadowSession {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
turns: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct GeminiShadowInner {
|
||||
sessions: HashMap<GeminiShadowKey, GeminiShadowSession>,
|
||||
session_order: VecDeque<GeminiShadowKey>,
|
||||
}
|
||||
|
||||
impl GeminiShadowInner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
session_order: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shadow store for Gemini Native replay state.
|
||||
///
|
||||
/// The store is intentionally small and explicit:
|
||||
/// - sessions are keyed by `(provider_id, session_id)`
|
||||
/// - each session keeps only a bounded number of recent assistant turns
|
||||
/// - the oldest session is evicted first when the store is full
|
||||
#[derive(Debug)]
|
||||
pub struct GeminiShadowStore {
|
||||
max_sessions: usize,
|
||||
max_turns_per_session: usize,
|
||||
inner: RwLock<GeminiShadowInner>,
|
||||
}
|
||||
|
||||
impl Default for GeminiShadowStore {
|
||||
fn default() -> Self {
|
||||
Self::with_limits(200, 64)
|
||||
}
|
||||
}
|
||||
|
||||
impl GeminiShadowStore {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_limits(max_sessions: usize, max_turns_per_session: usize) -> Self {
|
||||
Self {
|
||||
max_sessions: max_sessions.max(1),
|
||||
max_turns_per_session: max_turns_per_session.max(1),
|
||||
inner: RwLock::new(GeminiShadowInner::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a Gemini assistant turn for later replay.
|
||||
pub fn record_assistant_turn(
|
||||
&self,
|
||||
provider_id: impl Into<String>,
|
||||
session_id: impl Into<String>,
|
||||
assistant_content: Value,
|
||||
tool_calls: Vec<GeminiToolCallMeta>,
|
||||
) -> GeminiShadowSessionSnapshot {
|
||||
let key = GeminiShadowKey::new(provider_id, session_id);
|
||||
let turn = GeminiAssistantTurn::new(assistant_content, tool_calls);
|
||||
|
||||
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
|
||||
Self::touch_session_order(&mut inner.session_order, &key);
|
||||
|
||||
let snapshot = {
|
||||
let session = inner
|
||||
.sessions
|
||||
.entry(key.clone())
|
||||
.or_insert_with(GeminiShadowSession::new);
|
||||
session.turns.push_back(turn);
|
||||
while session.turns.len() > self.max_turns_per_session {
|
||||
session.turns.pop_front();
|
||||
}
|
||||
Self::snapshot_session(&key, session)
|
||||
};
|
||||
Self::prune_sessions(&mut inner, self.max_sessions);
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Get the latest assistant content for a provider/session pair.
|
||||
#[allow(dead_code)]
|
||||
pub fn latest_assistant_content(&self, provider_id: &str, session_id: &str) -> Option<Value> {
|
||||
self.get_session(provider_id, session_id)
|
||||
.and_then(|snapshot| {
|
||||
snapshot
|
||||
.turns
|
||||
.last()
|
||||
.map(|turn| turn.assistant_content.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the latest tool calls for a provider/session pair.
|
||||
#[allow(dead_code)]
|
||||
pub fn latest_tool_calls(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
) -> Option<Vec<GeminiToolCallMeta>> {
|
||||
self.get_session(provider_id, session_id)
|
||||
.and_then(|snapshot| snapshot.turns.last().map(|turn| turn.tool_calls.clone()))
|
||||
}
|
||||
|
||||
/// Read a full session snapshot.
|
||||
pub fn get_session(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
session_id: &str,
|
||||
) -> Option<GeminiShadowSessionSnapshot> {
|
||||
let key = GeminiShadowKey::new(provider_id, session_id);
|
||||
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
|
||||
let snapshot = inner
|
||||
.sessions
|
||||
.get(&key)
|
||||
.map(|session| Self::snapshot_session(&key, session));
|
||||
if snapshot.is_some() {
|
||||
Self::touch_session_order(&mut inner.session_order, &key);
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Remove a single session from the store.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_session(&self, provider_id: &str, session_id: &str) -> bool {
|
||||
let key = GeminiShadowKey::new(provider_id, session_id);
|
||||
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
|
||||
let removed = inner.sessions.remove(&key).is_some();
|
||||
if removed {
|
||||
Self::remove_key_from_order(&mut inner.session_order, &key);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Remove all sessions for a provider.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_provider(&self, provider_id: &str) -> usize {
|
||||
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
|
||||
let keys: Vec<_> = inner
|
||||
.sessions
|
||||
.keys()
|
||||
.filter(|key| key.provider_id == provider_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in &keys {
|
||||
inner.sessions.remove(key);
|
||||
Self::remove_key_from_order(&mut inner.session_order, key);
|
||||
}
|
||||
keys.len()
|
||||
}
|
||||
|
||||
/// Number of tracked sessions.
|
||||
#[allow(dead_code)]
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("gemini shadow lock poisoned")
|
||||
.sessions
|
||||
.len()
|
||||
}
|
||||
|
||||
fn snapshot_session(
|
||||
key: &GeminiShadowKey,
|
||||
session: &GeminiShadowSession,
|
||||
) -> GeminiShadowSessionSnapshot {
|
||||
GeminiShadowSessionSnapshot {
|
||||
provider_id: key.provider_id.clone(),
|
||||
session_id: key.session_id.clone(),
|
||||
turns: session.turns.iter().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn touch_session_order(order: &mut VecDeque<GeminiShadowKey>, key: &GeminiShadowKey) {
|
||||
if let Some(pos) = order.iter().position(|existing| existing == key) {
|
||||
order.remove(pos);
|
||||
}
|
||||
order.push_back(key.clone());
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn remove_key_from_order(order: &mut VecDeque<GeminiShadowKey>, key: &GeminiShadowKey) {
|
||||
if let Some(pos) = order.iter().position(|existing| existing == key) {
|
||||
order.remove(pos);
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_sessions(inner: &mut GeminiShadowInner, max_sessions: usize) {
|
||||
while inner.sessions.len() > max_sessions {
|
||||
let Some(evicted_key) = inner.session_order.pop_front() else {
|
||||
break;
|
||||
};
|
||||
inner.sessions.remove(&evicted_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn record_and_read_latest_turn() {
|
||||
let store = GeminiShadowStore::with_limits(8, 4);
|
||||
let snapshot = store.record_assistant_turn(
|
||||
"provider-a",
|
||||
"session-1",
|
||||
json!({"parts": [{"text": "hello", "thoughtSignature": "sig-1"}]}),
|
||||
vec![GeminiToolCallMeta::new(
|
||||
Some("call-1"),
|
||||
"get_weather",
|
||||
json!({"location": "Tokyo"}),
|
||||
Some("sig-1"),
|
||||
)],
|
||||
);
|
||||
|
||||
assert_eq!(snapshot.provider_id, "provider-a");
|
||||
assert_eq!(snapshot.session_id, "session-1");
|
||||
assert_eq!(snapshot.turns.len(), 1);
|
||||
|
||||
let content = store
|
||||
.latest_assistant_content("provider-a", "session-1")
|
||||
.expect("content");
|
||||
assert_eq!(content["parts"][0]["text"], "hello");
|
||||
assert_eq!(content["parts"][0]["thoughtSignature"], "sig-1");
|
||||
|
||||
let tool_calls = store
|
||||
.latest_tool_calls("provider-a", "session-1")
|
||||
.expect("tool calls");
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
assert_eq!(tool_calls[0].id.as_deref(), Some("call-1"));
|
||||
assert_eq!(tool_calls[0].name, "get_weather");
|
||||
assert_eq!(tool_calls[0].args["location"], "Tokyo");
|
||||
assert_eq!(tool_calls[0].thought_signature.as_deref(), Some("sig-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_are_isolated_by_provider_and_session_id() {
|
||||
let store = GeminiShadowStore::with_limits(8, 4);
|
||||
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"text": "a"}), vec![]);
|
||||
store.record_assistant_turn("provider-b", "session-1", json!({"text": "b"}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-2", json!({"text": "c"}), vec![]);
|
||||
|
||||
assert_eq!(store.session_count(), 3);
|
||||
assert_eq!(
|
||||
store.latest_assistant_content("provider-a", "session-1"),
|
||||
Some(json!({"text": "a"}))
|
||||
);
|
||||
assert_eq!(
|
||||
store.latest_assistant_content("provider-b", "session-1"),
|
||||
Some(json!({"text": "b"}))
|
||||
);
|
||||
assert_eq!(
|
||||
store.latest_assistant_content("provider-a", "session-2"),
|
||||
Some(json!({"text": "c"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_only_latest_turns_per_session() {
|
||||
let store = GeminiShadowStore::with_limits(8, 2);
|
||||
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 2}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 3}), vec![]);
|
||||
|
||||
let snapshot = store
|
||||
.get_session("provider-a", "session-1")
|
||||
.expect("snapshot");
|
||||
assert_eq!(snapshot.turns.len(), 2);
|
||||
assert_eq!(snapshot.turns[0].assistant_content, json!({"idx": 2}));
|
||||
assert_eq!(snapshot.turns[1].assistant_content, json!({"idx": 3}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicts_oldest_session_when_capacity_is_exceeded() {
|
||||
let store = GeminiShadowStore::with_limits(2, 2);
|
||||
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-2", json!({"idx": 2}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-3", json!({"idx": 3}), vec![]);
|
||||
|
||||
assert!(store.get_session("provider-a", "session-1").is_none());
|
||||
assert!(store.get_session("provider-a", "session-2").is_some());
|
||||
assert!(store.get_session("provider-a", "session-3").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_session_and_provider_work() {
|
||||
let store = GeminiShadowStore::with_limits(8, 4);
|
||||
|
||||
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
|
||||
store.record_assistant_turn("provider-a", "session-2", json!({"idx": 2}), vec![]);
|
||||
store.record_assistant_turn("provider-b", "session-3", json!({"idx": 3}), vec![]);
|
||||
|
||||
assert!(store.clear_session("provider-a", "session-1"));
|
||||
assert!(store.get_session("provider-a", "session-1").is_none());
|
||||
|
||||
let removed = store.clear_provider("provider-a");
|
||||
assert_eq!(removed, 1);
|
||||
assert!(store.get_session("provider-a", "session-2").is_none());
|
||||
assert!(store.get_session("provider-b", "session-3").is_some());
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,10 @@ mod codex;
|
||||
pub mod codex_oauth_auth;
|
||||
pub mod copilot_auth;
|
||||
mod gemini;
|
||||
pub(crate) mod gemini_schema;
|
||||
pub mod gemini_shadow;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
pub mod streaming_gemini;
|
||||
pub mod streaming_responses;
|
||||
pub mod transform;
|
||||
pub mod transform_gemini;
|
||||
pub mod transform_responses;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
@@ -105,14 +101,6 @@ impl ProviderType {
|
||||
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if get_claude_api_format(provider) == "gemini_native" {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
return match adapter.extract_auth(provider).map(|auth| auth.strategy) {
|
||||
Some(AuthStrategy::GoogleOAuth) => ProviderType::GeminiCli,
|
||||
_ => ProviderType::Gemini,
|
||||
};
|
||||
}
|
||||
|
||||
// 检测是否为 GitHub Copilot
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
|
||||
|
||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -118,7 +118,10 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
Ok(bytes) => {
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
while let Some(line) = take_sse_block(&mut buffer) {
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let line = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@
|
||||
//! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。
|
||||
|
||||
use super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason};
|
||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
@@ -122,7 +122,10 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// SSE 事件由 \n\n 分隔
|
||||
while let Some(block) = take_sse_block(&mut buffer) {
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let block = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
if block.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -194,14 +194,23 @@ pub(crate) fn map_responses_stop_reason(
|
||||
incomplete_reason: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
status.map(|s| match s {
|
||||
"completed" if has_tool_use => "tool_use",
|
||||
"incomplete"
|
||||
"completed" => {
|
||||
if has_tool_use {
|
||||
"tool_use"
|
||||
} else {
|
||||
"end_turn"
|
||||
}
|
||||
}
|
||||
"incomplete" => {
|
||||
if matches!(
|
||||
incomplete_reason,
|
||||
Some("max_output_tokens") | Some("max_tokens")
|
||||
) || incomplete_reason.is_none() =>
|
||||
{
|
||||
"max_tokens"
|
||||
) || incomplete_reason.is_none()
|
||||
{
|
||||
"max_tokens"
|
||||
} else {
|
||||
"end_turn"
|
||||
}
|
||||
}
|
||||
_ => "end_turn",
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use super::session::ProxySession;
|
||||
use super::usage::parser::TokenUsage;
|
||||
use super::ProxyError;
|
||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::Value;
|
||||
@@ -86,7 +86,10 @@ impl StreamHandler {
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 提取完整事件
|
||||
while let Some(event_text) = take_sse_block(&mut buffer) {
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let event_text = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
if data.trim() != "[DONE]" {
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::{
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
hyper_client::ProxyResponse,
|
||||
server::ProxyState,
|
||||
sse::{strip_sse_field, take_sse_block},
|
||||
sse::strip_sse_field,
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
@@ -662,7 +662,10 @@ pub fn create_logged_passthrough_stream(
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 尝试解析并记录完整的 SSE 事件
|
||||
while let Some(event_text) = take_sse_block(&mut buffer) {
|
||||
while let Some(pos) = buffer.find("\n\n") {
|
||||
let event_text = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
if !event_text.trim().is_empty() {
|
||||
// 提取 data 部分并尝试解析为 JSON
|
||||
for line in event_text.lines() {
|
||||
@@ -723,7 +726,6 @@ mod tests {
|
||||
use crate::provider::ProviderMeta;
|
||||
use crate::proxy::failover_switch::FailoverSwitchManager;
|
||||
use crate::proxy::provider_router::ProviderRouter;
|
||||
use crate::proxy::providers::gemini_shadow::GeminiShadowStore;
|
||||
use crate::proxy::types::{ProxyConfig, ProxyStatus};
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashMap;
|
||||
@@ -844,7 +846,6 @@ mod tests {
|
||||
start_time: Arc::new(RwLock::new(None)),
|
||||
current_providers: Arc::new(RwLock::new(HashMap::new())),
|
||||
provider_router: Arc::new(ProviderRouter::new(db.clone())),
|
||||
gemini_shadow: Arc::new(GeminiShadowStore::default()),
|
||||
app_handle: None,
|
||||
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
|
||||
use super::{
|
||||
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
|
||||
provider_router::ProviderRouter, providers::gemini_shadow::GeminiShadowStore, types::*,
|
||||
ProxyError,
|
||||
provider_router::ProviderRouter, types::*, ProxyError,
|
||||
};
|
||||
use crate::database::Database;
|
||||
use axum::{
|
||||
@@ -36,8 +35,6 @@ pub struct ProxyState {
|
||||
pub current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态,跨请求保持)
|
||||
pub provider_router: Arc<ProviderRouter>,
|
||||
/// Gemini Native shadow state,用于 thoughtSignature / tool call 回放
|
||||
pub gemini_shadow: Arc<GeminiShadowStore>,
|
||||
/// AppHandle,用于发射事件和更新托盘菜单
|
||||
pub app_handle: Option<tauri::AppHandle>,
|
||||
/// 故障转移切换管理器
|
||||
@@ -71,7 +68,6 @@ impl ProxyServer {
|
||||
start_time: Arc::new(RwLock::new(None)),
|
||||
current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||
provider_router,
|
||||
gemini_shadow: Arc::new(GeminiShadowStore::default()),
|
||||
app_handle,
|
||||
failover_manager,
|
||||
};
|
||||
|
||||
@@ -242,12 +242,6 @@ pub fn extract_session_id(
|
||||
body: &serde_json::Value,
|
||||
client_format: &str,
|
||||
) -> SessionIdResult {
|
||||
if client_format == "claude" {
|
||||
if let Some(result) = extract_claude_session(headers, body) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Codex 请求特殊处理
|
||||
if client_format == "codex" || client_format == "openai" {
|
||||
if let Some(result) = extract_codex_session(headers, body) {
|
||||
@@ -264,28 +258,6 @@ pub fn extract_session_id(
|
||||
generate_new_session_id()
|
||||
}
|
||||
|
||||
/// 提取 Claude Session ID
|
||||
fn extract_claude_session(
|
||||
headers: &HeaderMap,
|
||||
body: &serde_json::Value,
|
||||
) -> Option<SessionIdResult> {
|
||||
for header_name in &["x-claude-code-session-id", "claude-code-session-id"] {
|
||||
if let Some(value) = headers.get(*header_name) {
|
||||
if let Ok(session_id) = value.to_str() {
|
||||
if !session_id.is_empty() {
|
||||
return Some(SessionIdResult {
|
||||
session_id: session_id.to_string(),
|
||||
source: SessionIdSource::Header,
|
||||
client_provided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extract_from_metadata(body)
|
||||
}
|
||||
|
||||
/// 提取 Codex Session ID
|
||||
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
|
||||
// 1. 从 headers 提取
|
||||
@@ -543,47 +515,6 @@ mod tests {
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_from_claude_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-claude-code-session-id",
|
||||
"d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(),
|
||||
);
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = extract_session_id(&headers, &body, "claude");
|
||||
|
||||
assert_eq!(result.session_id, "d937243f-2702-4f20-97b6-c9682235ab81");
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_from_claude_header_precedes_metadata() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-claude-code-session-id",
|
||||
"header-session-123".parse().unwrap(),
|
||||
);
|
||||
let body = json!({
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"metadata": {
|
||||
"session_id": "my-session-123"
|
||||
}
|
||||
});
|
||||
|
||||
let result = extract_session_id(&headers, &body, "claude");
|
||||
|
||||
assert_eq!(result.session_id, "header-session-123");
|
||||
assert_eq!(result.source, SessionIdSource::Header);
|
||||
assert!(result.client_provided);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_from_codex_previous_response_id() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
@@ -4,24 +4,6 @@ pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str>
|
||||
.or_else(|| line.strip_prefix(&format!("{field}:")))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn take_sse_block(buffer: &mut String) -> Option<String> {
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
|
||||
for (delimiter, len) in [("\r\n\r\n", 4usize), ("\n\n", 2usize)] {
|
||||
if let Some(pos) = buffer.find(delimiter) {
|
||||
if best.is_none_or(|(best_pos, _)| pos < best_pos) {
|
||||
best = Some((pos, len));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (pos, len) = best?;
|
||||
let block = buffer[..pos].to_string();
|
||||
buffer.drain(..pos + len);
|
||||
Some(block)
|
||||
}
|
||||
|
||||
/// Append raw bytes to a UTF-8 `String` buffer, correctly handling multi-byte
|
||||
/// characters that are split across chunk boundaries.
|
||||
///
|
||||
@@ -86,7 +68,7 @@ pub(crate) fn append_utf8_safe(buffer: &mut String, remainder: &mut Vec<u8>, new
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{append_utf8_safe, strip_sse_field, take_sse_block};
|
||||
use super::{append_utf8_safe, strip_sse_field};
|
||||
|
||||
#[test]
|
||||
fn strip_sse_field_accepts_optional_space() {
|
||||
@@ -109,28 +91,6 @@ mod tests {
|
||||
assert_eq!(strip_sse_field("id:1", "data"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_sse_block_supports_lf_delimiters() {
|
||||
let mut buffer = "data: {\"ok\":true}\n\nrest".to_string();
|
||||
|
||||
assert_eq!(
|
||||
take_sse_block(&mut buffer),
|
||||
Some("data: {\"ok\":true}".to_string())
|
||||
);
|
||||
assert_eq!(buffer, "rest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_sse_block_supports_crlf_delimiters() {
|
||||
let mut buffer = "data: {\"ok\":true}\r\n\r\nrest".to_string();
|
||||
|
||||
assert_eq!(
|
||||
take_sse_block(&mut buffer),
|
||||
Some("data: {\"ok\":true}".to_string())
|
||||
);
|
||||
assert_eq!(buffer, "rest");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// append_utf8_safe tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -52,14 +52,6 @@ pub fn should_rectify_thinking_signature(
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景1b: Gemini/第三方渠道返回 "Thought signature is not valid"
|
||||
// 错误示例: "Unable to submit request because Thought signature is not valid"
|
||||
if lower.contains("thought signature")
|
||||
&& (lower.contains("not valid") || lower.contains("invalid"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景2: assistant 消息必须以 thinking block 开头
|
||||
// 错误示例: "must start with a thinking block"
|
||||
if lower.contains("must start with a thinking block") {
|
||||
@@ -288,16 +280,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_thought_signature_message() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some(
|
||||
"Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help"
|
||||
),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_signature_nested_json() {
|
||||
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
|
||||
@@ -308,15 +290,6 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_thought_signature_nested_json() {
|
||||
let nested_error = r#"{"error":{"message":"Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help","type":"upstream_error","param":"","code":400}}"#;
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some(nested_error),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_thinking_expected() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn get_custom_endpoints(
|
||||
}
|
||||
|
||||
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
|
||||
result.sort_by_key(|ep| std::cmp::Reverse(ep.added_at));
|
||||
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -297,16 +297,18 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32),
|
||||
};
|
||||
|
||||
match event_type {
|
||||
"session_meta" if state.session_id.is_none() => {
|
||||
let payload = value.get("payload");
|
||||
state.session_id = payload
|
||||
.and_then(|p| {
|
||||
p.get("session_id")
|
||||
.or_else(|| p.get("sessionId"))
|
||||
.or_else(|| p.get("id"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
"session_meta" => {
|
||||
if state.session_id.is_none() {
|
||||
let payload = value.get("payload");
|
||||
state.session_id = payload
|
||||
.and_then(|p| {
|
||||
p.get("session_id")
|
||||
.or_else(|| p.get("sessionId"))
|
||||
.or_else(|| p.get("id"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
}
|
||||
"turn_context" => {
|
||||
if let Some(payload) = value.get("payload") {
|
||||
|
||||
@@ -1268,7 +1268,7 @@ impl SkillService {
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort_by_key(|entry| std::cmp::Reverse(entry.created_at));
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
@@ -1772,7 +1772,7 @@ impl SkillService {
|
||||
let results: Vec<Result<Vec<DiscoverableSkill>>> =
|
||||
futures::future::join_all(fetch_tasks).await;
|
||||
|
||||
for (repo, result) in enabled_repos.into_iter().zip(results) {
|
||||
for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
|
||||
match result {
|
||||
Ok(repo_skills) => skills.extend(repo_skills),
|
||||
Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
|
||||
@@ -1781,7 +1781,7 @@ impl SkillService {
|
||||
|
||||
// 去重并排序
|
||||
Self::deduplicate_discoverable_skills(&mut skills);
|
||||
skills.sort_by_key(|skill| skill.name.to_lowercase());
|
||||
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
@@ -1848,7 +1848,7 @@ impl SkillService {
|
||||
}
|
||||
}
|
||||
|
||||
skills.sort_by_key(|skill| skill.name.to_lowercase());
|
||||
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
@@ -12,10 +12,8 @@ use std::time::Instant;
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::gemini_url::{normalize_gemini_model_id, resolve_gemini_native_url};
|
||||
use crate::proxy::providers::copilot_auth;
|
||||
use crate::proxy::providers::transform::anthropic_to_openai;
|
||||
use crate::proxy::providers::transform_gemini::anthropic_to_gemini;
|
||||
use crate::proxy::providers::transform_responses::anthropic_to_responses;
|
||||
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
|
||||
|
||||
@@ -57,8 +55,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.4@low".to_string(),
|
||||
gemini_model: "gemini-3-flash-preview".to_string(),
|
||||
codex_model: "gpt-5.1-codex@low".to_string(),
|
||||
gemini_model: "gemini-3-pro-preview".to_string(),
|
||||
test_prompt: default_test_prompt(),
|
||||
}
|
||||
}
|
||||
@@ -76,9 +74,6 @@ 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>,
|
||||
}
|
||||
|
||||
/// 流式健康检查服务
|
||||
@@ -148,7 +143,6 @@ impl StreamCheckService {
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: effective_config.max_retries,
|
||||
error_category: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -281,7 +275,6 @@ impl StreamCheckService {
|
||||
result,
|
||||
response_time,
|
||||
config.degraded_threshold_ms,
|
||||
&model_to_test,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -290,8 +283,6 @@ impl StreamCheckService {
|
||||
/// 根据供应商的 api_format 选择请求格式:
|
||||
/// - "anthropic" (默认): Anthropic Messages API (/v1/messages)
|
||||
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
|
||||
/// - "openai_responses": OpenAI Responses API (/v1/responses)
|
||||
/// - "gemini_native": Gemini Native streamGenerateContent
|
||||
///
|
||||
/// `extra_headers` 是一个可选的供应商级自定义 header 集合(从 OpenClaw
|
||||
/// 的 `settings_config.headers` 或 OpenCode 的 `settings_config.options.headers`
|
||||
@@ -333,14 +324,8 @@ impl StreamCheckService {
|
||||
.unwrap_or(false);
|
||||
let is_openai_chat = effective_api_format == "openai_chat";
|
||||
let is_openai_responses = effective_api_format == "openai_responses";
|
||||
let is_gemini_native = effective_api_format == "gemini_native";
|
||||
let url = Self::resolve_claude_stream_url(
|
||||
base,
|
||||
auth.strategy,
|
||||
effective_api_format,
|
||||
is_full_url,
|
||||
model,
|
||||
);
|
||||
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 };
|
||||
|
||||
@@ -362,9 +347,6 @@ impl StreamCheckService {
|
||||
let body = if is_openai_responses {
|
||||
anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth)
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else if is_gemini_native {
|
||||
anthropic_to_gemini(anthropic_body)
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else if is_openai_chat {
|
||||
anthropic_to_openai(anthropic_body)
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
@@ -400,23 +382,6 @@ impl StreamCheckService {
|
||||
.header("x-vscode-user-agent-library-version", "electron-fetch")
|
||||
.header("x-request-id", &request_id)
|
||||
.header("x-agent-task-id", &request_id);
|
||||
} else if is_gemini_native {
|
||||
request_builder = match auth.strategy {
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||
request_builder
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-goog-api-client", "GeminiCLI/1.0")
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
}
|
||||
_ => request_builder
|
||||
.header("x-goog-api-key", &auth.api_key)
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity"),
|
||||
};
|
||||
} else if is_openai_chat || is_openai_responses {
|
||||
// OpenAI-compatible targets: Bearer auth + SSE headers only
|
||||
request_builder = request_builder
|
||||
@@ -598,16 +563,13 @@ impl StreamCheckService {
|
||||
extra_headers: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Result<(u16, String), AppError> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
// Strip `models/` resource-name prefix from the model id — see
|
||||
// `normalize_gemini_model_id` for rationale.
|
||||
let normalized_model = normalize_gemini_model_id(model);
|
||||
// Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse
|
||||
// 智能处理 /v1beta 路径:如果 base_url 不包含版本路径,则添加 /v1beta
|
||||
// alt=sse 参数使 API 返回 SSE 格式(text/event-stream)而非 JSON 数组
|
||||
let url = if base.contains("/v1beta") || base.contains("/v1/") {
|
||||
format!("{base}/models/{normalized_model}:streamGenerateContent?alt=sse")
|
||||
format!("{base}/models/{model}:streamGenerateContent?alt=sse")
|
||||
} else {
|
||||
format!("{base}/v1beta/models/{normalized_model}:streamGenerateContent?alt=sse")
|
||||
format!("{base}/v1beta/models/{model}:streamGenerateContent?alt=sse")
|
||||
};
|
||||
|
||||
// Gemini 原生请求体格式
|
||||
@@ -708,21 +670,16 @@ 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 {
|
||||
@@ -735,19 +692,14 @@ impl StreamCheckService {
|
||||
model_used: model,
|
||||
tested_at,
|
||||
retry_count: 0,
|
||||
error_category: None,
|
||||
},
|
||||
Err(e) => {
|
||||
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),
|
||||
let (http_status, message) = match &e {
|
||||
AppError::HttpStatus { status, .. } => (
|
||||
Some(*status),
|
||||
Self::classify_http_status(*status).to_string(),
|
||||
),
|
||||
_ => (None, e.to_string()),
|
||||
};
|
||||
StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
@@ -755,47 +707,14 @@ impl StreamCheckService {
|
||||
message,
|
||||
response_time_ms: Some(response_time),
|
||||
http_status,
|
||||
model_used: model_tested.to_string(),
|
||||
model_used: String::new(),
|
||||
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` 字段分发到对应协议的检查器。
|
||||
@@ -1325,19 +1244,7 @@ impl StreamCheckService {
|
||||
auth_strategy: AuthStrategy,
|
||||
api_format: &str,
|
||||
is_full_url: bool,
|
||||
model: &str,
|
||||
) -> String {
|
||||
if api_format == "gemini_native" {
|
||||
// Strip an optional `models/` resource-name prefix so that model
|
||||
// identifiers copied from Gemini SDK outputs (e.g.
|
||||
// `models/gemini-2.5-pro`) don't produce a doubled
|
||||
// `/v1beta/models/models/...` URL.
|
||||
let normalized_model = normalize_gemini_model_id(model);
|
||||
let endpoint =
|
||||
format!("/v1beta/models/{normalized_model}:streamGenerateContent?alt=sse");
|
||||
return resolve_gemini_native_url(base_url, &endpoint, is_full_url);
|
||||
}
|
||||
|
||||
if is_full_url {
|
||||
return base_url.to_string();
|
||||
}
|
||||
@@ -1567,51 +1474,6 @@ 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();
|
||||
@@ -1666,7 +1528,6 @@ mod tests {
|
||||
AuthStrategy::Bearer,
|
||||
"openai_chat",
|
||||
true,
|
||||
"gpt-5.4",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/v1/chat/completions");
|
||||
@@ -1679,7 +1540,6 @@ mod tests {
|
||||
AuthStrategy::GitHubCopilot,
|
||||
"openai_chat",
|
||||
false,
|
||||
"gpt-5.4",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.githubcopilot.com/chat/completions");
|
||||
@@ -1692,7 +1552,6 @@ mod tests {
|
||||
AuthStrategy::GitHubCopilot,
|
||||
"openai_responses",
|
||||
false,
|
||||
"gpt-5.4",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.githubcopilot.com/v1/responses");
|
||||
@@ -1705,7 +1564,6 @@ mod tests {
|
||||
AuthStrategy::Bearer,
|
||||
"openai_chat",
|
||||
false,
|
||||
"gpt-5.4",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://example.com/v1/chat/completions");
|
||||
@@ -1718,7 +1576,6 @@ mod tests {
|
||||
AuthStrategy::Bearer,
|
||||
"openai_responses",
|
||||
false,
|
||||
"gpt-5.4",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://example.com/v1/responses");
|
||||
@@ -1731,78 +1588,11 @@ mod tests {
|
||||
AuthStrategy::Anthropic,
|
||||
"anthropic",
|
||||
false,
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_gemini_native() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://generativelanguage.googleapis.com",
|
||||
AuthStrategy::Google,
|
||||
"gemini_native",
|
||||
false,
|
||||
"gemini-2.5-flash",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_gemini_native_full_url_openai_compat_base() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
||||
AuthStrategy::Google,
|
||||
"gemini_native",
|
||||
true,
|
||||
"gemini-2.5-flash",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_gemini_native_opaque_full_url() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://relay.example/custom/generate-content",
|
||||
AuthStrategy::Google,
|
||||
"gemini_native",
|
||||
true,
|
||||
"gemini-2.5-flash",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
|
||||
}
|
||||
|
||||
/// Regression: Gemini SDK outputs commonly surface model ids as the
|
||||
/// resource-name form `models/gemini-2.5-pro`. Interpolating that raw
|
||||
/// value used to produce `/v1beta/models/models/gemini-2.5-pro:...`
|
||||
/// which the upstream rejects and the health check records as a
|
||||
/// false-negative for an otherwise valid provider.
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_gemini_native_strips_models_prefix() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://generativelanguage.googleapis.com",
|
||||
AuthStrategy::Google,
|
||||
"gemini_native",
|
||||
false,
|
||||
"models/gemini-2.5-pro",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_codex_stream_urls_for_full_url_mode() {
|
||||
let urls = StreamCheckService::resolve_codex_stream_urls(
|
||||
|
||||
@@ -275,14 +275,9 @@ impl Database {
|
||||
let mut bucket_count: i64 = if duration <= 0 {
|
||||
1
|
||||
} else {
|
||||
((duration as f64) / bucket_seconds as f64).ceil() as i64
|
||||
(duration + bucket_seconds - 1) / bucket_seconds
|
||||
};
|
||||
|
||||
// 固定 24 小时窗口为 24 个小时桶,避免浮点误差
|
||||
if bucket_seconds == 60 * 60 {
|
||||
bucket_count = 24;
|
||||
}
|
||||
|
||||
if bucket_count < 1 {
|
||||
bucket_count = 1;
|
||||
}
|
||||
@@ -453,14 +448,50 @@ impl Database {
|
||||
/// 获取 Provider 统计
|
||||
pub fn get_provider_stats(
|
||||
&self,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<Vec<ProviderStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let (detail_where, rollup_where) = if app_type.is_some() {
|
||||
("WHERE l.app_type = ?1", "WHERE r.app_type = ?2")
|
||||
let mut detail_conditions = Vec::new();
|
||||
let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
if let Some(start) = start_date {
|
||||
detail_conditions.push("l.created_at >= ?");
|
||||
detail_params.push(Box::new(start));
|
||||
}
|
||||
if let Some(end) = end_date {
|
||||
detail_conditions.push("l.created_at <= ?");
|
||||
detail_params.push(Box::new(end));
|
||||
}
|
||||
if let Some(at) = app_type {
|
||||
detail_conditions.push("l.app_type = ?");
|
||||
detail_params.push(Box::new(at.to_string()));
|
||||
}
|
||||
let detail_where = if detail_conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
("", "")
|
||||
format!("WHERE {}", detail_conditions.join(" AND "))
|
||||
};
|
||||
|
||||
let mut rollup_conditions = Vec::new();
|
||||
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
if let Some(start) = start_date {
|
||||
rollup_conditions.push("r.date >= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
rollup_params.push(Box::new(start));
|
||||
}
|
||||
if let Some(end) = end_date {
|
||||
rollup_conditions.push("r.date <= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
rollup_params.push(Box::new(end));
|
||||
}
|
||||
if let Some(at) = app_type {
|
||||
rollup_conditions.push("r.app_type = ?".to_string());
|
||||
rollup_params.push(Box::new(at.to_string()));
|
||||
}
|
||||
let rollup_where = if rollup_conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", rollup_conditions.join(" AND "))
|
||||
};
|
||||
|
||||
// UNION detail logs + rollup data, then aggregate
|
||||
@@ -506,6 +537,9 @@ impl Database {
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = detail_params;
|
||||
params.extend(rollup_params);
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let row_mapper = |row: &rusqlite::Row| {
|
||||
let request_count: i64 = row.get(3)?;
|
||||
let success_count: i64 = row.get(6)?;
|
||||
@@ -526,11 +560,7 @@ impl Database {
|
||||
})
|
||||
};
|
||||
|
||||
let rows = if let Some(at) = app_type {
|
||||
stmt.query_map(params![at, at], row_mapper)?
|
||||
} else {
|
||||
stmt.query_map([], row_mapper)?
|
||||
};
|
||||
let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
|
||||
|
||||
let mut stats = Vec::new();
|
||||
for row in rows {
|
||||
@@ -541,13 +571,52 @@ impl Database {
|
||||
}
|
||||
|
||||
/// 获取模型统计
|
||||
pub fn get_model_stats(&self, app_type: Option<&str>) -> Result<Vec<ModelStats>, AppError> {
|
||||
pub fn get_model_stats(
|
||||
&self,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<&str>,
|
||||
) -> Result<Vec<ModelStats>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let (detail_where, rollup_where) = if app_type.is_some() {
|
||||
("WHERE app_type = ?1", "WHERE app_type = ?2")
|
||||
let mut detail_conditions = Vec::new();
|
||||
let mut detail_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
if let Some(start) = start_date {
|
||||
detail_conditions.push("l.created_at >= ?");
|
||||
detail_params.push(Box::new(start));
|
||||
}
|
||||
if let Some(end) = end_date {
|
||||
detail_conditions.push("l.created_at <= ?");
|
||||
detail_params.push(Box::new(end));
|
||||
}
|
||||
if let Some(at) = app_type {
|
||||
detail_conditions.push("l.app_type = ?");
|
||||
detail_params.push(Box::new(at.to_string()));
|
||||
}
|
||||
let detail_where = if detail_conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
("", "")
|
||||
format!("WHERE {}", detail_conditions.join(" AND "))
|
||||
};
|
||||
|
||||
let mut rollup_conditions = Vec::new();
|
||||
let mut rollup_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
if let Some(start) = start_date {
|
||||
rollup_conditions.push("r.date >= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
rollup_params.push(Box::new(start));
|
||||
}
|
||||
if let Some(end) = end_date {
|
||||
rollup_conditions.push("r.date <= date(?, 'unixepoch', 'localtime')".to_string());
|
||||
rollup_params.push(Box::new(end));
|
||||
}
|
||||
if let Some(at) = app_type {
|
||||
rollup_conditions.push("r.app_type = ?".to_string());
|
||||
rollup_params.push(Box::new(at.to_string()));
|
||||
}
|
||||
let rollup_where = if rollup_conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", rollup_conditions.join(" AND "))
|
||||
};
|
||||
|
||||
// UNION detail logs + rollup data
|
||||
@@ -558,27 +627,30 @@ impl Database {
|
||||
SUM(total_tokens) as total_tokens,
|
||||
SUM(total_cost) as total_cost
|
||||
FROM (
|
||||
SELECT model,
|
||||
SELECT l.model,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost
|
||||
FROM proxy_request_logs
|
||||
COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost
|
||||
FROM proxy_request_logs l
|
||||
{detail_where}
|
||||
GROUP BY model
|
||||
GROUP BY l.model
|
||||
UNION ALL
|
||||
SELECT model,
|
||||
SELECT r.model,
|
||||
COALESCE(SUM(request_count), 0),
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0),
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||
FROM usage_daily_rollups
|
||||
FROM usage_daily_rollups r
|
||||
{rollup_where}
|
||||
GROUP BY model
|
||||
GROUP BY r.model
|
||||
)
|
||||
GROUP BY model
|
||||
ORDER BY total_cost DESC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = detail_params;
|
||||
params.extend(rollup_params);
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let row_mapper = |row: &rusqlite::Row| {
|
||||
let request_count: i64 = row.get(1)?;
|
||||
let total_cost: f64 = row.get(3)?;
|
||||
@@ -597,11 +669,7 @@ impl Database {
|
||||
})
|
||||
};
|
||||
|
||||
let rows = if let Some(at) = app_type {
|
||||
stmt.query_map(params![at, at], row_mapper)?
|
||||
} else {
|
||||
stmt.query_map([], row_mapper)?
|
||||
};
|
||||
let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?;
|
||||
|
||||
let mut stats = Vec::new();
|
||||
for row in rows {
|
||||
@@ -1168,7 +1236,7 @@ mod tests {
|
||||
)?;
|
||||
}
|
||||
|
||||
let stats = db.get_model_stats(None)?;
|
||||
let stats = db.get_model_stats(None, None, None)?;
|
||||
assert_eq!(stats.len(), 1);
|
||||
assert_eq!(stats[0].model, "claude-3-sonnet");
|
||||
assert_eq!(stats[0].request_count, 1);
|
||||
@@ -1176,6 +1244,73 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_provider_stats_with_time_filter() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
params!["old", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
params!["new", "p1", "claude", "claude-3", 200, 75, "0.02", 120, 200, 2000],
|
||||
)?;
|
||||
}
|
||||
|
||||
let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"))?;
|
||||
assert_eq!(stats.len(), 1);
|
||||
assert_eq!(stats[0].provider_id, "p1");
|
||||
assert_eq!(stats[0].request_count, 1);
|
||||
assert_eq!(stats[0].total_tokens, 275);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_daily_trends_respects_shorter_than_24_hours() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
params![
|
||||
"req-short",
|
||||
"p1",
|
||||
"claude",
|
||||
"claude-3",
|
||||
100,
|
||||
50,
|
||||
"0.01",
|
||||
100,
|
||||
200,
|
||||
10_800
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"))?;
|
||||
assert_eq!(stats.len(), 15);
|
||||
assert_eq!(stats[3].request_count, 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_pricing_matching() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -173,7 +173,10 @@ async fn run_worker_loop(
|
||||
let started_at = Instant::now();
|
||||
let mut merged_count = 1usize;
|
||||
|
||||
while let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) {
|
||||
loop {
|
||||
let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) else {
|
||||
break;
|
||||
};
|
||||
let timeout = tokio::time::timeout(wait_for, rx.recv()).await;
|
||||
|
||||
match timeout {
|
||||
|
||||
@@ -113,7 +113,7 @@ interface ClaudeFormFieldsProps {
|
||||
// Speed Test Endpoints
|
||||
speedTestEndpoints: EndpointCandidate[];
|
||||
|
||||
// API Format (for Claude-compatible providers that need request/response conversion)
|
||||
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
||||
apiFormat: ClaudeApiFormat;
|
||||
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||
|
||||
@@ -436,14 +436,7 @@ export function ClaudeFormFields({
|
||||
? t("providerForm.apiHintResponses")
|
||||
: apiFormat === "openai_chat"
|
||||
? t("providerForm.apiHintOAI")
|
||||
: apiFormat === "gemini_native"
|
||||
? t("providerForm.apiHintGeminiNative")
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
fullUrlHint={
|
||||
apiFormat === "gemini_native"
|
||||
? t("providerForm.fullUrlHintGeminiNative")
|
||||
: undefined
|
||||
: t("providerForm.apiHint")
|
||||
}
|
||||
onManageClick={() => onEndpointModalToggle(true)}
|
||||
showFullUrlToggle={true}
|
||||
@@ -518,11 +511,6 @@ export function ClaudeFormFields({
|
||||
defaultValue: "OpenAI Responses API (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini_native">
|
||||
{t("providerForm.apiFormatGeminiNative", {
|
||||
defaultValue: "Gemini Native generateContent (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -11,7 +11,6 @@ interface EndpointFieldProps {
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
hint?: string;
|
||||
fullUrlHint?: string;
|
||||
showManageButton?: boolean;
|
||||
onManageClick?: () => void;
|
||||
manageButtonLabel?: string;
|
||||
@@ -27,7 +26,6 @@ export function EndpointField({
|
||||
onChange,
|
||||
placeholder,
|
||||
hint,
|
||||
fullUrlHint,
|
||||
showManageButton = true,
|
||||
onManageClick,
|
||||
manageButtonLabel,
|
||||
@@ -42,8 +40,7 @@ export function EndpointField({
|
||||
});
|
||||
const effectiveHint =
|
||||
showFullUrlToggle && isFullUrl
|
||||
? fullUrlHint ||
|
||||
t("providerForm.fullUrlHint", {
|
||||
? t("providerForm.fullUrlHint", {
|
||||
defaultValue:
|
||||
"💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径",
|
||||
})
|
||||
|
||||
@@ -9,18 +9,21 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useModelStats } from "@/lib/query/usage";
|
||||
import { fmtUsd } from "./format";
|
||||
import type { UsageRangeSelection } from "@/types/usage";
|
||||
|
||||
interface ModelStatsTableProps {
|
||||
range: UsageRangeSelection;
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function ModelStatsTable({
|
||||
range,
|
||||
appType,
|
||||
refreshIntervalMs,
|
||||
}: ModelStatsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: stats, isLoading } = useModelStats(appType, {
|
||||
const { data: stats, isLoading } = useModelStats(range, appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ModelTestConfigPanel() {
|
||||
degradedThresholdMs: "6000",
|
||||
claudeModel: "claude-haiku-4-5-20251001",
|
||||
codexModel: "gpt-5.4@low",
|
||||
geminiModel: "gemini-3-flash-preview",
|
||||
geminiModel: "gemini-3-pro-preview",
|
||||
testPrompt: "Who are you?",
|
||||
});
|
||||
|
||||
|
||||
@@ -9,18 +9,21 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useProviderStats } from "@/lib/query/usage";
|
||||
import { fmtUsd } from "./format";
|
||||
import type { UsageRangeSelection } from "@/types/usage";
|
||||
|
||||
interface ProviderStatsTableProps {
|
||||
range: UsageRangeSelection;
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function ProviderStatsTable({
|
||||
range,
|
||||
appType,
|
||||
refreshIntervalMs,
|
||||
}: ProviderStatsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: stats, isLoading } = useProviderStats(appType, {
|
||||
const { data: stats, isLoading } = useProviderStats(range, appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,10 +17,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useRequestLogs, usageKeys } from "@/lib/query/usage";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { LogFilters } from "@/types/usage";
|
||||
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
|
||||
import { useRequestLogs } from "@/lib/query/usage";
|
||||
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
|
||||
import { Calendar, ChevronLeft, ChevronRight, Search, X } from "lucide-react";
|
||||
import {
|
||||
fmtInt,
|
||||
fmtUsd,
|
||||
@@ -29,51 +28,30 @@ import {
|
||||
} from "./format";
|
||||
|
||||
interface RequestLogTableProps {
|
||||
range: UsageRangeSelection;
|
||||
rangeLabel: string;
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
timeRange?: "1d" | "7d" | "30d";
|
||||
}
|
||||
|
||||
const ONE_DAY_SECONDS = 24 * 60 * 60;
|
||||
const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
|
||||
|
||||
const TIME_RANGE_SECONDS: Record<string, number> = {
|
||||
"1d": ONE_DAY_SECONDS,
|
||||
"7d": 7 * ONE_DAY_SECONDS,
|
||||
"30d": 30 * ONE_DAY_SECONDS,
|
||||
};
|
||||
|
||||
type TimeMode = "rolling" | "fixed";
|
||||
|
||||
export function RequestLogTable({
|
||||
range,
|
||||
rangeLabel,
|
||||
appType: dashboardAppType,
|
||||
refreshIntervalMs,
|
||||
timeRange = "1d",
|
||||
}: RequestLogTableProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const rollingWindowSeconds = TIME_RANGE_SECONDS[timeRange] ?? ONE_DAY_SECONDS;
|
||||
|
||||
const getRollingRange = () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return { startDate: now - rollingWindowSeconds, endDate: now };
|
||||
};
|
||||
|
||||
const [appliedTimeMode, setAppliedTimeMode] = useState<TimeMode>("rolling");
|
||||
const [draftTimeMode, setDraftTimeMode] = useState<TimeMode>("rolling");
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
|
||||
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageInput, setPageInput] = useState("");
|
||||
const pageSize = 20;
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
// Reset page when the dashboard time range changes
|
||||
// Reset page when the dashboard range changes
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [timeRange]);
|
||||
}, [range.preset, range.customStartDate, range.customEndDate]);
|
||||
|
||||
// When dashboard-level app filter is active (not "all"), override the local appType filter
|
||||
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
|
||||
@@ -83,8 +61,7 @@ export function RequestLogTable({
|
||||
|
||||
const { data: result, isLoading } = useRequestLogs({
|
||||
filters: effectiveFilters,
|
||||
timeMode: appliedTimeMode,
|
||||
rollingWindowSeconds,
|
||||
range,
|
||||
page,
|
||||
pageSize,
|
||||
options: {
|
||||
@@ -97,50 +74,11 @@ export function RequestLogTable({
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const handleSearch = () => {
|
||||
setValidationError(null);
|
||||
|
||||
if (draftTimeMode === "fixed") {
|
||||
const start = draftFilters.startDate;
|
||||
const end = draftFilters.endDate;
|
||||
|
||||
if (typeof start !== "number" || typeof end !== "number") {
|
||||
setValidationError(
|
||||
t("usage.invalidTimeRange", "请选择完整的开始/结束时间"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (start > end) {
|
||||
setValidationError(
|
||||
t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (end - start > MAX_FIXED_RANGE_SECONDS) {
|
||||
setValidationError(
|
||||
t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setAppliedTimeMode(draftTimeMode);
|
||||
setAppliedFilters((prev) => {
|
||||
const next = { ...prev, ...draftFilters };
|
||||
if (draftTimeMode === "rolling") {
|
||||
delete next.startDate;
|
||||
delete next.endDate;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setAppliedFilters(draftFilters);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setValidationError(null);
|
||||
setAppliedTimeMode("rolling");
|
||||
setDraftTimeMode("rolling");
|
||||
setDraftFilters({});
|
||||
setAppliedFilters({});
|
||||
setPage(0);
|
||||
@@ -155,73 +93,33 @@ export function RequestLogTable({
|
||||
setPageInput("");
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
const key = {
|
||||
timeMode: appliedTimeMode,
|
||||
rollingWindowSeconds:
|
||||
appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined,
|
||||
appType: appliedFilters.appType,
|
||||
providerName: appliedFilters.providerName,
|
||||
model: appliedFilters.model,
|
||||
statusCode: appliedFilters.statusCode,
|
||||
startDate:
|
||||
appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined,
|
||||
endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined,
|
||||
};
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: usageKeys.logs(key, page, pageSize),
|
||||
});
|
||||
};
|
||||
|
||||
// 将 Unix 时间戳转换为本地时间的 datetime-local 格式
|
||||
const timestampToLocalDatetime = (timestamp: number): string => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// 将 datetime-local 格式转换为 Unix 时间戳
|
||||
const localDatetimeToTimestamp = (datetime: string): number | undefined => {
|
||||
if (!datetime) return undefined;
|
||||
// 验证格式是否完整 (YYYY-MM-DDTHH:mm)
|
||||
if (datetime.length < 16) return undefined;
|
||||
const timestamp = new Date(datetime).getTime();
|
||||
// 验证是否为有效日期
|
||||
if (isNaN(timestamp)) return undefined;
|
||||
return Math.floor(timestamp / 1000);
|
||||
};
|
||||
|
||||
const language = i18n.resolvedLanguage || i18n.language || "en";
|
||||
const locale = getLocaleFromLanguage(language);
|
||||
|
||||
const rollingRangeForDisplay =
|
||||
draftTimeMode === "rolling" ? getRollingRange() : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="rounded-lg border bg-card/50 p-2 backdrop-blur-sm">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* App type */}
|
||||
{/* App type */}
|
||||
<Select
|
||||
value={
|
||||
dashboardAppTypeActive
|
||||
? dashboardAppType
|
||||
: draftFilters.appType || "all"
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setDraftFilters({
|
||||
onValueChange={(v) => {
|
||||
const next = {
|
||||
...draftFilters,
|
||||
appType: v === "all" ? undefined : v,
|
||||
})
|
||||
}
|
||||
};
|
||||
setDraftFilters(next);
|
||||
setAppliedFilters(next);
|
||||
setPage(0);
|
||||
}}
|
||||
disabled={!!dashboardAppTypeActive}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] bg-background">
|
||||
<SelectTrigger className="h-8 w-[110px] bg-background text-xs">
|
||||
<SelectValue placeholder={t("usage.appType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -232,10 +130,11 @@ export function RequestLogTable({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status code */}
|
||||
<Select
|
||||
value={draftFilters.statusCode?.toString() || "all"}
|
||||
onValueChange={(v) =>
|
||||
setDraftFilters({
|
||||
onValueChange={(v) => {
|
||||
const next = {
|
||||
...draftFilters,
|
||||
statusCode:
|
||||
v === "all"
|
||||
@@ -243,40 +142,49 @@ export function RequestLogTable({
|
||||
: Number.isFinite(Number.parseInt(v, 10))
|
||||
? Number.parseInt(v, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
};
|
||||
setDraftFilters(next);
|
||||
setAppliedFilters(next);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] bg-background">
|
||||
<SelectTrigger className="h-8 w-[100px] bg-background text-xs">
|
||||
<SelectValue placeholder={t("usage.statusCode")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("common.all")}</SelectItem>
|
||||
<SelectItem value="200">200 OK</SelectItem>
|
||||
<SelectItem value="400">400 Bad Request</SelectItem>
|
||||
<SelectItem value="401">401 Unauthorized</SelectItem>
|
||||
<SelectItem value="429">429 Rate Limit</SelectItem>
|
||||
<SelectItem value="500">500 Server Error</SelectItem>
|
||||
<SelectItem value="400">400</SelectItem>
|
||||
<SelectItem value="401">401</SelectItem>
|
||||
<SelectItem value="429">429</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("usage.searchProviderPlaceholder")}
|
||||
className="pl-9 bg-background"
|
||||
value={draftFilters.providerName || ""}
|
||||
onChange={(e) =>
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
providerName: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Provider search */}
|
||||
<div className="relative min-w-[140px] flex-1">
|
||||
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("usage.searchProviderPlaceholder")}
|
||||
className="h-8 bg-background pl-7 text-xs"
|
||||
value={draftFilters.providerName || ""}
|
||||
onChange={(e) =>
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
providerName: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model search */}
|
||||
<div className="relative min-w-[120px] flex-1">
|
||||
<Input
|
||||
placeholder={t("usage.searchModelPlaceholder")}
|
||||
className="w-[180px] bg-background"
|
||||
className="h-8 bg-background text-xs"
|
||||
value={draftFilters.model || ""}
|
||||
onChange={(e) =>
|
||||
setDraftFilters({
|
||||
@@ -284,89 +192,40 @@ export function RequestLogTable({
|
||||
model: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="whitespace-nowrap">{t("usage.timeRange")}:</span>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
className="h-8 w-[200px] bg-background"
|
||||
value={
|
||||
(rollingRangeForDisplay?.startDate ?? draftFilters.startDate)
|
||||
? timestampToLocalDatetime(
|
||||
(rollingRangeForDisplay?.startDate ??
|
||||
draftFilters.startDate) as number,
|
||||
)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const timestamp = localDatetimeToTimestamp(e.target.value);
|
||||
setDraftTimeMode("fixed");
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
startDate: timestamp,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>-</span>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
className="h-8 w-[200px] bg-background"
|
||||
value={
|
||||
(rollingRangeForDisplay?.endDate ?? draftFilters.endDate)
|
||||
? timestampToLocalDatetime(
|
||||
(rollingRangeForDisplay?.endDate ??
|
||||
draftFilters.endDate) as number,
|
||||
)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const timestamp = localDatetimeToTimestamp(e.target.value);
|
||||
setDraftTimeMode("fixed");
|
||||
setDraftFilters({
|
||||
...draftFilters,
|
||||
endDate: timestamp,
|
||||
});
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={handleSearch}
|
||||
className="h-8"
|
||||
>
|
||||
<Search className="mr-2 h-3.5 w-3.5" />
|
||||
{t("common.search")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="h-8"
|
||||
>
|
||||
<X className="mr-2 h-3.5 w-3.5" />
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRefresh}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Time range badge */}
|
||||
<div className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border/60 bg-background px-2 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="max-w-[180px] truncate text-foreground">
|
||||
{rangeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<div className="text-sm text-red-600">{validationError}</div>
|
||||
)}
|
||||
{/* Search & Reset (icon-only) */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="default"
|
||||
onClick={handleSearch}
|
||||
className="h-8 w-8"
|
||||
title={t("common.search")}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="h-8 w-8"
|
||||
title={t("common.reset")}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -377,40 +236,31 @@ export function RequestLogTable({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("usage.time")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("usage.provider")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px] whitespace-nowrap">
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("usage.billingModel")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("usage.inputTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("usage.outputTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||
{t("usage.cacheReadTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||
{t("usage.cacheCreationTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("usage.multiplier")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("usage.totalCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("usage.timingInfo")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("usage.status")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
<TableHead className="text-center whitespace-nowrap">
|
||||
{t("usage.source", { defaultValue: "Source" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -419,7 +269,7 @@ export function RequestLogTable({
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={12}
|
||||
colSpan={9}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("usage.noData")}
|
||||
@@ -428,140 +278,96 @@ export function RequestLogTable({
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.requestId}>
|
||||
<TableCell>
|
||||
{new Date(log.createdAt * 1000).toLocaleString(locale)}
|
||||
<TableCell className="text-center whitespace-nowrap text-xs px-1.5">
|
||||
{new Date(log.createdAt * 1000).toLocaleString(locale, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-center">
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-[200px]">
|
||||
<TableCell className="text-center font-mono text-xs max-w-[200px]">
|
||||
<div
|
||||
className="truncate"
|
||||
title={
|
||||
log.requestModel && log.requestModel !== log.model
|
||||
? `${t("usage.requestModel")}: ${log.requestModel}\n${t("usage.responseModel")}: ${log.model}`
|
||||
? `${log.requestModel} → ${log.model}`
|
||||
: log.model
|
||||
}
|
||||
>
|
||||
{log.model}
|
||||
{log.requestModel &&
|
||||
log.requestModel !== log.model ? (
|
||||
<span>
|
||||
{log.requestModel}
|
||||
<span className="text-muted-foreground">
|
||||
{" → "}
|
||||
{log.model}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
log.model
|
||||
)}
|
||||
</div>
|
||||
{log.requestModel && log.requestModel !== log.model && (
|
||||
<div
|
||||
className="truncate text-muted-foreground text-[10px]"
|
||||
title={log.requestModel}
|
||||
>
|
||||
← {log.requestModel}
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-1.5">
|
||||
<div className="tabular-nums">
|
||||
{fmtInt(log.inputTokens, locale)}
|
||||
</div>
|
||||
{(log.cacheReadTokens > 0 ||
|
||||
log.cacheCreationTokens > 0) && (
|
||||
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{[
|
||||
log.cacheReadTokens > 0 &&
|
||||
`R${fmtInt(log.cacheReadTokens, locale)}`,
|
||||
log.cacheCreationTokens > 0 &&
|
||||
`W${fmtInt(log.cacheCreationTokens, locale)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("·")}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{fmtInt(log.inputTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-center">
|
||||
{fmtInt(log.outputTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{fmtInt(log.cacheReadTokens, locale)}
|
||||
<TableCell className="text-center px-1.5">
|
||||
<div className="font-medium tabular-nums">
|
||||
{fmtUsd(log.totalCostUsd, 4)}
|
||||
</div>
|
||||
{parseFiniteNumber(log.costMultiplier) != null &&
|
||||
parseFiniteNumber(log.costMultiplier) !== 1 && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
×
|
||||
{parseFiniteNumber(log.costMultiplier)?.toFixed(
|
||||
2,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{fmtInt(log.cacheCreationTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? (
|
||||
<span className="text-orange-600">
|
||||
×{log.costMultiplier}
|
||||
<TableCell className="text-center whitespace-nowrap text-xs tabular-nums">
|
||||
{(log.latencyMs / 1000).toFixed(1)}s
|
||||
{log.firstTokenMs != null && (
|
||||
<span className="text-muted-foreground">
|
||||
/{(log.firstTokenMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">×1</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{fmtUsd(log.totalCostUsd, 6)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{(() => {
|
||||
const durationMs =
|
||||
typeof log.durationMs === "number"
|
||||
? log.durationMs
|
||||
: log.latencyMs;
|
||||
const durationSec = durationMs / 1000;
|
||||
const durationColor = Number.isFinite(durationSec)
|
||||
? durationSec <= 5
|
||||
? "bg-green-100 text-green-800"
|
||||
: durationSec <= 120
|
||||
? "bg-orange-100 text-orange-800"
|
||||
: "bg-red-200 text-red-900"
|
||||
: "bg-gray-100 text-gray-700";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
|
||||
>
|
||||
{Number.isFinite(durationSec)
|
||||
? `${Math.round(durationSec)}s`
|
||||
: "--"}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{log.isStreaming &&
|
||||
log.firstTokenMs != null &&
|
||||
(() => {
|
||||
const firstSec = log.firstTokenMs / 1000;
|
||||
const firstColor = Number.isFinite(firstSec)
|
||||
? firstSec <= 5
|
||||
? "bg-green-100 text-green-800"
|
||||
: firstSec <= 120
|
||||
? "bg-orange-100 text-orange-800"
|
||||
: "bg-red-200 text-red-900"
|
||||
: "bg-gray-100 text-gray-700";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
|
||||
>
|
||||
{Number.isFinite(firstSec)
|
||||
? `${firstSec.toFixed(1)}s`
|
||||
: "--"}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${
|
||||
log.isStreaming
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-purple-100 text-purple-800"
|
||||
}`}
|
||||
>
|
||||
{log.isStreaming
|
||||
? t("usage.stream")
|
||||
: t("usage.nonStream")}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs ${
|
||||
className={
|
||||
log.statusCode >= 200 && log.statusCode < 300
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{log.statusCode}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.dataSource && log.dataSource !== "proxy" ? (
|
||||
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-indigo-100 text-indigo-800">
|
||||
{t(`usage.dataSource.${log.dataSource}`, {
|
||||
defaultValue: log.dataSource,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-gray-100 text-gray-600">
|
||||
{t("usage.dataSource.proxy", {
|
||||
defaultValue: "Proxy",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<TableCell className="text-center text-xs text-muted-foreground">
|
||||
{log.dataSource || "proxy"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -570,89 +376,84 @@ export function RequestLogTable({
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{total > 0 && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("usage.totalRecords", { total })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{(() => {
|
||||
const pages: (number | string)[] = [];
|
||||
// 3 head + 3 tail + 3 neighborhood = 9 max distinct pages
|
||||
if (totalPages <= 9) {
|
||||
for (let i = 0; i < totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
const pageSet = new Set<number>();
|
||||
for (let i = 0; i < 3; i++) pageSet.add(i);
|
||||
for (let i = totalPages - 3; i < totalPages; i++)
|
||||
pageSet.add(i);
|
||||
for (
|
||||
let i = Math.max(0, page - 1);
|
||||
i <= Math.min(totalPages - 1, page + 1);
|
||||
i++
|
||||
)
|
||||
pageSet.add(i);
|
||||
const sorted = Array.from(pageSet).sort((a, b) => a - b);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
|
||||
pages.push(`ellipsis-${i}`);
|
||||
}
|
||||
pages.push(sorted[i]);
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{t("usage.totalRecords", { total })}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{(() => {
|
||||
const pages: (number | string)[] = [];
|
||||
// 3 head + 3 tail + 3 neighborhood = 9 max distinct pages
|
||||
if (totalPages <= 9) {
|
||||
for (let i = 0; i < totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
const pageSet = new Set<number>();
|
||||
for (let i = 0; i < 3; i++) pageSet.add(i);
|
||||
for (let i = totalPages - 3; i < totalPages; i++)
|
||||
pageSet.add(i);
|
||||
for (
|
||||
let i = Math.max(0, page - 1);
|
||||
i <= Math.min(totalPages - 1, page + 1);
|
||||
i++
|
||||
)
|
||||
pageSet.add(i);
|
||||
const sorted = Array.from(pageSet).sort((a, b) => a - b);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
|
||||
pages.push(`ellipsis-${i}`);
|
||||
}
|
||||
pages.push(sorted[i]);
|
||||
}
|
||||
return pages.map((p) =>
|
||||
typeof p === "string" ? (
|
||||
<span key={p} className="px-2 text-muted-foreground">
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
key={p}
|
||||
variant={p === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setPage(p)}
|
||||
>
|
||||
{p + 1}
|
||||
</Button>
|
||||
),
|
||||
);
|
||||
})()}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
}
|
||||
return pages.map((p) =>
|
||||
typeof p === "string" ? (
|
||||
<span key={p} className="px-2 text-muted-foreground">
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
key={p}
|
||||
variant={p === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setPage(p)}
|
||||
>
|
||||
{p + 1}
|
||||
</Button>
|
||||
),
|
||||
);
|
||||
})()}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={pageInput}
|
||||
onChange={(e) => setPageInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleGoToPage();
|
||||
}}
|
||||
placeholder={t("usage.pageInputPlaceholder")}
|
||||
className="h-8 w-16 text-center text-xs"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleGoToPage}>
|
||||
{t("usage.goToPage")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={pageInput}
|
||||
onChange={(e) => setPageInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleGoToPage();
|
||||
}}
|
||||
placeholder={t("usage.pageInputPlaceholder")}
|
||||
className="h-8 w-16 text-center text-xs"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleGoToPage}>
|
||||
{t("usage.goToPage")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UsageSummaryCards } from "./UsageSummaryCards";
|
||||
import { UsageTrendChart } from "./UsageTrendChart";
|
||||
import { RequestLogTable } from "./RequestLogTable";
|
||||
import { ProviderStatsTable } from "./ProviderStatsTable";
|
||||
import { ModelStatsTable } from "./ModelStatsTable";
|
||||
import type { AppTypeFilter, TimeRange } from "@/types/usage";
|
||||
import type {
|
||||
AppTypeFilter,
|
||||
UsageRangePreset,
|
||||
UsageRangeSelection,
|
||||
} from "@/types/usage";
|
||||
import { useUsageSummary } from "@/lib/query/usage";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
BarChart3,
|
||||
@@ -26,6 +30,10 @@ import {
|
||||
} from "@/components/ui/accordion";
|
||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fmtUsd, getLocaleFromLanguage, parseFiniteNumber } from "./format";
|
||||
import { resolveUsageRange } from "@/lib/usageRange";
|
||||
import { UsageDateRangePicker } from "./UsageDateRangePicker";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const APP_FILTER_OPTIONS: AppTypeFilter[] = [
|
||||
"all",
|
||||
@@ -34,10 +42,32 @@ const APP_FILTER_OPTIONS: AppTypeFilter[] = [
|
||||
"gemini",
|
||||
];
|
||||
|
||||
const RANGE_PRESETS: UsageRangePreset[] = ["today", "1d", "7d", "14d", "30d"];
|
||||
|
||||
function getPresetLabel(
|
||||
preset: UsageRangePreset,
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
switch (preset) {
|
||||
case "today":
|
||||
return t("usage.presetToday", { defaultValue: "当天" });
|
||||
case "1d":
|
||||
return t("usage.preset1d", { defaultValue: "1d" });
|
||||
case "7d":
|
||||
return t("usage.preset7d", { defaultValue: "7d" });
|
||||
case "14d":
|
||||
return t("usage.preset14d", { defaultValue: "14d" });
|
||||
case "30d":
|
||||
return t("usage.preset30d", { defaultValue: "30d" });
|
||||
case "custom":
|
||||
return t("usage.customRange", { defaultValue: "日历筛选" });
|
||||
}
|
||||
}
|
||||
|
||||
export function UsageDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
|
||||
const [range, setRange] = useState<UsageRangeSelection>({ preset: "today" });
|
||||
const [appType, setAppType] = useState<AppTypeFilter>("all");
|
||||
const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000);
|
||||
|
||||
@@ -46,14 +76,29 @@ export function UsageDashboard() {
|
||||
const currentIndex = refreshIntervalOptionsMs.indexOf(
|
||||
refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number],
|
||||
);
|
||||
const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s
|
||||
const safeIndex = currentIndex >= 0 ? currentIndex : 3;
|
||||
const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length;
|
||||
const next = refreshIntervalOptionsMs[nextIndex];
|
||||
setRefreshIntervalMs(next);
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
||||
};
|
||||
|
||||
const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30;
|
||||
const language = i18n.resolvedLanguage || i18n.language || "en";
|
||||
const locale = getLocaleFromLanguage(language);
|
||||
const resolvedRange = useMemo(() => resolveUsageRange(range), [range]);
|
||||
const rangeLabel = useMemo(() => {
|
||||
if (range.preset !== "custom") {
|
||||
return getPresetLabel(range.preset, t);
|
||||
}
|
||||
|
||||
return `${new Date(resolvedRange.startDate * 1000).toLocaleString(locale)} - ${new Date(
|
||||
resolvedRange.endDate * 1000,
|
||||
).toLocaleString(locale)}`;
|
||||
}, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]);
|
||||
|
||||
const { data: summaryData } = useUsageSummary(range, appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -62,82 +107,94 @@ export function UsageDashboard() {
|
||||
transition={{ duration: 0.4 }}
|
||||
className="space-y-8 pb-8"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold">{t("usage.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("usage.subtitle")}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold">{t("usage.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("usage.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={timeRange}
|
||||
onValueChange={(v) => setTimeRange(v as TimeRange)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<div className="flex w-full sm:w-auto items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-10 px-2 text-xs text-muted-foreground"
|
||||
title={t("common.refresh", "刷新")}
|
||||
onClick={changeRefreshInterval}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
|
||||
</Button>
|
||||
<TabsList className="flex w-full sm:w-auto bg-card/60 border border-border/50 backdrop-blur-sm shadow-sm h-10 p-1">
|
||||
<TabsTrigger
|
||||
value="1d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.today")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="7d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.last7days")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="30d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.last30days")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{APP_FILTER_OPTIONS.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setAppType(type)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
appType === type
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-primary hover:bg-muted/50 border border-transparent",
|
||||
)}
|
||||
>
|
||||
{t(`usage.appFilter.${type}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* App type filter bar (replaces DataSourceBar) */}
|
||||
<div className="rounded-xl border border-border/50 bg-card/40 backdrop-blur-sm p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{APP_FILTER_OPTIONS.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setAppType(type)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-lg text-sm font-medium transition-all",
|
||||
appType === type
|
||||
? "bg-primary/10 text-primary shadow-sm border border-primary/20"
|
||||
: "text-muted-foreground hover:text-primary hover:bg-muted/50 border border-transparent",
|
||||
)}
|
||||
>
|
||||
{t(`usage.appFilter.${type}`)}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-2 text-xs text-muted-foreground"
|
||||
title={t("common.refresh", "刷新")}
|
||||
onClick={changeRefreshInterval}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{refreshIntervalMs > 0 ? `${refreshIntervalMs / 1000}s` : "--"}
|
||||
</Button>
|
||||
|
||||
{RANGE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={range.preset === preset ? "default" : "outline"}
|
||||
onClick={() => setRange({ preset })}
|
||||
>
|
||||
{getPresetLabel(preset, t)}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<UsageDateRangePicker
|
||||
selection={range}
|
||||
triggerLabel={rangeLabel}
|
||||
onApply={(nextRange) => setRange(nextRange)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{rangeLabel}</span>
|
||||
<span className="text-border">|</span>
|
||||
<span>
|
||||
{(summaryData?.totalRequests ?? 0).toLocaleString()}{" "}
|
||||
{t("usage.requestsLabel")}
|
||||
</span>
|
||||
<span className="text-border">|</span>
|
||||
<span>
|
||||
{fmtUsd(parseFiniteNumber(summaryData?.totalCost) ?? 0, 4)}{" "}
|
||||
{t("usage.costLabel")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsageSummaryCards
|
||||
days={days}
|
||||
range={range}
|
||||
appType={appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
|
||||
<UsageTrendChart
|
||||
days={days}
|
||||
range={range}
|
||||
rangeLabel={rangeLabel}
|
||||
appType={appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
@@ -168,14 +225,16 @@ export function UsageDashboard() {
|
||||
>
|
||||
<TabsContent value="logs" className="mt-0">
|
||||
<RequestLogTable
|
||||
range={range}
|
||||
rangeLabel={rangeLabel}
|
||||
appType={appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
timeRange={timeRange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="providers" className="mt-0">
|
||||
<ProviderStatsTable
|
||||
range={range}
|
||||
appType={appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
@@ -183,6 +242,7 @@ export function UsageDashboard() {
|
||||
|
||||
<TabsContent value="models" className="mt-0">
|
||||
<ModelStatsTable
|
||||
range={range}
|
||||
appType={appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
@@ -191,7 +251,6 @@ export function UsageDashboard() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Pricing Configuration */}
|
||||
<Accordion type="multiple" defaultValue={[]} className="w-full space-y-4">
|
||||
<AccordionItem
|
||||
value="pricing"
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveUsageRange } from "@/lib/usageRange";
|
||||
import { getLocaleFromLanguage } from "./format";
|
||||
import type { UsageRangeSelection } from "@/types/usage";
|
||||
|
||||
type DraftField = "start" | "end";
|
||||
|
||||
interface UsageDateRangePickerProps {
|
||||
selection: UsageRangeSelection;
|
||||
onApply: (selection: UsageRangeSelection) => void;
|
||||
triggerLabel: string;
|
||||
}
|
||||
|
||||
/* ── helpers ── */
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function toTs(d: Date): number {
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
function fromTs(ts: number): Date {
|
||||
return new Date(ts * 1000);
|
||||
}
|
||||
|
||||
function fmtDate(ts: number): string {
|
||||
const d = fromTs(ts);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
const d = fromTs(ts);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseDateInput(ts: number, value: string): number {
|
||||
const [y, m, d] = value.split("-").map(Number);
|
||||
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d))
|
||||
return ts;
|
||||
const base = fromTs(ts);
|
||||
return toTs(new Date(y, m - 1, d, base.getHours(), base.getMinutes()));
|
||||
}
|
||||
|
||||
function parseTimeInput(ts: number, value: string): number {
|
||||
const [h, min] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(min)) return ts;
|
||||
const base = fromTs(ts);
|
||||
return toTs(
|
||||
new Date(base.getFullYear(), base.getMonth(), base.getDate(), h, min),
|
||||
);
|
||||
}
|
||||
|
||||
function setDateKeepTime(ts: number, day: Date): number {
|
||||
const base = fromTs(ts);
|
||||
return toTs(
|
||||
new Date(
|
||||
day.getFullYear(),
|
||||
day.getMonth(),
|
||||
day.getDate(),
|
||||
base.getHours(),
|
||||
base.getMinutes(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getCalendarDays(month: Date): Date[] {
|
||||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const gridStart = new Date(first);
|
||||
gridStart.setDate(first.getDate() - first.getDay());
|
||||
return Array.from({ length: 42 }, (_, i) => {
|
||||
const d = new Date(gridStart);
|
||||
d.setDate(gridStart.getDate() + i);
|
||||
return d;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── component ── */
|
||||
|
||||
export function UsageDateRangePicker({
|
||||
selection,
|
||||
onApply,
|
||||
triggerLabel,
|
||||
}: UsageDateRangePickerProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeField, setActiveField] = useState<DraftField>("start");
|
||||
const resolvedRange = useMemo(
|
||||
() => resolveUsageRange(selection),
|
||||
[selection],
|
||||
);
|
||||
const [draftStart, setDraftStart] = useState(resolvedRange.startDate);
|
||||
const [draftEnd, setDraftEnd] = useState(resolvedRange.endDate);
|
||||
const [displayMonth, setDisplayMonth] = useState(
|
||||
() =>
|
||||
new Date(
|
||||
fromTs(resolvedRange.startDate).getFullYear(),
|
||||
fromTs(resolvedRange.startDate).getMonth(),
|
||||
1,
|
||||
),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const language = i18n.resolvedLanguage || i18n.language || "en";
|
||||
const locale = getLocaleFromLanguage(language);
|
||||
|
||||
// Reset draft when popover opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const r = resolveUsageRange(selection);
|
||||
setDraftStart(r.startDate);
|
||||
setDraftEnd(r.endDate);
|
||||
setDisplayMonth(
|
||||
new Date(
|
||||
fromTs(r.startDate).getFullYear(),
|
||||
fromTs(r.startDate).getMonth(),
|
||||
1,
|
||||
),
|
||||
);
|
||||
setActiveField("start");
|
||||
setError(null);
|
||||
}, [open, selection]);
|
||||
|
||||
const calendarDays = useMemo(
|
||||
() => getCalendarDays(displayMonth),
|
||||
[displayMonth],
|
||||
);
|
||||
|
||||
const weekdayLabels = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 7 }, (_, i) =>
|
||||
new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format(
|
||||
new Date(2024, 0, 7 + i),
|
||||
),
|
||||
),
|
||||
[locale],
|
||||
);
|
||||
|
||||
const startDay = fromTs(draftStart);
|
||||
const endDay = fromTs(draftEnd);
|
||||
const today = new Date();
|
||||
|
||||
/* Pick a date from the calendar */
|
||||
const handleDatePick = (day: Date) => {
|
||||
setError(null);
|
||||
const nextTs = setDateKeepTime(
|
||||
activeField === "start" ? draftStart : draftEnd,
|
||||
day,
|
||||
);
|
||||
|
||||
if (activeField === "start") {
|
||||
setDraftStart(nextTs);
|
||||
// Auto-swap if start > end
|
||||
if (nextTs > draftEnd) {
|
||||
setDraftEnd(nextTs);
|
||||
}
|
||||
// Auto-advance to end field
|
||||
setActiveField("end");
|
||||
} else {
|
||||
// If picked end < start, treat as new start and auto-advance
|
||||
if (nextTs < draftStart) {
|
||||
setDraftStart(nextTs);
|
||||
setActiveField("end");
|
||||
} else {
|
||||
setDraftEnd(nextTs);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate calendar if the day is outside the displayed month
|
||||
if (
|
||||
day.getMonth() !== displayMonth.getMonth() ||
|
||||
day.getFullYear() !== displayMonth.getFullYear()
|
||||
) {
|
||||
setDisplayMonth(new Date(day.getFullYear(), day.getMonth(), 1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
setError(null);
|
||||
if (draftStart > draftEnd) {
|
||||
setError(t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"));
|
||||
return;
|
||||
}
|
||||
onApply({
|
||||
preset: "custom",
|
||||
customStartDate: draftStart,
|
||||
customEndDate: draftEnd,
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setDisplayMonth(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
};
|
||||
|
||||
/* ── Field card (start / end) ── */
|
||||
const renderField = (field: DraftField) => {
|
||||
const isActive = activeField === field;
|
||||
const ts = field === "start" ? draftStart : draftEnd;
|
||||
const setTs = field === "start" ? setDraftStart : setDraftEnd;
|
||||
const label =
|
||||
field === "start"
|
||||
? t("usage.startTime", "开始时间")
|
||||
: t("usage.endTime", "结束时间");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 cursor-pointer transition-all",
|
||||
isActive
|
||||
? "border-primary ring-1 ring-primary/30 bg-primary/5"
|
||||
: "border-border/50 hover:border-border",
|
||||
)}
|
||||
onClick={() => setActiveField(field)}
|
||||
>
|
||||
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-7 flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
|
||||
value={fmtDate(ts)}
|
||||
onChange={(e) => {
|
||||
const next = parseDateInput(ts, e.target.value);
|
||||
setTs(next);
|
||||
const d = fromTs(next);
|
||||
setDisplayMonth(new Date(d.getFullYear(), d.getMonth(), 1));
|
||||
setError(null);
|
||||
}}
|
||||
onFocus={() => setActiveField(field)}
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
step={60}
|
||||
className="h-7 w-[90px] flex-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
|
||||
value={fmtTime(ts)}
|
||||
onChange={(e) => {
|
||||
setTs(parseTimeInput(ts, e.target.value));
|
||||
setError(null);
|
||||
}}
|
||||
onFocus={() => setActiveField(field)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={selection.preset === "custom" ? "default" : "outline"}
|
||||
className="justify-start gap-2"
|
||||
>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[340px] max-w-[calc(100vw-2rem)] p-3 sm:w-[620px]"
|
||||
align="end"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
{/* Left: date fields */}
|
||||
<div className="space-y-2 sm:w-[250px] sm:flex-none">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("usage.customRangeHint", "支持日期与时间,最长 30 天")}
|
||||
</p>
|
||||
{renderField("start")}
|
||||
{renderField("end")}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={handleApply}
|
||||
>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: calendar */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 p-2.5 sm:min-w-0 sm:flex-1">
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() =>
|
||||
setDisplayMonth(
|
||||
new Date(
|
||||
displayMonth.getFullYear(),
|
||||
displayMonth.getMonth() - 1,
|
||||
1,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium hover:text-primary transition-colors"
|
||||
onClick={goToToday}
|
||||
title={t("usage.presetToday", { defaultValue: "当天" })}
|
||||
>
|
||||
{displayMonth.toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() =>
|
||||
setDisplayMonth(
|
||||
new Date(
|
||||
displayMonth.getFullYear(),
|
||||
displayMonth.getMonth() + 1,
|
||||
1,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 text-center text-[11px] text-muted-foreground mb-0.5">
|
||||
{weekdayLabels.map((label, i) => (
|
||||
<div key={i} className="py-0.5">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7 gap-px">
|
||||
{calendarDays.map((day) => {
|
||||
const isCurrentMonth =
|
||||
day.getMonth() === displayMonth.getMonth();
|
||||
const isToday = isSameDay(day, today);
|
||||
const isStart = isSameDay(day, startDay);
|
||||
const isEnd = isSameDay(day, endDay);
|
||||
const dayStart = startOfDay(day);
|
||||
const inRange =
|
||||
dayStart >= startOfDay(startDay) &&
|
||||
dayStart <= startOfDay(endDay);
|
||||
const isEndpoint = isStart || isEnd;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative h-7 rounded text-xs transition-colors",
|
||||
!isCurrentMonth && "text-muted-foreground/30",
|
||||
isCurrentMonth && !inRange && "hover:bg-muted",
|
||||
inRange && !isEndpoint && "bg-primary/10 text-primary",
|
||||
isEndpoint &&
|
||||
"bg-primary text-primary-foreground font-medium",
|
||||
isToday && !isEndpoint && "ring-1 ring-primary/40",
|
||||
)}
|
||||
onClick={() => handleDatePick(day)}
|
||||
>
|
||||
{day.getDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -5,21 +5,22 @@ import { useUsageSummary } from "@/lib/query/usage";
|
||||
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { fmtUsd, parseFiniteNumber } from "./format";
|
||||
import type { UsageRangeSelection } from "@/types/usage";
|
||||
|
||||
interface UsageSummaryCardsProps {
|
||||
days: number;
|
||||
range: UsageRangeSelection;
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function UsageSummaryCards({
|
||||
days,
|
||||
range,
|
||||
appType,
|
||||
refreshIntervalMs,
|
||||
}: UsageSummaryCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: summary, isLoading } = useUsageSummary(days, appType, {
|
||||
const { data: summary, isLoading } = useUsageSummary(range, appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,20 +17,25 @@ import {
|
||||
getLocaleFromLanguage,
|
||||
parseFiniteNumber,
|
||||
} from "./format";
|
||||
import { resolveUsageRange } from "@/lib/usageRange";
|
||||
import type { UsageRangeSelection } from "@/types/usage";
|
||||
|
||||
interface UsageTrendChartProps {
|
||||
days: number;
|
||||
range: UsageRangeSelection;
|
||||
rangeLabel: string;
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function UsageTrendChart({
|
||||
days,
|
||||
range,
|
||||
rangeLabel,
|
||||
appType,
|
||||
refreshIntervalMs,
|
||||
}: UsageTrendChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data: trends, isLoading } = useUsageTrends(days, appType, {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
const { data: trends, isLoading } = useUsageTrends(range, appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
@@ -42,7 +47,8 @@ export function UsageTrendChart({
|
||||
);
|
||||
}
|
||||
|
||||
const isToday = days === 1;
|
||||
const durationSeconds = Math.max(endDate - startDate, 0);
|
||||
const isHourly = durationSeconds <= 24 * 60 * 60;
|
||||
const language = i18n.resolvedLanguage || i18n.language || "en";
|
||||
const dateLocale = getLocaleFromLanguage(language);
|
||||
const chartData =
|
||||
@@ -51,7 +57,7 @@ export function UsageTrendChart({
|
||||
const cost = parseFiniteNumber(stat.totalCost);
|
||||
return {
|
||||
rawDate: stat.date,
|
||||
label: isToday
|
||||
label: isHourly
|
||||
? pointDate.toLocaleString(dateLocale, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
@@ -108,13 +114,7 @@ export function UsageTrendChart({
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("usage.trends", "使用趋势")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isToday
|
||||
? t("usage.rangeToday", "今天 (按小时)")
|
||||
: days === 7
|
||||
? t("usage.rangeLast7Days", "过去 7 天")
|
||||
: t("usage.rangeLast30Days", "过去 30 天")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="h-[350px] w-full">
|
||||
|
||||
@@ -49,12 +49,7 @@ export interface ProviderPreset {
|
||||
// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
// - "gemini_native": Gemini Native generateContent API 格式,需要格式转换
|
||||
apiFormat?:
|
||||
| "anthropic"
|
||||
| "openai_chat"
|
||||
| "openai_responses"
|
||||
| "gemini_native";
|
||||
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
|
||||
|
||||
// 供应商类型标识(用于特殊供应商检测)
|
||||
// - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证)
|
||||
@@ -85,27 +80,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "anthropic",
|
||||
iconColor: "#D4915D",
|
||||
},
|
||||
{
|
||||
name: "Gemini Native",
|
||||
websiteUrl: "https://ai.google.dev/gemini-api",
|
||||
apiKeyUrl: "https://aistudio.google.com/app/apikey",
|
||||
apiKeyField: "ANTHROPIC_API_KEY",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://generativelanguage.googleapis.com",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_MODEL: "gemini-2.5-pro",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "gemini-2.5-flash",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "gemini-2.5-pro",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "gemini-2.5-pro",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
apiFormat: "gemini_native",
|
||||
endpointCandidates: ["https://generativelanguage.googleapis.com"],
|
||||
icon: "gemini",
|
||||
iconColor: "#4285F4",
|
||||
},
|
||||
{
|
||||
name: "Shengsuanyun",
|
||||
nameKey: "providerForm.presets.shengsuanyun",
|
||||
|
||||
@@ -46,22 +46,6 @@ 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
|
||||
|
||||
@@ -801,7 +801,6 @@
|
||||
"modelHint": "💡 Leave blank to use provider's default model",
|
||||
"apiHint": "💡 Fill in Claude API compatible service endpoint, avoid trailing slash",
|
||||
"apiHintOAI": "💡 Fill in OpenAI Chat Completions compatible service endpoint, avoid trailing slash",
|
||||
"apiHintGeminiNative": "💡 Prefer a Gemini Native base URL such as https://generativelanguage.googleapis.com or https://generativelanguage.googleapis.com/v1beta; the proxy will append models/*:generateContent automatically",
|
||||
"codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format",
|
||||
"fillSupplierName": "Please fill in provider name",
|
||||
"fillConfigContent": "Please fill in configuration content",
|
||||
@@ -824,11 +823,9 @@
|
||||
"fullUrlEnabled": "Full URL Mode",
|
||||
"fullUrlDisabled": "Mark as Full URL",
|
||||
"fullUrlHint": "💡 Enter the full request URL. This mode requires routing to be enabled, and routing will use the URL as-is without appending a path",
|
||||
"fullUrlHintGeminiNative": "💡 In Gemini Native full URL mode, two inputs are supported: 1. official/structured Gemini URLs, which will still be normalized to the requested model and streaming method; 2. opaque custom relay URLs, which will be used mostly as-is with only query parameters appended",
|
||||
"apiFormatAnthropic": "Anthropic Messages (Native)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires routing)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API (Requires routing)",
|
||||
"apiFormatGeminiNative": "Gemini Native generateContent (Requires routing)",
|
||||
"authField": "Auth Field",
|
||||
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN (Default)",
|
||||
"authFieldApiKey": "ANTHROPIC_API_KEY",
|
||||
@@ -1051,6 +1048,11 @@
|
||||
"today": "24 Hours",
|
||||
"last7days": "7 Days",
|
||||
"last30days": "30 Days",
|
||||
"presetToday": "Today",
|
||||
"preset1d": "1d",
|
||||
"preset7d": "7d",
|
||||
"preset14d": "14d",
|
||||
"preset30d": "30d",
|
||||
"totalRequests": "Total Requests",
|
||||
"totalCost": "Total Cost",
|
||||
"cost": "Cost",
|
||||
@@ -1071,6 +1073,8 @@
|
||||
"outputTokens": "Output",
|
||||
"cacheReadTokens": "Cache Hit",
|
||||
"cacheCreationTokens": "Cache Creation",
|
||||
"cacheReadShort": "Cache Read",
|
||||
"cacheWriteShort": "Write",
|
||||
"timingInfo": "Duration/TTFT",
|
||||
"status": "Status",
|
||||
"multiplier": "Multiplier",
|
||||
@@ -1142,6 +1146,10 @@
|
||||
"searchProviderPlaceholder": "Search provider...",
|
||||
"searchModelPlaceholder": "Search model...",
|
||||
"timeRange": "Time Range",
|
||||
"customRange": "Calendar Filter",
|
||||
"customRangeHint": "Supports both date and time, up to 30 days",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Creation",
|
||||
@@ -2134,8 +2142,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -801,7 +801,6 @@
|
||||
"modelHint": "💡 空欄ならプロバイダーのデフォルトモデルを使用します",
|
||||
"apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
|
||||
"apiHintOAI": "💡 OpenAI Chat Completions 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください",
|
||||
"apiHintGeminiNative": "💡 Gemini Native では https://generativelanguage.googleapis.com または https://generativelanguage.googleapis.com/v1beta のような base URL を推奨します。プロキシが models/*:generateContent を自動補完します",
|
||||
"codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください",
|
||||
"fillSupplierName": "プロバイダー名を入力してください",
|
||||
"fillConfigContent": "設定内容を入力してください",
|
||||
@@ -824,11 +823,9 @@
|
||||
"fullUrlEnabled": "フル URL モード",
|
||||
"fullUrlDisabled": "フル URL として設定",
|
||||
"fullUrlHint": "💡 完全なリクエスト URL を入力してください。このモードはルーティングを有効にして使用する必要があり、ルーティングはこの URL をそのまま使用し、パスを追加しません",
|
||||
"fullUrlHintGeminiNative": "💡 Gemini Native のフル URL モードでは 2 種類の入力を扱えます。1. 公式/構造化された Gemini URL は、要求されたモデルやストリーミング方式に合わせて正規化されます。2. カスタム relay の opaque な完全 URL は、主にそのまま使用され、必要なクエリだけ追加されます",
|
||||
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions(ルーティングが必要)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API(ルーティングが必要)",
|
||||
"apiFormatGeminiNative": "Gemini Native generateContent(ルーティングが必要)",
|
||||
"authField": "認証フィールド",
|
||||
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(デフォルト)",
|
||||
"authFieldApiKey": "ANTHROPIC_API_KEY",
|
||||
@@ -1051,6 +1048,11 @@
|
||||
"today": "24時間",
|
||||
"last7days": "7日間",
|
||||
"last30days": "30日間",
|
||||
"presetToday": "当日",
|
||||
"preset1d": "1d",
|
||||
"preset7d": "7d",
|
||||
"preset14d": "14d",
|
||||
"preset30d": "30d",
|
||||
"totalRequests": "総リクエスト数",
|
||||
"totalCost": "総コスト",
|
||||
"cost": "コスト",
|
||||
@@ -1071,6 +1073,8 @@
|
||||
"outputTokens": "出力",
|
||||
"cacheReadTokens": "キャッシュヒット",
|
||||
"cacheCreationTokens": "キャッシュ作成",
|
||||
"cacheReadShort": "キャッシュ読",
|
||||
"cacheWriteShort": "書",
|
||||
"timingInfo": "応答時間/TTFT",
|
||||
"status": "ステータス",
|
||||
"multiplier": "倍率",
|
||||
@@ -1142,6 +1146,10 @@
|
||||
"searchProviderPlaceholder": "プロバイダーを検索...",
|
||||
"searchModelPlaceholder": "モデルを検索...",
|
||||
"timeRange": "期間",
|
||||
"customRange": "カレンダーフィルター",
|
||||
"customRangeHint": "日付と時刻の両方に対応、最大 30 日",
|
||||
"startTime": "開始時刻",
|
||||
"endTime": "終了時刻",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "作成",
|
||||
@@ -2134,8 +2142,6 @@
|
||||
"failed": "{{providerName}} のチェックに失敗しました: {{message}}",
|
||||
"rejected": "{{providerName}} のチェックが拒否されました: {{message}}",
|
||||
"error": "{{providerName}} のチェックでエラーが発生しました: {{error}}",
|
||||
"modelNotFound": "{{providerName}} のテストモデル {{model}} は存在しないか廃止されています",
|
||||
"modelNotFoundHint": "このモデルはプロバイダーにより廃止された可能性があります。「モデルテスト設定」でデフォルトのテストモデルを更新してください。",
|
||||
"httpHint": {
|
||||
"400": "リクエスト形式が拒否されました。ヘルスチェックの形式は実際の使用と異なる場合があります。",
|
||||
"401": "APIキーが無効か、OAuthなどの認証方式を使用しています。チェック失敗は実際に使えないことを意味しません。",
|
||||
|
||||
@@ -802,7 +802,6 @@
|
||||
"modelHint": "💡 留空将使用供应商的默认模型",
|
||||
"apiHint": "💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾",
|
||||
"apiHintOAI": "💡 填写兼容 OpenAI Chat Completions 的服务端点地址,不要以斜杠结尾",
|
||||
"apiHintGeminiNative": "💡 建议填写 Gemini Native 的 base URL,例如 https://generativelanguage.googleapis.com 或 https://generativelanguage.googleapis.com/v1beta;代理会自动补全 models/*:generateContent",
|
||||
"codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址",
|
||||
"fillSupplierName": "请填写供应商名称",
|
||||
"fillConfigContent": "请填写配置内容",
|
||||
@@ -825,11 +824,9 @@
|
||||
"fullUrlEnabled": "完整 URL 模式",
|
||||
"fullUrlDisabled": "标记为完整 URL",
|
||||
"fullUrlHint": "💡 请填写完整请求 URL,并且必须开启路由后使用;路由将直接使用此 URL,不拼接路径",
|
||||
"fullUrlHintGeminiNative": "💡 Gemini Native 下,完整 URL 模式同时兼容两类地址:1. 官方/标准 Gemini URL,代理会按模型和流式参数自动归一化;2. 自定义 relay 的完整 URL,代理会尽量原样使用,只补查询参数,不再强行追加 models 路径",
|
||||
"apiFormatAnthropic": "Anthropic Messages (原生)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启路由)",
|
||||
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启路由)",
|
||||
"apiFormatGeminiNative": "Gemini Native generateContent (需开启路由)",
|
||||
"authField": "认证字段",
|
||||
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(默认)",
|
||||
"authFieldApiKey": "ANTHROPIC_API_KEY",
|
||||
@@ -1052,6 +1049,11 @@
|
||||
"today": "24小时",
|
||||
"last7days": "7天",
|
||||
"last30days": "30天",
|
||||
"presetToday": "当天",
|
||||
"preset1d": "1d",
|
||||
"preset7d": "7d",
|
||||
"preset14d": "14d",
|
||||
"preset30d": "30d",
|
||||
"totalRequests": "总请求数",
|
||||
"totalCost": "总成本",
|
||||
"cost": "成本",
|
||||
@@ -1072,6 +1074,8 @@
|
||||
"outputTokens": "输出",
|
||||
"cacheReadTokens": "缓存命中",
|
||||
"cacheCreationTokens": "缓存创建",
|
||||
"cacheReadShort": "缓存读",
|
||||
"cacheWriteShort": "写",
|
||||
"timingInfo": "用时/首字",
|
||||
"status": "状态",
|
||||
"multiplier": "倍率",
|
||||
@@ -1143,6 +1147,10 @@
|
||||
"searchProviderPlaceholder": "搜索供应商...",
|
||||
"searchModelPlaceholder": "搜索模型...",
|
||||
"timeRange": "时间范围",
|
||||
"customRange": "日历筛选",
|
||||
"customRangeHint": "支持日期与时间,最长 30 天",
|
||||
"startTime": "开始时间",
|
||||
"endTime": "结束时间",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "创建",
|
||||
@@ -2135,8 +2143,6 @@
|
||||
"failed": "{{providerName}} 检查失败: {{message}}",
|
||||
"rejected": "{{providerName}} 检查被拒: {{message}}",
|
||||
"error": "{{providerName}} 检查出错: {{error}}",
|
||||
"modelNotFound": "{{providerName}} 测试模型 {{model}} 不存在或已下架",
|
||||
"modelNotFoundHint": "该模型可能已被供应商弃用。请在\"模型测试配置\"中更新默认测试模型。",
|
||||
"httpHint": {
|
||||
"400": "供应商拒绝了请求格式。健康检查的探测格式可能与实际使用不同。",
|
||||
"401": "API Key 可能无效,或供应商使用 OAuth 等认证方式。检查失败不代表实际不可用。",
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface StreamCheckResult {
|
||||
modelUsed: string;
|
||||
testedAt: number;
|
||||
retryCount: number;
|
||||
/** 细粒度错误分类,如 "modelNotFound" */
|
||||
errorCategory?: string;
|
||||
}
|
||||
|
||||
// ===== 流式健康检查 API =====
|
||||
|
||||
+12
-4
@@ -63,12 +63,20 @@ export const usageApi = {
|
||||
return invoke("get_usage_trends", { startDate, endDate, appType });
|
||||
},
|
||||
|
||||
getProviderStats: async (appType?: string): Promise<ProviderStats[]> => {
|
||||
return invoke("get_provider_stats", { appType });
|
||||
getProviderStats: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
): Promise<ProviderStats[]> => {
|
||||
return invoke("get_provider_stats", { startDate, endDate, appType });
|
||||
},
|
||||
|
||||
getModelStats: async (appType?: string): Promise<ModelStats[]> => {
|
||||
return invoke("get_model_stats", { appType });
|
||||
getModelStats: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
appType?: string,
|
||||
): Promise<ModelStats[]> => {
|
||||
return invoke("get_model_stats", { startDate, endDate, appType });
|
||||
},
|
||||
|
||||
getRequestLogs: async (
|
||||
|
||||
+112
-55
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { usageApi } from "@/lib/api/usage";
|
||||
import type { LogFilters } from "@/types/usage";
|
||||
import { resolveUsageRange } from "@/lib/usageRange";
|
||||
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
|
||||
|
||||
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
|
||||
|
||||
@@ -9,51 +10,94 @@ type UsageQueryOptions = {
|
||||
refetchIntervalInBackground?: boolean;
|
||||
};
|
||||
|
||||
type RequestLogsTimeMode = "rolling" | "fixed";
|
||||
|
||||
type RequestLogsQueryArgs = {
|
||||
filters: LogFilters;
|
||||
timeMode: RequestLogsTimeMode;
|
||||
range: UsageRangeSelection;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
rollingWindowSeconds?: number;
|
||||
options?: UsageQueryOptions;
|
||||
};
|
||||
|
||||
type RequestLogsKey = {
|
||||
timeMode: RequestLogsTimeMode;
|
||||
rollingWindowSeconds?: number;
|
||||
preset: UsageRangeSelection["preset"];
|
||||
customStartDate?: number;
|
||||
customEndDate?: number;
|
||||
appType?: string;
|
||||
providerName?: string;
|
||||
model?: string;
|
||||
statusCode?: number;
|
||||
startDate?: number;
|
||||
endDate?: number;
|
||||
};
|
||||
|
||||
// Query keys
|
||||
export const usageKeys = {
|
||||
all: ["usage"] as const,
|
||||
summary: (days: number, appType?: string) =>
|
||||
[...usageKeys.all, "summary", days, appType ?? "all"] as const,
|
||||
trends: (days: number, appType?: string) =>
|
||||
[...usageKeys.all, "trends", days, appType ?? "all"] as const,
|
||||
providerStats: (appType?: string) =>
|
||||
[...usageKeys.all, "provider-stats", appType ?? "all"] as const,
|
||||
modelStats: (appType?: string) =>
|
||||
[...usageKeys.all, "model-stats", appType ?? "all"] as const,
|
||||
summary: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
"summary",
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
] as const,
|
||||
trends: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
"trends",
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
] as const,
|
||||
providerStats: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
"provider-stats",
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
] as const,
|
||||
modelStats: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
appType?: string,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
"model-stats",
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
] as const,
|
||||
logs: (key: RequestLogsKey, page: number, pageSize: number) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
"logs",
|
||||
key.timeMode,
|
||||
key.rollingWindowSeconds ?? 0,
|
||||
key.preset,
|
||||
key.customStartDate ?? 0,
|
||||
key.customEndDate ?? 0,
|
||||
key.appType ?? "",
|
||||
key.providerName ?? "",
|
||||
key.model ?? "",
|
||||
key.statusCode ?? -1,
|
||||
key.startDate ?? 0,
|
||||
key.endDate ?? 0,
|
||||
page,
|
||||
pageSize,
|
||||
] as const,
|
||||
@@ -64,23 +108,22 @@ export const usageKeys = {
|
||||
[...usageKeys.all, "limits", providerId, appType] as const,
|
||||
};
|
||||
|
||||
const getWindow = (days: number) => {
|
||||
const endDate = Math.floor(Date.now() / 1000);
|
||||
const startDate = endDate - days * 24 * 60 * 60;
|
||||
return { startDate, endDate };
|
||||
};
|
||||
|
||||
// Hooks
|
||||
export function useUsageSummary(
|
||||
days: number,
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
return useQuery({
|
||||
queryKey: usageKeys.summary(days, appType),
|
||||
queryKey: usageKeys.summary(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = getWindow(days);
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getUsageSummary(startDate, endDate, effectiveAppType);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
@@ -89,15 +132,20 @@ export function useUsageSummary(
|
||||
}
|
||||
|
||||
export function useUsageTrends(
|
||||
days: number,
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
return useQuery({
|
||||
queryKey: usageKeys.trends(days, appType),
|
||||
queryKey: usageKeys.trends(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = getWindow(days);
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getUsageTrends(startDate, endDate, effectiveAppType);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
@@ -106,61 +154,70 @@ export function useUsageTrends(
|
||||
}
|
||||
|
||||
export function useProviderStats(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
return useQuery({
|
||||
queryKey: usageKeys.providerStats(appType),
|
||||
queryFn: () => usageApi.getProviderStats(effectiveAppType),
|
||||
queryKey: usageKeys.providerStats(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getProviderStats(startDate, endDate, effectiveAppType);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelStats(appType?: string, options?: UsageQueryOptions) {
|
||||
export function useModelStats(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
const effectiveAppType = appType === "all" ? undefined : appType;
|
||||
return useQuery({
|
||||
queryKey: usageKeys.modelStats(appType),
|
||||
queryFn: () => usageApi.getModelStats(effectiveAppType),
|
||||
queryKey: usageKeys.modelStats(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
appType,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getModelStats(startDate, endDate, effectiveAppType);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
const getRollingRange = (windowSeconds: number) => {
|
||||
const endDate = Math.floor(Date.now() / 1000);
|
||||
const startDate = endDate - windowSeconds;
|
||||
return { startDate, endDate };
|
||||
};
|
||||
|
||||
export function useRequestLogs({
|
||||
filters,
|
||||
timeMode,
|
||||
range,
|
||||
page = 0,
|
||||
pageSize = 20,
|
||||
rollingWindowSeconds = 24 * 60 * 60,
|
||||
options,
|
||||
}: RequestLogsQueryArgs) {
|
||||
const key: RequestLogsKey = {
|
||||
timeMode,
|
||||
rollingWindowSeconds:
|
||||
timeMode === "rolling" ? rollingWindowSeconds : undefined,
|
||||
preset: range.preset,
|
||||
customStartDate: range.customStartDate,
|
||||
customEndDate: range.customEndDate,
|
||||
appType: filters.appType,
|
||||
providerName: filters.providerName,
|
||||
model: filters.model,
|
||||
statusCode: filters.statusCode,
|
||||
startDate: timeMode === "fixed" ? filters.startDate : undefined,
|
||||
endDate: timeMode === "fixed" ? filters.endDate : undefined,
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryKey: usageKeys.logs(key, page, pageSize),
|
||||
queryFn: () => {
|
||||
const effectiveFilters =
|
||||
timeMode === "rolling"
|
||||
? { ...filters, ...getRollingRange(rollingWindowSeconds) }
|
||||
: filters;
|
||||
const effectiveFilters = { ...filters, ...resolveUsageRange(range) };
|
||||
return usageApi.getRequestLogs(effectiveFilters, page, pageSize);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { UsageRangePreset, UsageRangeSelection } from "@/types/usage";
|
||||
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
const DAY_MS = DAY_SECONDS * 1000;
|
||||
|
||||
export const MAX_CUSTOM_USAGE_RANGE_SECONDS = 30 * DAY_SECONDS;
|
||||
|
||||
export interface ResolvedUsageRange {
|
||||
startDate: number;
|
||||
endDate: number;
|
||||
}
|
||||
|
||||
function getStartOfLocalDayDate(nowMs: number): Date {
|
||||
const date = new Date(nowMs);
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
function getPresetLookbackStart(
|
||||
preset: Exclude<UsageRangePreset, "today" | "1d" | "custom">,
|
||||
nowMs: number,
|
||||
): number {
|
||||
const dayCount = preset === "7d" ? 7 : preset === "14d" ? 14 : 30;
|
||||
return Math.floor(
|
||||
getStartOfLocalDayDate(nowMs - (dayCount - 1) * DAY_MS).getTime() / 1000,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveUsageRange(
|
||||
selection: UsageRangeSelection,
|
||||
nowMs: number = Date.now(),
|
||||
): ResolvedUsageRange {
|
||||
const endDate = Math.floor(nowMs / 1000);
|
||||
|
||||
switch (selection.preset) {
|
||||
case "today":
|
||||
return {
|
||||
startDate: Math.floor(getStartOfLocalDayDate(nowMs).getTime() / 1000),
|
||||
endDate,
|
||||
};
|
||||
case "1d":
|
||||
return {
|
||||
startDate: endDate - DAY_SECONDS,
|
||||
endDate,
|
||||
};
|
||||
case "7d":
|
||||
case "14d":
|
||||
case "30d":
|
||||
return {
|
||||
startDate: getPresetLookbackStart(selection.preset, nowMs),
|
||||
endDate,
|
||||
};
|
||||
case "custom": {
|
||||
const startDate = selection.customStartDate ?? endDate - DAY_SECONDS;
|
||||
const customEndDate = selection.customEndDate ?? endDate;
|
||||
return {
|
||||
startDate,
|
||||
endDate: customEndDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function timestampToLocalDatetime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export function localDatetimeToTimestamp(datetime: string): number | undefined {
|
||||
if (!datetime || datetime.length < 16) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timestamp = new Date(datetime).getTime();
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.floor(timestamp / 1000);
|
||||
}
|
||||
+2
-12
@@ -141,12 +141,7 @@ export interface ProviderMeta {
|
||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
// - "gemini_native": Gemini Native generateContent API 格式,需要格式转换
|
||||
apiFormat?:
|
||||
| "anthropic"
|
||||
| "openai_chat"
|
||||
| "openai_responses"
|
||||
| "gemini_native";
|
||||
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
|
||||
// 通用认证绑定
|
||||
authBinding?: AuthBinding;
|
||||
// Claude 认证字段名
|
||||
@@ -171,12 +166,7 @@ export type SkillStorageLocation = "cc_switch" | "unified";
|
||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
// - "gemini_native": Gemini Native generateContent API 格式,需要格式转换
|
||||
export type ClaudeApiFormat =
|
||||
| "anthropic"
|
||||
| "openai_chat"
|
||||
| "openai_responses"
|
||||
| "gemini_native";
|
||||
export type ClaudeApiFormat = "anthropic" | "openai_chat" | "openai_responses";
|
||||
|
||||
// Claude 认证字段类型
|
||||
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
||||
|
||||
+8
-2
@@ -121,12 +121,18 @@ export interface ProviderLimitStatus {
|
||||
monthlyExceeded: boolean;
|
||||
}
|
||||
|
||||
export type TimeRange = "1d" | "7d" | "30d";
|
||||
export type UsageRangePreset = "today" | "1d" | "7d" | "14d" | "30d" | "custom";
|
||||
|
||||
export interface UsageRangeSelection {
|
||||
preset: UsageRangePreset;
|
||||
customStartDate?: number;
|
||||
customEndDate?: number;
|
||||
}
|
||||
|
||||
export type AppTypeFilter = "all" | "claude" | "codex" | "gemini";
|
||||
|
||||
export interface StatsFilters {
|
||||
timeRange: TimeRange;
|
||||
timeRange: UsageRangePreset;
|
||||
providerId?: string;
|
||||
appType?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user