18 files changed
+4974
-1510
No files matched your search
+15
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+943
-38
File diff suppressed because it is too large.
Load diff
@@ -14,8 +14,8 @@
|
||||
{
|
||||
"label": "main",
|
||||
"title": "EasyCLIProxyAPI",
|
||||
"width": 1500,
|
||||
"height": 815,
|
||||
"width": 1551,
|
||||
"height": 893,
|
||||
"minWidth": 640,
|
||||
"minHeight": 600
|
||||
}
|
||||
|
||||
+30
@@ -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
@@ -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
@@ -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,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
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -383,7 +383,6 @@ export const createProviderDraft = (category: ProviderCategory): ProviderDraft =
|
||||
return {
|
||||
...draft,
|
||||
name: 'DeepSeek',
|
||||
remark: 'DeepSeek',
|
||||
baseUrl: DEEPSEEK_BASE_URL,
|
||||
thinkingLevels: [...DEEPSEEK_THINKING_LEVELS],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user