Merge pull request #72 from router-for-me/dev

Dev
This commit is contained in:
lzt404 authored and GitHub committed 2026-07-29 19:49:55 +08:00
commit 79dabbb022
18 files changed
+4974 -1510

No files matched your search

+15
View File
@@ -3,6 +3,21 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
(() => {
try {
const savedTheme = localStorage.getItem('easy-cli-proxy-api.theme');
const theme = savedTheme === 'light' || savedTheme === 'dark'
? savedTheme
: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.style.backgroundColor = theme === 'dark' ? '#101419' : '#faf9f5';
} catch {
// The app applies the theme again during initialization.
}
})();
</script>
<title>EasyCLIProxyAPI</title>
</head>
<body>
+7 -1
View File
@@ -3,5 +3,11 @@
"identifier": "default",
"description": "Default permissions for the main EasyCLIProxyAPI window.",
"windows": ["main"],
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:window:allow-set-theme",
"core:window:allow-set-background-color",
"core:webview:allow-set-webview-background-color"
]
}
File diff suppressed because it is too large. Load diff
+63
View File
@@ -0,0 +1,63 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-28",
"models": {
"gpt-5.6": { "inputPer1M": 5.0, "outputPer1M": 30.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 },
"gpt-5.6-sol": { "inputPer1M": 5.0, "outputPer1M": 30.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 },
"gpt-5.6-terra": { "inputPer1M": 2.5, "outputPer1M": 15.0, "cacheReadPer1M": 0.25, "cacheCreationPer1M": 3.125 },
"gpt-5.6-luna": { "inputPer1M": 1.0, "outputPer1M": 6.0, "cacheReadPer1M": 0.1, "cacheCreationPer1M": 1.25 },
"gpt-5.5": { "inputPer1M": 5.0, "outputPer1M": 30.0, "cacheReadPer1M": 0.5 },
"gpt-5.5-pro": { "inputPer1M": 30.0, "outputPer1M": 180.0, "cacheReadPer1M": 3.0 },
"gpt-5.4": { "inputPer1M": 2.5, "outputPer1M": 15.0, "cacheReadPer1M": 0.25 },
"gpt-5.4-pro": { "inputPer1M": 30.0, "outputPer1M": 180.0, "cacheReadPer1M": 3.0 },
"gpt-5.4-mini": { "inputPer1M": 0.75, "outputPer1M": 4.5, "cacheReadPer1M": 0.075 },
"gpt-5.4-nano": { "inputPer1M": 0.2, "outputPer1M": 1.25, "cacheReadPer1M": 0.02 },
"gpt-5.3-codex": { "inputPer1M": 1.75, "outputPer1M": 14.0, "cacheReadPer1M": 0.175 },
"gpt-5.2": { "inputPer1M": 1.75, "outputPer1M": 14.0, "cacheReadPer1M": 0.175 },
"gpt-5.2-pro": { "inputPer1M": 21.0, "outputPer1M": 168.0 },
"gpt-5.2-codex": { "inputPer1M": 1.75, "outputPer1M": 14.0, "cacheReadPer1M": 0.175 },
"gpt-5.1": { "inputPer1M": 1.25, "outputPer1M": 10.0, "cacheReadPer1M": 0.125 },
"gpt-5.1-codex": { "inputPer1M": 1.25, "outputPer1M": 10.0, "cacheReadPer1M": 0.125 },
"gpt-5.1-codex-mini": { "inputPer1M": 0.25, "outputPer1M": 2.0, "cacheReadPer1M": 0.025 },
"gpt-5": { "inputPer1M": 1.25, "outputPer1M": 10.0, "cacheReadPer1M": 0.125 },
"gpt-5-pro": { "inputPer1M": 15.0, "outputPer1M": 120.0 },
"gpt-5-mini": { "inputPer1M": 0.25, "outputPer1M": 2.0, "cacheReadPer1M": 0.025 },
"gpt-5-nano": { "inputPer1M": 0.05, "outputPer1M": 0.4, "cacheReadPer1M": 0.005 },
"gpt-4.1": { "inputPer1M": 2.0, "outputPer1M": 8.0, "cacheReadPer1M": 0.5 },
"gpt-4.1-mini": { "inputPer1M": 0.4, "outputPer1M": 1.6, "cacheReadPer1M": 0.1 },
"gpt-4.1-nano": { "inputPer1M": 0.1, "outputPer1M": 0.4, "cacheReadPer1M": 0.025 },
"o1": { "inputPer1M": 15.0, "outputPer1M": 60.0, "cacheReadPer1M": 7.5 },
"o1-pro": { "inputPer1M": 150.0, "outputPer1M": 600.0 },
"o3": { "inputPer1M": 2.0, "outputPer1M": 8.0, "cacheReadPer1M": 0.5 },
"o3-mini": { "inputPer1M": 1.1, "outputPer1M": 4.4, "cacheReadPer1M": 0.55 },
"o3-pro": { "inputPer1M": 20.0, "outputPer1M": 80.0 },
"o4-mini": { "inputPer1M": 1.1, "outputPer1M": 4.4, "cacheReadPer1M": 0.275 },
"claude-opus-4-8": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 },
"claude-opus-4-7": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 },
"claude-opus-4-6": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 },
"claude-opus-4-5": { "inputPer1M": 5.0, "outputPer1M": 25.0, "cacheReadPer1M": 0.5, "cacheCreationPer1M": 6.25 },
"claude-opus-4-1": { "inputPer1M": 15.0, "outputPer1M": 75.0, "cacheReadPer1M": 1.5, "cacheCreationPer1M": 18.75 },
"claude-sonnet-4-6": { "inputPer1M": 3.0, "outputPer1M": 15.0, "cacheReadPer1M": 0.3, "cacheCreationPer1M": 3.75 },
"claude-sonnet-4-5": { "inputPer1M": 3.0, "outputPer1M": 15.0, "cacheReadPer1M": 0.3, "cacheCreationPer1M": 3.75 },
"claude-sonnet-4": { "inputPer1M": 3.0, "outputPer1M": 15.0, "cacheReadPer1M": 0.3, "cacheCreationPer1M": 3.75 },
"claude-haiku-4-5": { "inputPer1M": 1.0, "outputPer1M": 5.0, "cacheReadPer1M": 0.1, "cacheCreationPer1M": 1.25 },
"gemini-3.6-flash": { "inputPer1M": 1.5, "outputPer1M": 7.5, "cacheReadPer1M": 0.15 },
"gemini-3.5-flash": { "inputPer1M": 1.5, "outputPer1M": 9.0, "cacheReadPer1M": 0.15 },
"gemini-3.1-pro-preview": { "inputPer1M": 2.0, "outputPer1M": 12.0, "cacheReadPer1M": 0.2 },
"gemini-3.1-flash-lite": { "inputPer1M": 0.25, "outputPer1M": 1.5, "cacheReadPer1M": 0.025 },
"gemini-3-pro-preview": { "inputPer1M": 2.0, "outputPer1M": 12.0, "cacheReadPer1M": 0.2 },
"gemini-3-flash-preview": { "inputPer1M": 0.5, "outputPer1M": 3.0, "cacheReadPer1M": 0.05 },
"gemini-2.5-pro": { "inputPer1M": 1.25, "outputPer1M": 10.0, "cacheReadPer1M": 0.125 },
"gemini-2.5-flash": { "inputPer1M": 0.3, "outputPer1M": 2.5, "cacheReadPer1M": 0.03 },
"gemini-2.5-flash-lite": { "inputPer1M": 0.1, "outputPer1M": 0.4, "cacheReadPer1M": 0.01 },
"deepseek-chat": { "inputPer1M": 0.28, "outputPer1M": 0.42, "cacheReadPer1M": 0.028 },
"deepseek-reasoner": { "inputPer1M": 0.28, "outputPer1M": 0.42, "cacheReadPer1M": 0.028 },
"grok-4.5": { "inputPer1M": 2.0, "outputPer1M": 6.0, "cacheReadPer1M": 0.5 },
"grok-4.3": { "inputPer1M": 1.25, "outputPer1M": 2.5, "cacheReadPer1M": 0.2 },
"grok-4.20": { "inputPer1M": 2.0, "outputPer1M": 6.0, "cacheReadPer1M": 0.2 },
"grok-4": { "inputPer1M": 3.0, "outputPer1M": 15.0 },
"grok-4-1-fast": { "inputPer1M": 0.2, "outputPer1M": 0.5, "cacheReadPer1M": 0.05 },
"grok-3": { "inputPer1M": 3.0, "outputPer1M": 15.0, "cacheReadPer1M": 0.75 },
"grok-3-mini": { "inputPer1M": 0.3, "outputPer1M": 0.5, "cacheReadPer1M": 0.075 }
}
}
+896
View File
@@ -0,0 +1,896 @@
use crate::AgentModelOption;
use serde_json::{Map, Value};
use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
const MODEL_CATALOG_JSON: &str = include_str!("../resources/codex_models/model-catalog.json");
static SOURCES: OnceLock<Result<CatalogSources, String>> = OnceLock::new();
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CodexRuntimeModel {
pub(crate) slug: String,
display_name: Option<String>,
description: Option<String>,
context_window: Option<u64>,
max_context_window: Option<u64>,
input_modalities: Option<Vec<String>>,
supported_reasoning_levels: Option<Vec<String>>,
default_reasoning_level: Option<String>,
hidden: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PreparedCodexCatalog {
pub(crate) models: Vec<AgentModelOption>,
pub(crate) json: String,
}
#[derive(Clone, Debug)]
struct CatalogSources {
fallback: Map<String, Value>,
templates: HashMap<String, Template>,
max_template_priority: i64,
}
#[derive(Clone, Debug)]
struct Template {
value: Map<String, Value>,
order: usize,
}
#[derive(Clone, Debug)]
struct CatalogEntry {
value: Map<String, Value>,
template_order: Option<usize>,
}
pub(crate) fn validate_embedded_catalog() -> Result<(), String> {
sources().map(|_| ())
}
pub(crate) fn parse_runtime_models(payload: &Value) -> Result<Vec<CodexRuntimeModel>, String> {
let values = payload
.get("models")
.and_then(Value::as_array)
.or_else(|| payload.get("data").and_then(Value::as_array))
.or_else(|| payload.as_array())
.ok_or_else(|| "Codex 模型列表响应缺少 models 或 data 数组".to_string())?;
let mut seen = HashSet::with_capacity(values.len());
let mut models = Vec::with_capacity(values.len());
for value in values {
let slug = if let Some(value) = value.as_str() {
value.trim()
} else {
["slug", "id", "name", "model", "value"]
.into_iter()
.filter_map(|key| value.get(key).and_then(Value::as_str).map(str::trim))
.find(|value| !value.is_empty())
.unwrap_or_default()
};
if slug.is_empty() || !seen.insert(normalize_id(slug)) {
continue;
}
let display_name = optional_string(value, &["display_name", "displayName", "alias"]);
let description = optional_string(value, &["description"]);
let context_window = positive_u64_field(
value,
&[
"context_window",
"contextWindow",
"context_length",
"contextLength",
],
);
let max_context_window =
positive_u64_field(value, &["max_context_window", "maxContextWindow"]);
let input_modalities = parse_modalities(value);
let supported_reasoning_levels = parse_reasoning_levels(value);
let default_reasoning_level =
optional_string(value, &["default_reasoning_level", "defaultReasoningLevel"])
.map(|value| value.to_ascii_lowercase())
.filter(|value| is_allowed_reasoning_level(value));
let hidden = optional_string(value, &["visibility"])
.is_some_and(|value| value.eq_ignore_ascii_case("hide"));
models.push(CodexRuntimeModel {
slug: slug.to_string(),
display_name,
description,
context_window,
max_context_window,
input_modalities,
supported_reasoning_levels,
default_reasoning_level,
hidden,
});
}
Ok(models)
}
pub(crate) fn prepare_catalog(
runtime_models: &[CodexRuntimeModel],
) -> Result<PreparedCodexCatalog, String> {
prepare_catalog_with_sources(runtime_models, sources()?)
}
fn sources() -> Result<&'static CatalogSources, String> {
SOURCES
.get_or_init(|| parse_sources(MODEL_CATALOG_JSON))
.as_ref()
.map_err(Clone::clone)
}
fn parse_sources(catalog_json: &str) -> Result<CatalogSources, String> {
let root: Value = serde_json::from_str(catalog_json)
.map_err(|error| format!("解析内置 model-catalog.json 失败: {error}"))?;
let root = root
.as_object()
.ok_or_else(|| "内置 model-catalog.json 根节点必须是对象".to_string())?;
let fallback = root
.get("fallback_model")
.and_then(Value::as_object)
.cloned()
.ok_or_else(|| "内置 model-catalog.json 缺少 fallback_model 对象".to_string())?;
validate_model(&fallback, "fallback_model", false)?;
let values = root
.get("models")
.and_then(Value::as_array)
.filter(|models| !models.is_empty())
.ok_or_else(|| "内置 model-catalog.json 必须包含非空 models 数组".to_string())?;
let mut templates = HashMap::with_capacity(values.len());
let mut max_template_priority = 0;
for (order, value) in values.iter().enumerate() {
let model = value
.as_object()
.cloned()
.ok_or_else(|| format!("内置 model-catalog.json 第 {} 个模型必须是对象", order + 1))?;
validate_model(&model, &format!("第 {} 个正式模板", order + 1), true)?;
let slug = string_value(&model, "slug");
let key = normalize_id(&slug);
if templates.contains_key(&key) {
return Err(format!(
"内置 model-catalog.json 模型 slug 大小写重复: {slug}"
));
}
max_template_priority = max_template_priority.max(priority_value(&model));
templates.insert(
key,
Template {
value: model,
order,
},
);
}
Ok(CatalogSources {
fallback,
templates,
max_template_priority,
})
}
fn validate_model(
model: &Map<String, Value>,
label: &str,
require_slug: bool,
) -> Result<(), String> {
if require_slug && string_value(model, "slug").is_empty() {
return Err(format!("内置 model-catalog.json {label} 的 slug 不能为空"));
}
if string_value(model, "base_instructions").is_empty() {
return Err(format!(
"内置 model-catalog.json {label} 的 base_instructions 不能为空"
));
}
validate_required_codex_fields(model, label)?;
let context_window = positive_u64_value(model.get("context_window"))
.ok_or_else(|| format!("内置 model-catalog.json {label} 的 context_window 必须为正数"))?;
let max_context_window =
positive_u64_value(model.get("max_context_window")).ok_or_else(|| {
format!("内置 model-catalog.json {label} 的 max_context_window 必须为正数")
})?;
if max_context_window < context_window {
return Err(format!(
"内置 model-catalog.json {label} 的 max_context_window 不能小于 context_window"
));
}
Ok(())
}
fn validate_required_codex_fields(model: &Map<String, Value>, label: &str) -> Result<(), String> {
for field in ["display_name", "shell_type", "visibility"] {
if string_value(model, field).is_empty() {
return Err(format!(
"Codex 模型 {label} 的必填字段 {field} 必须是非空字符串"
));
}
}
for field in ["supported_reasoning_levels", "experimental_supported_tools"] {
if !model.get(field).is_some_and(Value::is_array) {
return Err(format!("Codex 模型 {label} 的必填字段 {field} 必须是数组"));
}
}
for field in [
"supported_in_api",
"support_verbosity",
"supports_parallel_tool_calls",
] {
if !model.get(field).is_some_and(Value::is_boolean) {
return Err(format!(
"Codex 模型 {label} 的必填字段 {field} 必须是布尔值"
));
}
}
if !model
.get("priority")
.is_some_and(|value| value.as_i64().is_some() || value.as_u64().is_some())
{
return Err(format!("Codex 模型 {label} 的必填字段 priority 必须是整数"));
}
if !model
.get("default_reasoning_summary")
.is_some_and(Value::is_string)
{
return Err(format!(
"Codex 模型 {label} 的必填字段 default_reasoning_summary 必须是字符串"
));
}
let truncation = model
.get("truncation_policy")
.and_then(Value::as_object)
.ok_or_else(|| format!("Codex 模型 {label} 缺少 truncation_policy 对象"))?;
if string_value(truncation, "mode").is_empty()
|| !truncation
.get("limit")
.is_some_and(|value| value.as_i64().is_some() || value.as_u64().is_some())
{
return Err(format!("Codex 模型 {label} 的 truncation_policy 无效"));
}
Ok(())
}
fn prepare_catalog_with_sources(
runtime_models: &[CodexRuntimeModel],
sources: &CatalogSources,
) -> Result<PreparedCodexCatalog, String> {
if runtime_models.is_empty() {
return Err("CPA 当前没有可写入 Codex 的模型".to_string());
}
let mut entries = Vec::with_capacity(runtime_models.len());
for runtime in runtime_models {
let key = normalize_id(&runtime.slug);
if key.is_empty() {
continue;
}
if let Some(template) = sources.templates.get(&key) {
let mut value = template.value.clone();
value.insert("slug".to_string(), Value::String(runtime.slug.clone()));
entries.push(CatalogEntry {
value,
template_order: Some(template.order),
});
} else {
let mut value = sources.fallback.clone();
apply_runtime_metadata(&mut value, runtime);
disable_fallback_capabilities(&mut value);
entries.push(CatalogEntry {
value,
template_order: None,
});
}
}
if entries.is_empty() {
return Err("CPA 当前没有有效的 Codex 模型 ID".to_string());
}
for entry in &entries {
let slug = string_value(&entry.value, "slug");
validate_required_codex_fields(&entry.value, &slug)?;
}
let mut fallback_indexes = entries
.iter()
.enumerate()
.filter(|(_, entry)| entry.template_order.is_none())
.map(|(index, entry)| {
(
index,
string_value(&entry.value, "display_name").to_ascii_lowercase(),
string_value(&entry.value, "slug").to_ascii_lowercase(),
)
})
.collect::<Vec<_>>();
fallback_indexes.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.2.cmp(&right.2)));
for (rank, (index, _, _)) in fallback_indexes.into_iter().enumerate() {
entries[index].value.insert(
"priority".to_string(),
Value::Number((sources.max_template_priority + 100 * (rank as i64 + 1)).into()),
);
}
entries.sort_by(
|left, right| match (left.template_order, right.template_order) {
(Some(left_order), Some(right_order)) => priority_value(&left.value)
.cmp(&priority_value(&right.value))
.then_with(|| left_order.cmp(&right_order)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => priority_value(&left.value).cmp(&priority_value(&right.value)),
},
);
let models = entries
.iter()
.map(|entry| AgentModelOption {
name: string_value(&entry.value, "slug"),
alias: optional_map_string(&entry.value, "display_name").filter(|display| {
!display.eq_ignore_ascii_case(&string_value(&entry.value, "slug"))
}),
})
.collect::<Vec<_>>();
let values = entries
.into_iter()
.map(|entry| Value::Object(entry.value))
.collect::<Vec<_>>();
let mut json = serde_json::to_string_pretty(&serde_json::json!({ "models": values }))
.map_err(|error| format!("生成 Codex 模型目录失败: {error}"))?;
json.push('\n');
Ok(PreparedCodexCatalog { models, json })
}
fn apply_runtime_metadata(model: &mut Map<String, Value>, runtime: &CodexRuntimeModel) {
model.insert("slug".to_string(), Value::String(runtime.slug.clone()));
model.insert(
"display_name".to_string(),
Value::String(
runtime
.display_name
.clone()
.unwrap_or_else(|| runtime.slug.clone()),
),
);
model.insert("visibility".to_string(), Value::String("list".to_string()));
if let Some(description) = runtime.description.as_ref() {
model.insert(
"description".to_string(),
Value::String(description.clone()),
);
}
if let Some(context_window) = runtime.context_window {
let max_context_window = runtime
.max_context_window
.unwrap_or(context_window)
.max(context_window);
model.insert(
"context_window".to_string(),
Value::Number(context_window.into()),
);
model.insert(
"max_context_window".to_string(),
Value::Number(max_context_window.into()),
);
} else if let Some(max_context_window) = runtime.max_context_window {
let context_window = positive_u64_value(model.get("context_window")).unwrap_or(128_000);
model.insert(
"max_context_window".to_string(),
Value::Number(max_context_window.max(context_window).into()),
);
}
if let Some(modalities) = runtime.input_modalities.as_ref() {
model.insert(
"input_modalities".to_string(),
Value::Array(modalities.iter().cloned().map(Value::String).collect()),
);
model.insert(
"supports_image_detail_original".to_string(),
Value::Bool(modalities.iter().any(|value| value == "image")),
);
}
apply_runtime_reasoning(model, runtime);
if runtime.hidden {
model.insert("visibility".to_string(), Value::String("hide".to_string()));
}
}
fn apply_runtime_reasoning(model: &mut Map<String, Value>, runtime: &CodexRuntimeModel) {
let Some(levels) = runtime.supported_reasoning_levels.as_ref() else {
if let Some(default) = runtime.default_reasoning_level.as_ref() {
let fallback_levels = reasoning_efforts(model);
if fallback_levels.contains(default) {
model.insert(
"default_reasoning_level".to_string(),
Value::String(default.clone()),
);
}
}
return;
};
let fallback_default = optional_map_string(model, "default_reasoning_level")
.map(|value| value.to_ascii_lowercase());
let default = runtime
.default_reasoning_level
.as_ref()
.filter(|default| levels.contains(default))
.cloned()
.or_else(|| fallback_default.filter(|default| levels.contains(default)));
let Some(default) = default else {
return;
};
model.insert(
"supported_reasoning_levels".to_string(),
Value::Array(
levels
.iter()
.map(|effort| {
serde_json::json!({
"effort": effort,
"description": reasoning_description(effort),
})
})
.collect(),
),
);
model.insert(
"default_reasoning_level".to_string(),
Value::String(default),
);
}
fn disable_fallback_capabilities(model: &mut Map<String, Value>) {
model.insert("prefer_websockets".to_string(), Value::Bool(false));
model.insert("supports_search_tool".to_string(), Value::Bool(false));
model.insert(
"web_search_tool_type".to_string(),
Value::String("text".to_string()),
);
model.insert("service_tiers".to_string(), Value::Array(Vec::new()));
model.insert(
"additional_speed_tiers".to_string(),
Value::Array(Vec::new()),
);
model.insert("default_service_tier".to_string(), Value::Null);
model.insert("upgrade".to_string(), Value::Null);
model.insert("availability_nux".to_string(), Value::Null);
model.remove("minimal_client_version");
}
fn parse_modalities(value: &Value) -> Option<Vec<String>> {
let raw = value
.get("input_modalities")
.or_else(|| value.get("inputModalities"))
.or_else(|| value.get("supported_input_modalities"))
.and_then(Value::as_array)?;
let mut seen = HashSet::new();
let modalities = raw
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
.filter(|value| matches!(value.as_str(), "text" | "image"))
.filter(|value| seen.insert(value.clone()))
.collect::<Vec<_>>();
(!modalities.is_empty()).then_some(modalities)
}
fn parse_reasoning_levels(value: &Value) -> Option<Vec<String>> {
let raw = value
.get("supported_reasoning_levels")
.or_else(|| value.get("supportedReasoningLevels"))
.and_then(Value::as_array)
.or_else(|| {
value
.get("thinking")
.and_then(|thinking| thinking.get("levels"))
.and_then(Value::as_array)
})?;
let mut seen = HashSet::new();
let levels = raw
.iter()
.filter_map(|level| {
level
.as_str()
.or_else(|| level.get("effort").and_then(Value::as_str))
})
.map(str::trim)
.map(str::to_ascii_lowercase)
.filter(|level| is_allowed_reasoning_level(level))
.filter(|level| seen.insert(level.clone()))
.collect::<Vec<_>>();
(!levels.is_empty()).then_some(levels)
}
fn reasoning_efforts(model: &Map<String, Value>) -> Vec<String> {
model
.get("supported_reasoning_levels")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|level| level.get("effort").and_then(Value::as_str))
.map(str::to_ascii_lowercase)
.collect()
}
fn optional_string(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.filter_map(|key| value.get(*key).and_then(Value::as_str).map(str::trim))
.find(|value| !value.is_empty())
.map(str::to_string)
}
fn positive_u64_field(value: &Value, keys: &[&str]) -> Option<u64> {
keys.iter()
.find_map(|key| positive_u64_value(value.get(*key)))
}
fn positive_u64_value(value: Option<&Value>) -> Option<u64> {
value
.and_then(|value| {
value
.as_u64()
.or_else(|| value.as_str()?.trim().parse().ok())
})
.filter(|value| *value > 0)
}
fn optional_map_string(model: &Map<String, Value>, key: &str) -> Option<String> {
model
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn string_value(model: &Map<String, Value>, key: &str) -> String {
optional_map_string(model, key).unwrap_or_default()
}
fn priority_value(model: &Map<String, Value>) -> i64 {
model
.get("priority")
.and_then(|value| {
value
.as_i64()
.or_else(|| value.as_u64().map(|value| value as i64))
})
.unwrap_or(100)
}
fn normalize_id(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn is_allowed_reasoning_level(level: &str) -> bool {
matches!(
level,
"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra"
)
}
fn reasoning_description(level: &str) -> &'static str {
match level {
"none" => "No reasoning",
"minimal" => "Minimal reasoning",
"low" => "Fast responses with lighter reasoning",
"medium" => "Balances speed and reasoning depth for everyday tasks",
"high" => "Greater reasoning depth for complex problems",
"xhigh" => "Extra high reasoning depth for complex problems",
"max" => "Maximum available reasoning depth for complex problems",
"ultra" => "Highest available reasoning depth",
_ => "Model-supported reasoning level",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_sources() -> CatalogSources {
parse_sources(
r#"{
"fallback_model": {
"base_instructions": "You are Codex, a model-neutral coding agent.",
"display_name": "Compatible Model",
"description": "Conservative fallback",
"context_window": 128000,
"max_context_window": 128000,
"input_modalities": ["text"],
"default_reasoning_level": "medium",
"supported_reasoning_levels": [
{"effort":"low","description":"Low"},
{"effort":"medium","description":"Medium"},
{"effort":"high","description":"High"}
],
"shell_type": "shell_command",
"supported_in_api": true,
"default_reasoning_summary": "none",
"support_verbosity": false,
"truncation_policy": {"mode":"tokens","limit":10000},
"prefer_websockets": false,
"supports_search_tool": false,
"supports_parallel_tool_calls": true,
"experimental_supported_tools": [],
"service_tiers": [],
"additional_speed_tiers": [],
"visibility": "list",
"priority": 100
},
"models": [
{
"slug":"A",
"display_name":"Known A",
"base_instructions":"Known A",
"context_window":200000,
"max_context_window":200000,
"priority":10,
"supported_reasoning_levels":[],
"shell_type":"shell_command",
"visibility":"list",
"supported_in_api":true,
"default_reasoning_summary":"none",
"support_verbosity":false,
"truncation_policy":{"mode":"tokens","limit":10000},
"supports_parallel_tool_calls":true,
"experimental_supported_tools":[],
"service_tiers":[{"id":"priority"}],
"additional_speed_tiers":["fast"],
"nested":{"unknown":true}
},
{
"slug":"B",
"display_name":"Known B",
"base_instructions":"Known B",
"context_window":300000,
"max_context_window":300000,
"priority":20,
"supported_reasoning_levels":[],
"shell_type":"shell_command",
"visibility":"list",
"supported_in_api":true,
"default_reasoning_summary":"none",
"support_verbosity":false,
"truncation_policy":{"mode":"tokens","limit":10000},
"supports_parallel_tool_calls":true,
"experimental_supported_tools":[]
}
]
}"#,
)
.unwrap()
}
fn runtime(payload: Value) -> Vec<CodexRuntimeModel> {
parse_runtime_models(&payload).unwrap()
}
fn output_models(catalog: &PreparedCodexCatalog) -> Vec<Map<String, Value>> {
let root = serde_json::from_str::<Value>(&catalog.json).unwrap();
assert!(root.get("fallback_model").is_none());
root["models"]
.as_array()
.unwrap()
.iter()
.map(|value| value.as_object().unwrap().clone())
.collect()
}
#[test]
fn embedded_catalog_is_valid() {
validate_embedded_catalog().unwrap();
let sources = sources().unwrap();
assert_eq!(sources.templates.len(), 10);
let fallback_prompt = string_value(&sources.fallback, "base_instructions");
assert!(fallback_prompt.starts_with(
"You are a coding agent running in the Codex CLI, a terminal-based coding assistant."
));
assert!(!fallback_prompt.contains("GPT-5"));
assert_eq!(sources.fallback["context_window"], 272_000);
assert_eq!(sources.fallback["max_context_window"], 272_000);
assert_eq!(
sources.fallback["input_modalities"],
serde_json::json!(["text", "image"])
);
assert_eq!(sources.fallback["default_reasoning_level"], Value::Null);
assert!(reasoning_efforts(&sources.fallback).is_empty());
assert_eq!(sources.fallback["shell_type"], "default");
assert_eq!(sources.fallback["visibility"], "none");
assert_eq!(sources.fallback["default_reasoning_summary"], "auto");
assert_eq!(sources.fallback["supports_parallel_tool_calls"], false);
assert_eq!(sources.fallback["truncation_policy"]["mode"], "bytes");
let gpt = &sources.templates["gpt-5.6-sol"].value;
let gpt_prompt = string_value(gpt, "base_instructions");
let gpt_prompt_body = gpt_prompt.split_once("\n\n").unwrap().1;
let gpt_messages = gpt["model_messages"].as_object().unwrap();
let gpt_message_prompt = gpt_messages["instructions_template"].as_str().unwrap();
let gpt_message_prompt_body = gpt_message_prompt.split_once("\n\n").unwrap().1;
for slug in ["deepseek-v4-flash", "deepseek-v4-pro"] {
let model = &sources.templates[slug].value;
assert_eq!(model["context_window"], 1_000_000);
assert_eq!(model["max_context_window"], 1_000_000);
assert_eq!(model["default_reasoning_level"], "high");
assert_eq!(reasoning_efforts(model), ["high", "xhigh"]);
assert_eq!(model["input_modalities"], serde_json::json!(["text"]));
assert_eq!(model["supports_parallel_tool_calls"], false);
assert_eq!(model["supports_search_tool"], false);
assert_eq!(model["service_tiers"], serde_json::json!([]));
assert_eq!(model["additional_speed_tiers"], serde_json::json!([]));
let prompt = string_value(model, "base_instructions");
assert!(
prompt.starts_with("You are Codex, a coding agent powered by a DeepSeek model.")
);
assert_eq!(prompt.split_once("\n\n").unwrap().1, gpt_prompt_body);
let mut normalized_messages = model["model_messages"].as_object().unwrap().clone();
let message_prompt = normalized_messages["instructions_template"]
.as_str()
.unwrap();
assert!(message_prompt
.starts_with("You are Codex, a coding agent powered by a DeepSeek model."));
assert_eq!(
message_prompt.split_once("\n\n").unwrap().1,
gpt_message_prompt_body
);
normalized_messages.insert(
"instructions_template".to_string(),
Value::String(gpt_message_prompt.to_string()),
);
assert_eq!(&normalized_messages, gpt_messages);
}
}
#[test]
fn runtime_parser_prefers_models_and_keeps_first_duplicate() {
let models = runtime(serde_json::json!({
"models": [
{"slug":"Model-A","context_window":"200000"},
{"id":"model-a","context_window":1},
{"slug":" ","id":"Model-B"},
{"id":""}
],
"data": [{"id":"ignored"}]
}));
assert_eq!(models.len(), 2);
assert_eq!(models[0].slug, "Model-A");
assert_eq!(models[0].context_window, Some(200_000));
assert_eq!(models[1].slug, "Model-B");
}
#[test]
fn known_and_unknown_models_are_composed_from_the_correct_sources() {
let runtime = runtime(serde_json::json!({"models":[
{"id":"b","display_name":"Runtime B","context_window":1},
{"id":"C","display_name":"Runtime C","context_window":200000}
]}));
let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap();
let models = output_models(&catalog);
assert_eq!(
models
.iter()
.map(|model| string_value(model, "slug"))
.collect::<Vec<_>>(),
["b", "C"]
);
assert_eq!(models[0]["base_instructions"], "Known B");
assert_eq!(models[0]["context_window"], 300_000);
assert_eq!(
models[1]["base_instructions"],
"You are Codex, a model-neutral coding agent."
);
assert_eq!(models[1]["context_window"], 200_000);
assert_eq!(models[1]["max_context_window"], 200_000);
assert!(models[1]["priority"].as_i64().unwrap() > 20);
}
#[test]
fn known_template_is_preserved_except_for_runtime_slug() {
let sources = test_sources();
let runtime = runtime(serde_json::json!({"data":[{
"id":"a",
"display_name":"Overwrite",
"context_window":1,
"service_tiers":[]
}]}));
let catalog = prepare_catalog_with_sources(&runtime, &sources).unwrap();
let model = &output_models(&catalog)[0];
let template = &sources.templates["a"].value;
for (key, expected) in template {
if key != "slug" {
assert_eq!(model.get(key), Some(expected), "changed field {key}");
}
}
assert_eq!(model["slug"], "a");
assert_eq!(model["nested"]["unknown"], true);
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
}
#[test]
fn fallback_accepts_only_safe_valid_metadata() {
let runtime = runtime(serde_json::json!({"models":[{
"id":"C",
"display_name":"Third Party",
"description":"Detailed",
"context_window":200000,
"max_context_window":100000,
"input_modalities":["image","IMAGE","audio"],
"supported_reasoning_levels":["low", {"effort":"high"}],
"default_reasoning_level":"high",
"visibility":"hide",
"base_instructions":"unsafe",
"supports_search_tool":true,
"service_tiers":[{"id":"priority"}]
}]}));
let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap();
let model = &output_models(&catalog)[0];
assert_eq!(model["display_name"], "Third Party");
assert_eq!(model["context_window"], 200_000);
assert_eq!(model["max_context_window"], 200_000);
assert_eq!(model["input_modalities"], serde_json::json!(["image"]));
assert_eq!(model["default_reasoning_level"], "high");
assert_eq!(model["visibility"], "hide");
assert_eq!(
model["base_instructions"],
"You are Codex, a model-neutral coding agent."
);
assert_eq!(model["supports_search_tool"], false);
assert_eq!(model["service_tiers"], serde_json::json!([]));
assert_eq!(model["additional_speed_tiers"], serde_json::json!([]));
}
#[test]
fn invalid_reasoning_combination_keeps_fallback_and_missing_context_uses_128k() {
let runtime = runtime(serde_json::json!({"models":[{
"id":"C",
"supported_reasoning_levels":["xhigh"],
"default_reasoning_level":"invalid"
}]}));
let catalog = prepare_catalog_with_sources(&runtime, &test_sources()).unwrap();
let model = &output_models(&catalog)[0];
assert_eq!(model["context_window"], 128_000);
assert_eq!(model["max_context_window"], 128_000);
assert_eq!(model["display_name"], "C");
assert_eq!(model["visibility"], "list");
assert_eq!(model["web_search_tool_type"], "text");
assert_eq!(model["availability_nux"], Value::Null);
assert_eq!(model["upgrade"], Value::Null);
assert_eq!(model["default_reasoning_level"], "medium");
assert_eq!(reasoning_efforts(model), ["low", "medium", "high"]);
}
#[test]
fn invalid_catalogs_and_empty_runtime_are_rejected() {
let mut invalid_fallback = test_sources().fallback;
invalid_fallback.remove("experimental_supported_tools");
assert!(validate_model(&invalid_fallback, "fallback_model", false).is_err());
assert!(parse_sources(r#"{"fallback_model":{},"models":[]}"#).is_err());
assert!(parse_sources(
r#"{
"fallback_model":{"base_instructions":"x","context_window":2,"max_context_window":1},
"models":[{"slug":"A","base_instructions":"A","context_window":1,"max_context_window":1}]
}"#
)
.is_err());
assert!(parse_sources(
r#"{
"fallback_model":{"base_instructions":"x","context_window":1,"max_context_window":1},
"models":[
{"slug":"A","context_window":1,"max_context_window":1},
{"slug":"a","context_window":1,"max_context_window":1}
]
}"#
)
.is_err());
assert!(prepare_catalog_with_sources(&[], &test_sources()).is_err());
}
}
+399 -714
View File
File diff suppressed because it is too large. Load diff
+943 -38
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -14,8 +14,8 @@
{
"label": "main",
"title": "EasyCLIProxyAPI",
"width": 1500,
"height": 815,
"width": 1551,
"height": 893,
"minWidth": 640,
"minHeight": 600
}
+30
View File
@@ -35,6 +35,7 @@ import { languageOptions, useI18n } from './i18n';
import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate';
import { appUpdateIndicatorState } from './appUpdateModel';
import { canOpenAppPage, isAlwaysAvailablePage } from './navigation';
import { detectInitialTheme, saveTheme, type AppTheme } from './theme';
const CONTACT_URL = 'https://qm.qq.com/q/3queDaIG';
@@ -132,6 +133,7 @@ function AppContent() {
const { info: appUpdateInfo, hasUpdate, processing: appUpdateProcessing } = useAppUpdate();
const [active, setActive] = useState<PageId>('home');
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
const [theme, setTheme] = useState<AppTheme>(detectInitialTheme);
const [windowsClosePrompt, setWindowsClosePrompt] = useState<WindowsClosePrompt | null>(null);
const closeDialogRef = useRef<HTMLElement>(null);
const languageMenuRef = useRef<HTMLDivElement>(null);
@@ -143,6 +145,10 @@ function AppContent() {
const selectedLanguage = languageOptions.find((option) => option.value === locale)
?? languageOptions[0];
useEffect(() => {
saveTheme(theme);
}, [theme]);
useEffect(() => {
if (!canOpenAppPage(active, coreRunning)) {
setActive('home');
@@ -300,6 +306,30 @@ function AppContent() {
</nav>
<div className="sidebar-bottom">
<div
className="sidebar-theme-selector"
role="group"
aria-label={`${t('app.theme.light')} / ${t('app.theme.dark')}`}
>
<button
type="button"
className={theme === 'light' ? 'active' : ''}
aria-pressed={theme === 'light'}
title={t('app.theme.switchToLight')}
onClick={() => setTheme('light')}
>
{t('app.theme.light')}
</button>
<button
type="button"
className={theme === 'dark' ? 'active' : ''}
aria-pressed={theme === 'dark'}
title={t('app.theme.switchToDark')}
onClick={() => setTheme('dark')}
>
{t('app.theme.dark')}
</button>
</div>
<div ref={languageMenuRef} className="sidebar-language">
<button
ref={languageButtonRef}
+35 -6
View File
@@ -2,6 +2,10 @@ import type { MessageKey } from './resources';
export const jaOverrides = {
'app.language': '言語',
'app.theme.light': 'ライトモード',
'app.theme.dark': 'ダークモード',
'app.theme.switchToLight': 'ライトモードに切り替え',
'app.theme.switchToDark': 'ダークモードに切り替え',
'app.desktopConsole': 'デスクトップコンソール',
'app.navigation': 'メインナビゲーション',
'app.nav.home': 'ホーム',
@@ -95,6 +99,7 @@ export const jaOverrides = {
'usage.tab.overview': '概要',
'usage.tab.analysis': '分析',
'usage.tab.events': 'リクエスト詳細',
'usage.tab.pricing': '料金統計',
'usage.filter.timeRange': '期間',
'usage.range.4h': '過去 4 時間',
'usage.range.24h': '過去 24 時間',
@@ -124,6 +129,34 @@ export const jaOverrides = {
'usage.stat.successRate': '成功率',
'usage.stat.reasoningMeta': '推論 {tokens} Token',
'usage.stat.averageLatency': '平均レイテンシ',
'usage.stat.tps': 'TPS',
'usage.stat.performanceMeta': '{rpm} RPM · 平均レイテンシ {latency} ms',
'usage.stat.cacheHitRate': 'キャッシュヒット率',
'usage.stat.cacheHitMeta': 'ヒット {hit} / 入力 {input} Token',
'usage.stat.estimatedCost': '推定コスト',
'usage.stat.costMeta': '{priced} / {total} 件を計算済み · 参考値',
'usage.pricing.coverage': '{priced} / {total} 件を計算済み · 利用可能なモデル料金 {saved} 件',
'usage.pricing.search': 'モデルを検索',
'usage.pricing.add': '手動で追加',
'usage.pricing.sync': '料金を同期',
'usage.pricing.syncing': '同期中',
'usage.pricing.syncResult': '料金を更新しました:{imported} 件を追加、手動料金 {skipped} 件を保持、{unmatched} 件は未一致',
'usage.pricing.syncFallback': 'リモート料金を読み込めなかったため、内蔵料金を引き続き使用します',
'usage.pricing.model': 'モデル',
'usage.pricing.calls': 'リクエスト数',
'usage.pricing.cost': '推定コスト',
'usage.pricing.prompt': '入力料金 ($/1M)',
'usage.pricing.completion': '出力料金 ($/1M)',
'usage.pricing.cache': '互換キャッシュ料金 ($/1M)',
'usage.pricing.cacheRead': 'キャッシュ読み取り料金 ($/1M)',
'usage.pricing.cacheCreation': 'キャッシュ作成料金 ($/1M)',
'usage.pricing.actions': '操作',
'usage.pricing.optional': '任意。空欄の場合は代替料金を使用',
'usage.pricing.modelRequired': 'モデル名を入力してください',
'usage.pricing.saving': '保存中',
'usage.pricing.saved': 'モデル料金を保存し、概要のコストを更新しました',
'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました',
'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?',
'usage.trend.title': 'リクエストと Token の推移',
'usage.trend.description': 'システムのローカル時刻ごとに集計',
'usage.token.title': 'Token の内訳',
@@ -146,7 +179,8 @@ export const jaOverrides = {
'usage.column.model': 'モデル',
'usage.column.source': 'ソース',
'usage.column.result': '結果',
'usage.column.latency': 'レイテンシ',
'usage.column.latency': '合計所要時間',
'usage.column.ttft': '初回トークン遅延',
'usage.column.input': '入力',
'usage.column.output': '出力',
'usage.column.reasoning': '推論',
@@ -393,7 +427,6 @@ export const jaOverrides = {
'agents.status.conflict': '設定競合',
'agents.status.modified': '設定変更済み',
'agents.status.original': '元の設定を維持',
'agents.status.externalChanged': '外部設定は適用待ち',
'agents.status.invalid': '設定が無効',
'agents.status.applied': '適用済み',
'agents.status.unconfigured': '未設定',
@@ -413,8 +446,6 @@ export const jaOverrides = {
'agents.sessions.repairTitle': '過去のセッションを復元',
'agents.sessions.repairDescription': '他の Provider のセッションを現在のセッション Provider に同期します。',
'agents.sessions.autoRepair': 'Codex 起動前に過去のセッションを自動復元',
'agents.sessions.autoRepairEnabled': '起動前の自動復元を有効にしました。',
'agents.sessions.autoRepairDisabled': '起動前の自動復元を無効にしました。',
'agents.sessions.settingLoading': '読み込み中',
'agents.sessions.settingOn': 'オン',
'agents.sessions.settingOff': 'オフ',
@@ -487,12 +518,10 @@ export const jaOverrides = {
'agents.modify.apply': '設定変更を適用',
'agents.modify.default': 'デフォルト設定',
'agents.modify.clear': '設定を消去',
'agents.modify.externalChanged': '外部の設定変更を検出し、その内容を保持しています。「設定変更を適用」をクリックするまで管理対象フィールドは書き換えません。',
'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。',
'agents.modify.applied': 'アプリが管理するフィールドは適用済みで、それ以外のユーザー設定は保持されています。',
'agents.footer.changed': 'モデル選択が変更されました。設定変更を適用すると新しいモデルが使用されます。',
'agents.footer.applied': '設定変更は適用済みです。アプリは関連フィールドだけを管理します。',
'agents.footer.externalChanged': '外部変更を保持しています。設定変更を適用すると管理対象フィールドが再度書き込まれます。',
'agents.footer.invalidManaged': '設定が無効です。変更を適用して修復するか、デフォルト設定にリセットしてください。',
'agents.footer.unsupported': '現在のシステムではこのクライアントを設定できません',
'agents.footer.installFirst': 'クライアントをインストールしてから再検出してください',
+70 -12
View File
@@ -5,6 +5,10 @@ export type MessageVariables = Record<string, string | number>;
export const zhCN = {
'app.language': '语言',
'app.theme.light': '日间模式',
'app.theme.dark': '夜间模式',
'app.theme.switchToLight': '切换到日间模式',
'app.theme.switchToDark': '切换到夜间模式',
'app.desktopConsole': '桌面控制台',
'app.navigation': '主导航',
'app.nav.home': '首页',
@@ -98,6 +102,7 @@ export const zhCN = {
'usage.tab.overview': '总览',
'usage.tab.analysis': '分析',
'usage.tab.events': '请求明细',
'usage.tab.pricing': '价格统计',
'usage.filter.timeRange': '时间范围',
'usage.range.4h': '最近 4 小时',
'usage.range.24h': '最近 24 小时',
@@ -127,6 +132,34 @@ export const zhCN = {
'usage.stat.successRate': '成功率',
'usage.stat.reasoningMeta': '思考 {tokens} Token',
'usage.stat.averageLatency': '平均延迟',
'usage.stat.tps': 'TPS',
'usage.stat.performanceMeta': '{rpm} RPM · 平均延迟 {latency} ms',
'usage.stat.cacheHitRate': '缓存命中率',
'usage.stat.cacheHitMeta': '命中 {hit} / 输入 {input} Token',
'usage.stat.estimatedCost': '预估成本',
'usage.stat.costMeta': '已计价 {priced} / {total} 次请求 · 仅供参考',
'usage.pricing.coverage': '已计价 {priced} / {total} 次请求 · 可用 {saved} 个模型价格',
'usage.pricing.search': '搜索模型',
'usage.pricing.add': '手动添加',
'usage.pricing.sync': '同步价格',
'usage.pricing.syncing': '同步中',
'usage.pricing.syncResult': '价格更新完成:导入 {imported},保留手动价格 {skipped},未匹配 {unmatched}',
'usage.pricing.syncFallback': '远程价格读取失败,已继续使用软件内置价格',
'usage.pricing.model': '模型',
'usage.pricing.calls': '请求数',
'usage.pricing.cost': '预估成本',
'usage.pricing.prompt': '输入价 ($/1M)',
'usage.pricing.completion': '输出价 ($/1M)',
'usage.pricing.cache': '兼容缓存价 ($/1M)',
'usage.pricing.cacheRead': '缓存读取价 ($/1M)',
'usage.pricing.cacheCreation': '缓存创建价 ($/1M)',
'usage.pricing.actions': '操作',
'usage.pricing.optional': '可选,留空使用回退价',
'usage.pricing.modelRequired': '请输入模型名称',
'usage.pricing.saving': '保存中',
'usage.pricing.saved': '模型价格已保存,总览成本已更新',
'usage.pricing.deleted': '模型价格已删除,总览成本已更新',
'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?',
'usage.trend.title': '请求与 Token 趋势',
'usage.trend.description': '按系统本地小时聚合',
'usage.token.title': 'Token 构成',
@@ -149,7 +182,8 @@ export const zhCN = {
'usage.column.model': '模型',
'usage.column.source': '来源',
'usage.column.result': '结果',
'usage.column.latency': '延迟',
'usage.column.latency': '总耗时',
'usage.column.ttft': '首字延迟',
'usage.column.input': '输入',
'usage.column.output': '输出',
'usage.column.reasoning': '思考',
@@ -396,7 +430,6 @@ export const zhCN = {
'agents.status.conflict': '配置冲突',
'agents.status.modified': '已修改配置',
'agents.status.original': '保持原配置',
'agents.status.externalChanged': '外部配置待应用',
'agents.status.invalid': '配置无效',
'agents.status.applied': '已应用',
'agents.status.unconfigured': '尚未配置',
@@ -416,8 +449,6 @@ export const zhCN = {
'agents.sessions.repairTitle': '恢复历史会话',
'agents.sessions.repairDescription': '将其他渠道的会话同步到当前会话',
'agents.sessions.autoRepair': '启动 Codex 前自动恢复历史会话',
'agents.sessions.autoRepairEnabled': '已开启启动前自动恢复历史会话。',
'agents.sessions.autoRepairDisabled': '已关闭启动前自动恢复历史会话。',
'agents.sessions.settingLoading': '读取中',
'agents.sessions.settingOn': '已开启',
'agents.sessions.settingOff': '已关闭',
@@ -490,12 +521,10 @@ export const zhCN = {
'agents.modify.apply': '应用配置修改',
'agents.modify.default': '默认配置',
'agents.modify.clear': '清空配置',
'agents.modify.externalChanged': '检测到外部配置变化。软件会保留这些内容;点击“应用配置修改”后才会重新写入受管字段。',
'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。',
'agents.modify.applied': '软件负责的配置字段已应用,其他用户内容保持不变。',
'agents.footer.changed': '模型选择已变化;应用配置修改后将使用新模型。',
'agents.footer.applied': '配置修改已应用;软件只管理相关字段。',
'agents.footer.externalChanged': '外部配置已保留;应用配置修改后才会重新写入受管字段。',
'agents.footer.invalidManaged': '配置当前无效,请应用修改进行修复,或重置为默认配置。',
'agents.footer.unsupported': '当前系统无法配置该客户端',
'agents.footer.installFirst': '请先安装客户端并重新检测',
@@ -820,6 +849,10 @@ export type MessageKey = keyof typeof zhCN;
export const en: Record<MessageKey, string> = {
'app.language': 'Language',
'app.theme.light': 'Light mode',
'app.theme.dark': 'Dark mode',
'app.theme.switchToLight': 'Switch to light mode',
'app.theme.switchToDark': 'Switch to dark mode',
'app.desktopConsole': 'Desktop Console',
'app.navigation': 'Main navigation',
'app.nav.home': 'Home',
@@ -913,6 +946,7 @@ export const en: Record<MessageKey, string> = {
'usage.tab.overview': 'Overview',
'usage.tab.analysis': 'Analysis',
'usage.tab.events': 'Request Details',
'usage.tab.pricing': 'Price Statistics',
'usage.filter.timeRange': 'Time range',
'usage.range.4h': 'Last 4 Hours',
'usage.range.24h': 'Last 24 Hours',
@@ -942,6 +976,34 @@ export const en: Record<MessageKey, string> = {
'usage.stat.successRate': 'Success Rate',
'usage.stat.reasoningMeta': 'Reasoning {tokens} tokens',
'usage.stat.averageLatency': 'Average Latency',
'usage.stat.tps': 'TPS',
'usage.stat.performanceMeta': '{rpm} RPM · Avg latency {latency} ms',
'usage.stat.cacheHitRate': 'Cache Hit Rate',
'usage.stat.cacheHitMeta': '{hit} hit / {input} input tokens',
'usage.stat.estimatedCost': 'Estimated Cost',
'usage.stat.costMeta': '{priced} / {total} requests priced · Estimate only',
'usage.pricing.coverage': '{priced} / {total} requests priced · {saved} model prices available',
'usage.pricing.search': 'Search model',
'usage.pricing.add': 'Add Manually',
'usage.pricing.sync': 'Sync Prices',
'usage.pricing.syncing': 'Syncing',
'usage.pricing.syncResult': 'Prices updated: {imported} imported, {skipped} manual prices kept, {unmatched} unmatched',
'usage.pricing.syncFallback': 'Remote prices could not be read; built-in prices remain active',
'usage.pricing.model': 'Model',
'usage.pricing.calls': 'Requests',
'usage.pricing.cost': 'Estimated Cost',
'usage.pricing.prompt': 'Input ($/1M)',
'usage.pricing.completion': 'Output ($/1M)',
'usage.pricing.cache': 'Legacy Cache ($/1M)',
'usage.pricing.cacheRead': 'Cache Read ($/1M)',
'usage.pricing.cacheCreation': 'Cache Creation ($/1M)',
'usage.pricing.actions': 'Actions',
'usage.pricing.optional': 'Optional; blank uses fallback',
'usage.pricing.modelRequired': 'Enter a model name',
'usage.pricing.saving': 'Saving',
'usage.pricing.saved': 'Model price saved; overview cost updated',
'usage.pricing.deleted': 'Model price deleted; overview cost updated',
'usage.pricing.deleteConfirm': 'Delete the price for {model}?',
'usage.trend.title': 'Request and Token Trend',
'usage.trend.description': 'Grouped by local system hour',
'usage.token.title': 'Token Breakdown',
@@ -964,7 +1026,8 @@ export const en: Record<MessageKey, string> = {
'usage.column.model': 'Model',
'usage.column.source': 'Source',
'usage.column.result': 'Result',
'usage.column.latency': 'Latency',
'usage.column.latency': 'Total Duration',
'usage.column.ttft': 'First Token Latency',
'usage.column.input': 'Input',
'usage.column.output': 'Output',
'usage.column.reasoning': 'Reasoning',
@@ -1211,7 +1274,6 @@ export const en: Record<MessageKey, string> = {
'agents.status.conflict': 'Configuration Conflict',
'agents.status.modified': 'Configuration Modified',
'agents.status.original': 'Original Configuration',
'agents.status.externalChanged': 'External Changes Pending',
'agents.status.invalid': 'Invalid Configuration',
'agents.status.applied': 'Applied',
'agents.status.unconfigured': 'Not Configured',
@@ -1231,8 +1293,6 @@ export const en: Record<MessageKey, string> = {
'agents.sessions.repairTitle': 'Restore Historical Sessions',
'agents.sessions.repairDescription': 'Sync sessions from other providers to the current session provider.',
'agents.sessions.autoRepair': 'Restore historical sessions before launching Codex',
'agents.sessions.autoRepairEnabled': 'Automatic restoration before launch is enabled.',
'agents.sessions.autoRepairDisabled': 'Automatic restoration before launch is disabled.',
'agents.sessions.settingLoading': 'Loading',
'agents.sessions.settingOn': 'On',
'agents.sessions.settingOff': 'Off',
@@ -1305,12 +1365,10 @@ export const en: Record<MessageKey, string> = {
'agents.modify.apply': 'Apply Configuration Changes',
'agents.modify.default': 'Default Configuration',
'agents.modify.clear': 'Clear Configuration',
'agents.modify.externalChanged': 'External configuration changes were detected and preserved. Managed fields are rewritten only after you click Apply Configuration Changes.',
'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.',
'agents.modify.applied': 'The fields managed by the app are applied; all other user content remains unchanged.',
'agents.footer.changed': 'The model selection changed. Apply the configuration to use the new model.',
'agents.footer.applied': 'Configuration changes are applied; only the relevant fields are managed by the app.',
'agents.footer.externalChanged': 'External changes were preserved. Managed fields are rewritten when you apply the configuration.',
'agents.footer.invalidManaged': 'The configuration is invalid. Apply changes to repair it or reset it to the default configuration.',
'agents.footer.unsupported': 'This client cannot be configured on the current system',
'agents.footer.installFirst': 'Install the client, then detect again',
+3
View File
@@ -3,8 +3,11 @@ import { createRoot } from 'react-dom/client';
import { AppErrorBoundary } from './AppErrorBoundary';
import App from './App';
import { I18nProvider } from './i18n';
import { applyTheme, detectInitialTheme } from './theme';
import './styles.css';
applyTheme(detectInitialTheme());
createRoot(document.getElementById('root')!).render(
<StrictMode>
<I18nProvider>
+44 -47
View File
@@ -49,7 +49,7 @@ type AgentClientId =
| 'openclaw'
| 'hermes';
type AgentModificationState = 'unconfigured' | 'applied' | 'external-changed' | 'invalid';
type AgentModificationState = 'unconfigured' | 'applied' | 'invalid';
type AgentConfigStatus = {
id: AgentClientId;
@@ -62,6 +62,7 @@ type AgentConfigStatus = {
configValid: boolean;
configured: boolean;
currentModel: string | null;
oauthConfiguration: boolean;
modificationEnabled: boolean;
modificationState: AgentModificationState;
backupAvailable: boolean;
@@ -210,17 +211,6 @@ const writeAgentModelSelections = (
}
};
const reconcileAgentModelSelections = (
current: Partial<Record<AgentClientId, string>>,
models: ModelOption[],
) => {
return agentDefinitions.reduce<Partial<Record<AgentClientId, string>>>((result, agent) => {
const existing = current[agent.id] ?? '';
result[agent.id] = resolveAgentModelSelection(models, existing);
return result;
}, {});
};
function AgentMark({ definition, size = 26 }: { definition: AgentDefinition; size?: number }) {
if (definition.icon) {
return <img src={definition.icon} alt="" className="provider-logo" />;
@@ -234,7 +224,6 @@ const listStatusText = (status: AgentConfigStatus | undefined) => {
if (!status) return translate(locale, 'agents.list.detecting');
if (!status.supportedPlatform) return translate(locale, 'agents.list.unsupported');
if (!status.installed) return translate(locale, 'agents.list.notInstalled');
if (status.modificationState === 'external-changed') return translate(locale, 'agents.status.externalChanged');
if (status.modificationState === 'invalid') return translate(locale, 'agents.status.invalid');
if (status.modificationState === 'applied') return translate(locale, 'agents.list.modified', { model: status.appliedModel ?? '—' });
return status.version
@@ -510,8 +499,8 @@ export function AgentsPage() {
const [closeAppError, setCloseAppError] = useState('');
const [closeAppNotice, setCloseAppNotice] = useState('');
const [closeAppConfirmOpen, setCloseAppConfirmOpen] = useState(false);
const [oauthConfiguration, setOauthConfiguration] = useState(false);
const autoLoadedModelClientRef = useRef<AgentClientId | null>(null);
const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState<boolean | null>(null);
const modelRequestRef = useRef(0);
const loadStatuses = useCallback(async (forceRefresh = false) => {
const command = forceRefresh
@@ -521,22 +510,29 @@ export function AgentsPage() {
setStatuses(nextStatuses);
}, []);
const loadModels = useCallback(async () => {
const loadModels = useCallback(async (client: AgentClientId) => {
const requestId = modelRequestRef.current + 1;
modelRequestRef.current = requestId;
setModelLoading(true);
setModelError('');
setModels([]);
try {
const nextModels = await invoke<ModelOption[]>('get_agent_models');
const nextModels = await invoke<ModelOption[]>('get_agent_models', { client });
if (modelRequestRef.current !== requestId) return;
setModels(nextModels);
setModelSelectionError('');
setModelByClient((current) => {
const next = reconcileAgentModelSelections(current, nextModels);
const next = {
...current,
[client]: resolveAgentModelSelection(nextModels, current[client] ?? ''),
};
writeAgentModelSelections(next);
return next;
});
} catch (requestError) {
setModelError(String(requestError));
if (modelRequestRef.current === requestId) setModelError(String(requestError));
} finally {
setModelLoading(false);
if (modelRequestRef.current === requestId) setModelLoading(false);
}
}, []);
@@ -561,9 +557,7 @@ export function AgentsPage() {
}, [loadStatuses]);
useEffect(() => {
if (autoLoadedModelClientRef.current === selected) return;
autoLoadedModelClientRef.current = selected;
void loadModels();
void loadModels(selected);
}, [loadModels, selected]);
useEffect(() => {
@@ -621,6 +615,9 @@ export function AgentsPage() {
const activeDefinition = agentDefinitions.find((agent) => agent.id === selected)
?? agentDefinitions[0];
const activeStatus = statuses.find((status) => status.id === selected) ?? null;
const oauthConfiguration = oauthConfigurationDraft
?? activeStatus?.oauthConfiguration
?? false;
const savedSelectedModel = modelByClient[selected] ?? '';
const selectedModelOption = findAgentModel(models, savedSelectedModel);
const selectedModel = selectedModelOption?.name ?? '';
@@ -630,11 +627,14 @@ export function AgentsPage() {
(target) => target.id === selectedLaunchTargetId,
) ?? activeLaunchTargets[0] ?? null;
const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? '';
const draftChanged = Boolean(
const modelDraftChanged = Boolean(
selectedModel.trim()
&& appliedModel.trim()
&& selectedModel.trim() !== appliedModel.trim(),
);
const oauthConfigurationChanged = selected === 'codex'
&& oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration);
const draftChanged = modelDraftChanged || oauthConfigurationChanged;
const canEnable = Boolean(
activeStatus?.supportedPlatform
&& activeStatus.installed
@@ -657,31 +657,27 @@ export function AgentsPage() {
: activeStatus?.modificationState === 'applied'
? t('agents.model.current', { model: appliedModel || '—' })
: t('agents.model.firstSelection', { count: models.length }));
const modificationDescription = activeStatus?.modificationState === 'external-changed'
? t('agents.modify.externalChanged')
: activeStatus?.modificationState === 'invalid'
? t('agents.modify.invalid')
: activeStatus?.modificationState === 'applied'
? t('agents.modify.applied')
: '';
const modificationDescription = activeStatus?.modificationState === 'invalid'
? t('agents.modify.invalid')
: activeStatus?.modificationState === 'applied'
? t('agents.modify.applied')
: '';
const footerMessage = activeStatus?.modificationState === 'applied'
? draftChanged
? t('agents.footer.changed')
: t('agents.footer.applied')
: activeStatus?.modificationState === 'external-changed'
? t('agents.footer.externalChanged')
: activeStatus?.modificationState === 'invalid'
? t('agents.footer.invalidManaged')
: !activeStatus?.supportedPlatform
? t('agents.footer.unsupported')
: !activeStatus.installed
? t('agents.footer.installFirst')
: activeStatus.launchTargets.length === 0
? t('agents.footer.noCommand')
: '';
: activeStatus?.modificationState === 'invalid'
? t('agents.footer.invalidManaged')
: !activeStatus?.supportedPlatform
? t('agents.footer.unsupported')
: !activeStatus.installed
? t('agents.footer.installFirst')
: activeStatus.launchTargets.length === 0
? t('agents.footer.noCommand')
: '';
const refreshModels = () => {
void loadModels();
void loadModels(selected);
};
const reloadStatusesAfterAction = async () => {
@@ -731,10 +727,10 @@ export function AgentsPage() {
await invoke<AgentConfigActionResult>('apply_agent_config', {
client: selected,
model,
models,
oauthConfiguration,
});
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
} catch (requestError) {
setConfigurationError(String(requestError));
} finally {
@@ -751,11 +747,11 @@ export function AgentsPage() {
await invoke<AgentConfigActionResult>('reset_agent_config_to_default', {
client: selected,
model,
models,
oauthConfiguration,
});
setDefaultConfirmOpen(false);
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
} catch (requestError) {
setDefaultError(String(requestError));
} finally {
@@ -772,6 +768,7 @@ export function AgentsPage() {
setClearConfirmOpen(false);
setClearNotice(t('agents.clear.success'));
await reloadStatusesAfterAction();
setOauthConfigurationDraft(null);
} catch (requestError) {
setClearError(String(requestError));
} finally {
@@ -947,7 +944,7 @@ export function AgentsPage() {
<section className="agent-core-setting-section agent-model-section">
<div className="agent-section-heading">
<div><strong>{t('agents.useModel')}</strong></div>
{draftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
{modelDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
</div>
<AgentModelPicker
models={models}
@@ -988,7 +985,7 @@ export function AgentsPage() {
type="checkbox"
role="switch"
checked={oauthConfiguration}
onChange={(event) => setOauthConfiguration(event.currentTarget.checked)}
onChange={(event) => setOauthConfigurationDraft(event.currentTarget.checked)}
disabled={busy}
aria-label={t('agents.modify.oauthConfiguration')}
/>
-1
View File
@@ -383,7 +383,6 @@ export const createProviderDraft = (category: ProviderCategory): ProviderDraft =
return {
...draft,
name: 'DeepSeek',
remark: 'DeepSeek',
baseUrl: DEEPSEEK_BASE_URL,
thinkingLevels: [...DEEPSEEK_THINKING_LEVELS],
};
+2 -11
View File
@@ -8,7 +8,6 @@ type RepairOnLaunchSetting = {
};
type StatusMessage = {
kind: 'success' | 'error';
text: string;
};
@@ -32,7 +31,6 @@ export function CodexSessionAutoRestoreCard() {
.catch((error) => {
if (!disposed) {
setStatus({
kind: 'error',
text: t('agents.sessions.settingLoadFailed', { error: errorMessage(error) }),
});
}
@@ -53,15 +51,8 @@ export function CodexSessionAutoRestoreCard() {
request: { enabled: nextEnabled },
});
setEnabled(result.enabled);
setStatus({
kind: 'success',
text: result.enabled
? t('agents.sessions.autoRepairEnabled')
: t('agents.sessions.autoRepairDisabled'),
});
} catch (error) {
setStatus({
kind: 'error',
text: t('agents.sessions.settingFailed', { error: errorMessage(error) }),
});
} finally {
@@ -102,8 +93,8 @@ export function CodexSessionAutoRestoreCard() {
</div>
{status ? (
<small
className={`codex-auto-restore-message ${status.kind}`}
role={status.kind === 'error' ? 'alert' : 'status'}
className="codex-auto-restore-message error"
role="alert"
>
{status.text}
</small>
+218 -6
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { Activity, BarChart3, Clock3, Database, List, ShieldCheck, Sparkles, TriangleAlert } from 'lucide-react';
import { Activity, BarChart3, CircleDollarSign, Clock3, Database, List, Pencil, RefreshCw, ShieldCheck, Sparkles, Trash2, TriangleAlert, X } from 'lucide-react';
import { getCurrentLocale, useI18n } from '../i18n';
type UsageTab = 'overview' | 'analysis' | 'events';
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing';
type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom';
type CollectorStatus = {
@@ -35,7 +35,11 @@ type UsageOverview = {
totalTokens: number;
rpm: number;
tpm: number;
tps: number;
averageLatencyMs: number;
cacheHitRate: number;
estimatedCost: number;
pricedRequests: number;
timeline: TimelinePoint[];
};
@@ -88,6 +92,49 @@ type UsageEventPage = {
totalPages: number;
};
type ModelPrice = {
model: string;
prompt: number;
completion: number;
cache: number;
cacheRead: number;
cacheCreation: number;
promptConfigured: boolean;
completionConfigured: boolean;
cacheReadConfigured: boolean;
cacheCreationConfigured: boolean;
source: string;
sourceModelId: string;
updatedAtMs: number;
};
type UsagePriceRow = {
model: string;
requests: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
totalTokens: number;
estimatedCost: number;
price: ModelPrice | null;
};
type UsagePricing = {
rows: UsagePriceRow[];
totalCost: number;
totalRequests: number;
pricedRequests: number;
savedPrices: number;
};
type ModelPriceSyncResult = {
imported: number;
skipped: number;
unmatched: string[];
usedBuiltin: boolean;
};
type UsageQuery = {
start?: string;
end?: string;
@@ -107,7 +154,7 @@ const emptyAnalysis: UsageAnalysis = { models: [], providers: [], sources: [], a
const loadTab = (): UsageTab => {
try {
const saved = localStorage.getItem(TAB_KEY);
return saved === 'analysis' || saved === 'events' ? saved : 'overview';
return saved === 'analysis' || saved === 'events' || saved === 'pricing' ? saved : 'overview';
} catch {
return 'overview';
}
@@ -148,6 +195,22 @@ const compactNumber = (value: number) => new Intl.NumberFormat(getCurrentLocale(
maximumFractionDigits: 1,
}).format(Number.isFinite(value) ? value : 0);
const formatUsd = (value: number) => {
const amount = Number.isFinite(value) ? value : 0;
const absolute = Math.abs(amount);
const maximumFractionDigits = absolute === 0 || absolute >= 1
? 2
: absolute >= 0.01
? 4
: absolute >= 0.0001
? 6
: 8;
return `$${new Intl.NumberFormat(getCurrentLocale(), {
minimumFractionDigits: 2,
maximumFractionDigits,
}).format(amount)}`;
};
const formatTime = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(getCurrentLocale(), {
@@ -174,6 +237,7 @@ export function UsageRecordsPage() {
const [analysis, setAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
const [optionsAnalysis, setOptionsAnalysis] = useState<UsageAnalysis>(emptyAnalysis);
const [events, setEvents] = useState<UsageEventPage | null>(null);
const [pricing, setPricing] = useState<UsagePricing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const requestIdRef = useRef(0);
@@ -230,7 +294,7 @@ export function UsageRecordsPage() {
setOptionsAnalysis(nextOptions);
setOverview(nextOverview);
setAnalysis(nextAnalysis);
} else {
} else if (activeTab === 'events') {
const [nextStatus, nextOptions, nextEvents] = await Promise.all([
statusRequest,
optionsRequest,
@@ -242,6 +306,16 @@ export function UsageRecordsPage() {
setStatus(nextStatus);
setOptionsAnalysis(nextOptions);
setEvents(nextEvents);
} else {
const [nextStatus, nextOptions, nextPricing] = await Promise.all([
statusRequest,
optionsRequest,
invoke<UsagePricing>('get_usage_pricing', { query }),
]);
if (requestId !== requestIdRef.current) return;
setStatus(nextStatus);
setOptionsAnalysis(nextOptions);
setPricing(nextPricing);
}
setError('');
} catch (requestError) {
@@ -289,6 +363,7 @@ export function UsageRecordsPage() {
(activeTab === 'overview' && !overview)
|| (activeTab === 'analysis' && !overview)
|| (activeTab === 'events' && !events)
|| (activeTab === 'pricing' && !pricing)
);
return (
@@ -313,6 +388,7 @@ export function UsageRecordsPage() {
<button type="button" className={activeTab === 'overview' ? 'active' : ''} onClick={() => setActiveTab('overview')}><Activity size={15} />{t('usage.tab.overview')}</button>
<button type="button" className={activeTab === 'analysis' ? 'active' : ''} onClick={() => setActiveTab('analysis')}><BarChart3 size={15} />{t('usage.tab.analysis')}</button>
<button type="button" className={activeTab === 'events' ? 'active' : ''} onClick={() => setActiveTab('events')}><List size={15} />{t('usage.tab.events')}</button>
<button type="button" className={activeTab === 'pricing' ? 'active' : ''} onClick={() => setActiveTab('pricing')}><CircleDollarSign size={15} />{t('usage.tab.pricing')}</button>
</div>
<section className="panel usage-filter-panel">
@@ -332,6 +408,7 @@ export function UsageRecordsPage() {
{activeTab === 'overview' && overview ? <OverviewView overview={overview} /> : null}
{activeTab === 'analysis' ? <AnalysisView analysis={analysis} overview={overview} /> : null}
{activeTab === 'events' && events ? <EventsView events={events} onPage={setPage} /> : null}
{activeTab === 'pricing' && pricing ? <PricingView pricing={pricing} query={buildQueries().query} onChanged={() => loadData(true)} /> : null}
</section>
);
}
@@ -342,7 +419,9 @@ function OverviewView({ overview }: { overview: UsageOverview }) {
{ icon: Activity, label: t('usage.stat.requests'), value: compactNumber(overview.totalRequests), meta: t('usage.stat.requestMeta', { success: overview.successCount, failed: overview.failureCount }) },
{ icon: Sparkles, label: t('usage.stat.tokens'), value: compactNumber(overview.totalTokens), meta: t('usage.stat.tokenMeta', { input: compactNumber(overview.inputTokens), output: compactNumber(overview.outputTokens) }) },
{ icon: ShieldCheck, label: t('usage.stat.successRate'), value: `${overview.successRate.toFixed(1)}%`, meta: t('usage.stat.reasoningMeta', { tokens: compactNumber(overview.reasoningTokens) }) },
{ icon: Clock3, label: t('usage.stat.averageLatency'), value: `${Math.round(overview.averageLatencyMs)} ms`, meta: `${overview.rpm.toFixed(2)} RPM · ${compactNumber(overview.tpm)} TPM` },
{ icon: Clock3, label: t('usage.stat.tps'), value: `${overview.tps.toFixed(1)} TPS`, meta: t('usage.stat.performanceMeta', { rpm: overview.rpm.toFixed(2), latency: Math.round(overview.averageLatencyMs) }) },
{ icon: Database, label: t('usage.stat.cacheHitRate'), value: `${(overview.cacheHitRate * 100).toFixed(1)}%`, meta: t('usage.stat.cacheHitMeta', { hit: compactNumber(overview.cacheReadTokens), input: compactNumber(overview.inputTokens) }) },
{ icon: CircleDollarSign, label: t('usage.stat.estimatedCost'), value: formatUsd(overview.estimatedCost), meta: t('usage.stat.costMeta', { priced: compactNumber(overview.pricedRequests), total: compactNumber(overview.totalRequests) }) },
];
return <div className="usage-overview-layout">
<div className="usage-stat-grid">{cards.map(({ icon: Icon, label, value, meta }) => <article className="panel usage-stat-card" key={label}><span><Icon size={16} />{label}</span><strong>{value}</strong><small>{meta}</small></article>)}</div>
@@ -385,7 +464,140 @@ function CategoryPanel({ title, items, compactLabels = false }: { title: string;
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
const { t } = useI18n();
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source_display || undefined}>{record.source_display || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>{t('usage.column.ttft')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source_display || undefined}>{record.source_display || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
}
type PriceDraft = {
model: string;
prompt: string;
completion: string;
cache: string;
cacheRead: string;
cacheCreation: string;
};
const emptyPriceDraft = (): PriceDraft => ({ model: '', prompt: '', completion: '', cache: '', cacheRead: '', cacheCreation: '' });
const priceDraftFor = (model = '', price?: ModelPrice | null): PriceDraft => ({
model,
prompt: price ? String(price.prompt) : '',
completion: price ? String(price.completion) : '',
cache: price ? String(price.cache) : '',
cacheRead: price && (price.cacheReadConfigured || price.cacheRead > 0) ? String(price.cacheRead) : '',
cacheCreation: price && (price.cacheCreationConfigured || price.cacheCreation > 0) ? String(price.cacheCreation) : '',
});
const parsePrice = (value: string) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
};
const priceUnit = (value: number | undefined) => Number.isFinite(value) ? `$${Number(value).toFixed(4)}` : '—';
function PricingView({ pricing, query, onChanged }: { pricing: UsagePricing; query: UsageQuery; onChanged: () => void | Promise<void> }) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [draft, setDraft] = useState<PriceDraft | null>(null);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [message, setMessage] = useState('');
const [localError, setLocalError] = useState('');
const visibleRows = pricing.rows.filter((row) => {
const keyword = search.trim().toLowerCase();
return !keyword || row.model.toLowerCase().includes(keyword);
});
const savePrice = async () => {
if (!draft?.model.trim()) {
setLocalError(t('usage.pricing.modelRequired'));
return;
}
setSaving(true);
setLocalError('');
try {
await invoke('save_usage_model_price', {
price: {
model: draft.model.trim(),
prompt: parsePrice(draft.prompt),
completion: parsePrice(draft.completion),
cache: draft.cache.trim() ? parsePrice(draft.cache) : parsePrice(draft.prompt),
cacheRead: parsePrice(draft.cacheRead),
cacheCreation: parsePrice(draft.cacheCreation),
promptConfigured: draft.prompt.trim() !== '',
completionConfigured: draft.completion.trim() !== '',
cacheReadConfigured: draft.cacheRead.trim() !== '',
cacheCreationConfigured: draft.cacheCreation.trim() !== '',
source: 'manual',
sourceModelId: '',
updatedAtMs: 0,
} satisfies ModelPrice,
});
setDraft(null);
setMessage(t('usage.pricing.saved'));
await onChanged();
} catch (saveError) {
setLocalError(String(saveError));
} finally {
setSaving(false);
}
};
const deletePrice = async (model: string) => {
if (!window.confirm(t('usage.pricing.deleteConfirm', { model }))) return;
setLocalError('');
try {
await invoke('delete_usage_model_price', { model });
setMessage(t('usage.pricing.deleted'));
await onChanged();
} catch (deleteError) {
setLocalError(String(deleteError));
}
};
const syncPrices = async () => {
setSyncing(true);
setLocalError('');
setMessage('');
try {
const result = await invoke<ModelPriceSyncResult>('sync_usage_model_prices', { query });
setMessage(result.usedBuiltin
? t('usage.pricing.syncFallback')
: t('usage.pricing.syncResult', { imported: result.imported, skipped: result.skipped, unmatched: result.unmatched.length }));
await onChanged();
} catch (syncError) {
setLocalError(String(syncError));
} finally {
setSyncing(false);
}
};
return <section className="panel usage-pricing-panel">
<div className="usage-pricing-toolbar">
<div className="usage-pricing-summary">
<strong>{formatUsd(pricing.totalCost)}</strong>
<span>{t('usage.pricing.coverage', { priced: compactNumber(pricing.pricedRequests), total: compactNumber(pricing.totalRequests), saved: compactNumber(pricing.savedPrices) })}</span>
</div>
<div className="usage-pricing-actions">
<input value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={t('usage.pricing.search')} aria-label={t('usage.pricing.search')} />
<button type="button" className="secondary-button" onClick={() => setDraft(emptyPriceDraft())}>{t('usage.pricing.add')}</button>
<button type="button" className="primary-button" disabled={syncing} onClick={() => void syncPrices()}><RefreshCw size={14} className={syncing ? 'spin' : ''} />{syncing ? t('usage.pricing.syncing') : t('usage.pricing.sync')}</button>
</div>
</div>
{localError ? <div className="management-alert error">{localError}</div> : null}
{message ? <div className="management-alert success">{message}</div> : null}
{draft ? <div className="usage-price-editor">
<label><span>{t('usage.pricing.model')}</span><input value={draft.model} onChange={(event) => setDraft({ ...draft, model: event.currentTarget.value })} placeholder="gpt-5.6-terra" /></label>
<label><span>{t('usage.pricing.prompt')}</span><input type="number" min="0" step="0.0001" value={draft.prompt} onChange={(event) => setDraft({ ...draft, prompt: event.currentTarget.value })} /></label>
<label><span>{t('usage.pricing.completion')}</span><input type="number" min="0" step="0.0001" value={draft.completion} onChange={(event) => setDraft({ ...draft, completion: event.currentTarget.value })} /></label>
<label><span>{t('usage.pricing.cache')}</span><input type="number" min="0" step="0.0001" value={draft.cache} onChange={(event) => setDraft({ ...draft, cache: event.currentTarget.value })} /></label>
<label><span>{t('usage.pricing.cacheRead')}</span><input type="number" min="0" step="0.0001" value={draft.cacheRead} onChange={(event) => setDraft({ ...draft, cacheRead: event.currentTarget.value })} placeholder={t('usage.pricing.optional')} /></label>
<label><span>{t('usage.pricing.cacheCreation')}</span><input type="number" min="0" step="0.0001" value={draft.cacheCreation} onChange={(event) => setDraft({ ...draft, cacheCreation: event.currentTarget.value })} placeholder={t('usage.pricing.optional')} /></label>
<div className="usage-price-editor-actions"><button type="button" className="secondary-button" onClick={() => setDraft(null)}><X size={14} />{t('common.cancel')}</button><button type="button" className="primary-button" disabled={saving} onClick={() => void savePrice()}>{saving ? t('usage.pricing.saving') : t('common.save')}</button></div>
</div> : null}
{visibleRows.length ? <div className="usage-table-wrap usage-pricing-table-wrap"><table className="usage-pricing-table"><thead><tr><th>{t('usage.pricing.model')}</th><th>{t('usage.pricing.calls')}</th><th>Token</th><th>{t('usage.pricing.cost')}</th><th>{t('usage.pricing.prompt')}</th><th>{t('usage.pricing.completion')}</th><th>{t('usage.pricing.cacheRead')}</th><th>{t('usage.pricing.cacheCreation')}</th><th>{t('usage.pricing.actions')}</th></tr></thead><tbody>{visibleRows.map((row) => <tr key={row.model}><td><strong>{row.model}</strong></td><td>{compactNumber(row.requests)}</td><td>{compactNumber(row.totalTokens)}</td><td><strong>{row.price ? formatUsd(row.estimatedCost) : '—'}</strong></td><td>{row.price ? priceUnit(row.price.prompt) : '—'}</td><td>{row.price ? priceUnit(row.price.completion) : '—'}</td><td>{row.price ? priceUnit(row.price.cacheReadConfigured || row.price.cacheRead > 0 ? row.price.cacheRead : row.price.cache) : '—'}</td><td>{row.price ? priceUnit(row.price.cacheCreationConfigured || row.price.cacheCreation > 0 ? row.price.cacheCreation : row.price.prompt) : '—'}</td><td><div className="usage-price-row-actions"><button type="button" className="icon-button" title={t('common.edit')} onClick={() => setDraft(priceDraftFor(row.model, row.price))}><Pencil size={14} /></button>{row.price?.source === 'manual' ? <button type="button" className="icon-button danger" title={t('common.delete')} onClick={() => void deletePrice(row.model)}><Trash2 size={14} /></button> : null}</div></td></tr>)}</tbody></table></div> : <UsageEmpty />}
</section>;
}
function UsageEmpty() {
+1176 -672
View File
File diff suppressed because it is too large. Load diff
+54
View File
@@ -0,0 +1,54 @@
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { getCurrentWindow } from '@tauri-apps/api/window';
export type AppTheme = 'light' | 'dark';
const STORAGE_KEY = 'easy-cli-proxy-api.theme';
const WINDOW_BACKGROUND: Record<AppTheme, string> = {
light: '#faf9f5',
dark: '#101419',
};
async function applyNativeTheme(theme: AppTheme): Promise<void> {
const background = WINDOW_BACKGROUND[theme];
const currentWindow = getCurrentWindow();
const currentWebview = getCurrentWebview();
await Promise.allSettled([
currentWindow.setTheme(theme),
currentWindow.setBackgroundColor(background),
currentWebview.setBackgroundColor(background),
]);
}
export function detectInitialTheme(): AppTheme {
if (typeof window === 'undefined') return 'light';
try {
const saved = window.localStorage.getItem(STORAGE_KEY);
if (saved === 'light' || saved === 'dark') return saved;
} catch {
// Storage may be unavailable in a restricted WebView; use the OS preference.
}
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function applyTheme(theme: AppTheme): void {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.style.backgroundColor = WINDOW_BACKGROUND[theme];
if (document.body) {
document.body.style.backgroundColor = WINDOW_BACKGROUND[theme];
}
void applyNativeTheme(theme);
}
export function saveTheme(theme: AppTheme): void {
applyTheme(theme);
try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
// The in-memory theme still works when persistent storage is unavailable.
}
}