Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions crates/token_proxy_core/src/proxy/request_detail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::request_body::ReplayableBody;

const BODY_TOO_LARGE_MESSAGE: &str = "[body omitted: too large]";
const REQUEST_DETAIL_CAPTURE_WINDOW: Duration = Duration::from_secs(30);
const DEFAULT_CAPTURE_WINDOW_SECS: u64 = 600; // 10 minutes
const PERMANENT_WINDOW_SECS: u64 = 0;
const DISARMED_AT_MS: u64 = 0;

#[derive(Clone, Default)]
Expand All @@ -21,26 +22,31 @@ pub struct RequestDetailSnapshot {
pub struct RequestDetailCaptureState {
pub enabled: bool,
pub expires_at_ms: Option<u64>,
pub is_permanent: bool,
}

impl RequestDetailCaptureState {
pub fn idle() -> Self {
Self {
enabled: false,
expires_at_ms: None,
is_permanent: false,
}
}

fn active(expires_at_ms: u64) -> Self {
fn active(expires_at_ms: u64, is_permanent: bool) -> Self {
Self {
enabled: true,
expires_at_ms: Some(expires_at_ms),
is_permanent,
}
}
}

pub struct RequestDetailCapture {
#[allow(dead_code)]
expires_at_ms: AtomicU64,
#[allow(dead_code)]
window_ms: u64,
now_ms: Arc<dyn Fn() -> u64 + Send + Sync>,
on_change: Option<Arc<dyn Fn(RequestDetailCaptureState) + Send + Sync>>,
Expand All @@ -50,7 +56,7 @@ impl RequestDetailCapture {
pub fn new(on_change: Option<Arc<dyn Fn(RequestDetailCaptureState) + Send + Sync>>) -> Self {
Self {
expires_at_ms: AtomicU64::new(DISARMED_AT_MS),
window_ms: duration_to_millis(REQUEST_DETAIL_CAPTURE_WINDOW),
window_ms: duration_to_millis(Duration::from_secs(DEFAULT_CAPTURE_WINDOW_SECS)),
now_ms: Arc::new(current_time_millis),
on_change,
}
Expand All @@ -71,8 +77,21 @@ impl RequestDetailCapture {
}

pub fn arm(&self) -> RequestDetailCaptureState {
let expires_at_ms = (self.now_ms)().saturating_add(self.window_ms);
let state = RequestDetailCaptureState::active(expires_at_ms);
self.arm_with_window_secs(DEFAULT_CAPTURE_WINDOW_SECS)
}

pub fn arm_permanent(&self) -> RequestDetailCaptureState {
self.arm_with_window_secs(PERMANENT_WINDOW_SECS)
}

fn arm_with_window_secs(&self, window_secs: u64) -> RequestDetailCaptureState {
let window_ms = if window_secs == 0 {
u64::MAX
} else {
duration_to_millis(Duration::from_secs(window_secs))
};
let expires_at_ms = (self.now_ms)().saturating_add(window_ms);
let state = RequestDetailCaptureState::active(expires_at_ms, window_secs == 0);
self.expires_at_ms.store(expires_at_ms, Ordering::SeqCst);
self.notify(state);
state
Expand Down Expand Up @@ -100,8 +119,9 @@ impl RequestDetailCapture {
return RequestDetailCaptureState::idle();
}

if (self.now_ms)() <= expires_at_ms {
return RequestDetailCaptureState::active(expires_at_ms);
let is_permanent = expires_at_ms == u64::MAX;
if is_permanent || (self.now_ms)() <= expires_at_ms {
return RequestDetailCaptureState::active(expires_at_ms, is_permanent);
}

// 窗口过期后仅第一个观察者负责清空并广播关闭,避免并发重复通知。
Expand Down
14 changes: 11 additions & 3 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -393,9 +393,17 @@
"logs_timing_upstream_first_body_chunk_ms": "Upstream first body chunk (ms)",
"logs_timing_first_client_flush_ms": "First client flush (ms)",
"logs_timing_first_output_ms": "First output (ms)",
"logs_capture_title": "Capture request details for 30 seconds",
"logs_capture_desc": "When enabled, captures request headers/bodies during a 30-second window; error responses are always recorded. Turns off automatically when the window ends.",
"logs_capture_status_ready": "Enabled: capture request details during the 30-second window",
"logs_capture_title": "Capture request details",
"logs_capture_desc": "When enabled, captures request headers/bodies for 10 minutes; error responses are always recorded.",
"logs_capture_idle_desc": "Select a mode (10 min or Permanent), then click the power button to start capturing.",
"logs_capture_permanent_desc": "Captures request headers/bodies permanently until disabled; error responses are always recorded.",
"logs_capture_mode_temporary": "10 min",
"logs_capture_mode_permanent": "Permanent",
"logs_capture_start": "Start capture",
"logs_capture_start_temporary": "Start capturing for 10 minutes",
"logs_capture_start_permanent": "Start permanent capture",
"logs_capture_stop": "Stop capture",
"logs_capture_status_ready": "Enabled: capture request details for 10 minutes",
"logs_capture_status_idle": "Request details are not captured by default",
"logs_capture_status_countdown": "{seconds}s left",
"logs_detail_title": "Request details",
Expand Down
14 changes: 11 additions & 3 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,17 @@
"logs_timing_upstream_first_body_chunk_ms": "上游首响应体 chunk (ms)",
"logs_timing_first_client_flush_ms": "首次客户端 flush (ms)",
"logs_timing_first_output_ms": "首输出 (ms)",
"logs_capture_title": "记录 30 秒内请求详情",
"logs_capture_desc": "开启后会在 30 秒窗口内记录请求的 header/body;错误响应始终记录,到时自动关闭。",
"logs_capture_status_ready": "已开启:30 秒窗口内记录详情",
"logs_capture_title": "记录请求详情",
"logs_capture_desc": "开启后记录 10 分钟内的请求 header/body;错误响应始终记录。",
"logs_capture_idle_desc": "选择一个模式(10 分钟或永久),然后点击电源按钮开始记录。",
"logs_capture_permanent_desc": "永久记录请求详情直到手动关闭;错误响应始终记录。",
"logs_capture_mode_temporary": "10 分钟",
"logs_capture_mode_permanent": "永久",
"logs_capture_start": "开始记录",
"logs_capture_start_temporary": "开始记录 10 分钟",
"logs_capture_start_permanent": "开始永久记录",
"logs_capture_stop": "停止记录",
"logs_capture_status_ready": "已开启:10 分钟窗口内记录详情",
"logs_capture_status_idle": "默认不记录请求详情",
"logs_capture_status_countdown": "剩余 {seconds}s",
"logs_detail_title": "请求详情",
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/src/commands/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@ pub fn read_request_detail_capture(
pub fn set_request_detail_capture(
capture_state: tauri::State<'_, Arc<proxy::request_detail::RequestDetailCapture>>,
enabled: bool,
permanent: bool,
) -> proxy::request_detail::RequestDetailCaptureState {
if enabled {
capture_state.arm()
if permanent {
capture_state.arm_permanent()
} else {
capture_state.arm()
}
} else {
capture_state.disarm()
}
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ pub use pricing::{
};
pub use providers::{providers_delete_accounts, providers_list_accounts_page};
pub use proxy::{
prepare_relaunch, proxy_reload, proxy_restart, proxy_start, proxy_status, proxy_stop,
fetch_upstream_models, prepare_relaunch, proxy_reload, proxy_restart, proxy_start,
proxy_status, proxy_stop,
};

#[derive(Clone, Copy)]
Expand Down
72 changes: 72 additions & 0 deletions src-tauri/src/commands/proxy.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,78 @@
use crate::{proxy, tray};
use tauri::Manager;

#[tauri::command]
pub async fn fetch_upstream_models(
provider: String,
base_url: String,
api_key: String,
) -> Result<Vec<String>, String> {
let url = match provider.as_str() {
"openai" | "openai-response" => format!("{}/v1/models", base_url.trim_end_matches('/')),
"anthropic" => format!("{}/v1/models", base_url.trim_end_matches('/')),
"gemini" => format!("{}/v1beta/models", base_url.trim_end_matches('/')),
_ => {
return Err(format!("不支持的 provider: {}", provider));
}
};

let client = reqwest::Client::new();
let mut request = client.get(&url);
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {}", api_key));
}

let response = request
.send()
.await
.map_err(|err| format!("请求失败: {}", err))?;

if !response.status().is_success() {
return Err(format!("返回错误: {}", response.status()));
}

let body: serde_json::Value = response
.json()
.await
.map_err(|err| format!("解析失败: {}", err))?;

let mut models: Vec<String> = Vec::new();

if let Some(data) = body.get("data").and_then(|v| v.as_array()) {
for item in data {
if let Some(id) = item.get("id").and_then(|v| v.as_str()) {
models.push(id.to_string());
}
}
}

if models.is_empty() {
if let Some(m) = body.get("models").and_then(|v| v.as_array()) {
for item in m {
if let Some(id) = item.get("id").and_then(|v| v.as_str()) {
models.push(id.to_string());
}
}
}
}

if models.is_empty() {
if let Some(m) = body.get("modelNames").and_then(|v| v.as_array()) {
for item in m {
if let Some(id) = item.as_str() {
models.push(id.to_string());
}
}
}
}

if models.is_empty() {
return Err(format!("返回为空,body: {}", body));
}

Ok(models)
}

#[tauri::command]
pub async fn proxy_status(
proxy_service: tauri::State<'_, proxy::service::ProxyServiceHandle>,
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use commands::{
read_model_pricing_settings, read_proxy_config, read_request_detail_capture,
read_request_log_detail, refresh_dashboard_model_discovery, reset_model_pricing_settings,
save_model_pricing_settings, save_proxy_config, set_request_detail_capture,
write_claude_code_settings, write_codex_config, write_opencode_config,
write_claude_code_settings, write_codex_config, write_opencode_config,fetch_upstream_models,
};

type ProxyServiceHandle = proxy::service::ProxyServiceHandle;
Expand Down Expand Up @@ -262,6 +262,7 @@ pub fn run() {
prepare_relaunch,
proxy_restart,
proxy_reload,
fetch_upstream_models,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
Expand Down
74 changes: 73 additions & 1 deletion src/features/config/cards/upstreams/editor-dialog-form.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CirclePlus, HelpCircle } from "lucide-react";
import { CirclePlus, HelpCircle, Loader2, RefreshCw } from "lucide-react";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand Down Expand Up @@ -27,6 +28,7 @@ import type {
UpstreamForm,
} from "@/features/config/types";
import { m } from "@/paraglide/messages.js";
import { invoke } from "@tauri-apps/api/core";

const KIRO_ENDPOINT_INHERIT = "inherit";

Expand Down Expand Up @@ -394,10 +396,61 @@ function UpstreamModelMappingFields({
draft,
onChangeDraft,
}: UpstreamModelMappingFieldsProps) {
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState("");

const handleAdd = () => {
onChangeDraft({ modelMappings: [...draft.modelMappings, createModelMapping()] });
};

const provider = draft.providers[0]?.trim() || "";
const baseUrl = draft.baseUrl;
const apiKey = draft.apiKeys.trim();

const canFetch =
(provider === "openai" ||
provider === "openai-response" ||
provider === "anthropic" ||
provider === "gemini") &&
!!baseUrl;

const handleFetchModels = async () => {
if (!baseUrl) {
setFetchError("请填写 Base URL");
return;
}
setFetching(true);
setFetchError("");
try {
const models = await invoke<string[]>("fetch_upstream_models", {
provider,
baseUrl,
apiKey,
});
if (models.length === 0) {
setFetchError("未获取到模型");
return;
}
const existingTargets = new Set(
draft.modelMappings.map((m) => m.target).filter(Boolean)
);
const newMappings = models
.filter((model) => !existingTargets.has(model))
.map((model) => ({
id: `mapping-${Date.now()}-${Math.random().toString(36).slice(2)}`,
pattern: model,
target: model,
}));
onChangeDraft({
modelMappings: [...draft.modelMappings, ...newMappings],
});
} catch (error) {
setFetchError(String(error));
} finally {
setFetching(false);
}
};

return (
<div data-slot="upstream-model-mapping-fields" className="col-span-2 space-y-2">
<div className="flex items-center gap-2">
Expand All @@ -412,6 +465,22 @@ function UpstreamModelMappingFields({
</TooltipContent>
</Tooltip>
</Label>
{canFetch && (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="从上游获取模型列表"
onClick={handleFetchModels}
disabled={fetching}
>
{fetching ? (
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
) : (
<RefreshCw className="size-4" aria-hidden="true" />
)}
</Button>
)}
<Button
type="button"
variant="ghost"
Expand All @@ -422,6 +491,9 @@ function UpstreamModelMappingFields({
<CirclePlus className="size-4" aria-hidden="true" />
</Button>
</div>
{fetchError && (
<p className="text-xs text-destructive">{fetchError}</p>
)}
{draft.modelMappings.length > 0 ? (
<ModelMappingsEditor
mappings={draft.modelMappings}
Expand Down
Loading