feat(agents): add configurable DeepSeek Harness model catalog

- Separate default model selection from catalog configuration
- Use API metadata with per-model and provider overrides
- Expose documented capability, reasoning, protocol, and request settings
- Preserve customizations with conflict detection and transactional rollback
This commit is contained in:
lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent 145b585c3f
commit 4d8d549e04
28 files changed
+2047 -20

No files matched your search

+2
View File
@@ -5,6 +5,7 @@ use super::*;
mod commands;
mod configuration;
mod discovery;
mod deepseek_harness;
mod transactions;
mod backups;
mod templates;
@@ -13,6 +14,7 @@ mod state;
pub(crate) use commands::*;
pub(crate) use configuration::*;
pub(crate) use discovery::*;
pub(crate) use deepseek_harness::*;
pub(crate) use transactions::*;
pub(crate) use backups::*;
pub(crate) use templates::*;
+10
View File
@@ -410,6 +410,13 @@ pub(crate) fn desktop_mapping_path(paths: &[PathBuf]) -> Result<PathBuf, String>
.join("current-mapping.json"))
}
pub(crate) fn deepseek_harness_catalog_state_path(paths: &[PathBuf]) -> Result<PathBuf, String> {
Ok(agent_data_directory(paths)?
.join("agents/deepseek-harness")
.join(path_identity(paths))
.join("catalog-state.json"))
}
fn profile_models_hash(images: &Images) -> Option<String> {
let (path, bytes) = images.get(2)?;
let profile = parse(path, text(bytes.as_deref()).ok()?).ok()?;
@@ -452,6 +459,9 @@ pub(crate) fn current_desktop_mappings(home: &Path) -> Option<ClaudeDesktopModel
}
pub(crate) fn mapping_revision(client: &str, paths: &[PathBuf]) -> Result<String, String> {
if client == "deepseek-harness" {
return Ok(image_hash(read_agent_bytes(&deepseek_harness_catalog_state_path(paths)?)?.as_deref()));
}
if client != "claude-desktop" {
return Ok(String::new());
}
+2
View File
@@ -63,6 +63,8 @@ fn desktop_restore_models(plan: &RestorePlan) -> Result<Option<Vec<AgentModelOpt
.into_iter()
.zip([&mappings.opus, &mappings.sonnet, &mappings.haiku])
.map(|(route, source)| AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: source.clone(),
alias: None,
is_alias: source != route
+2
View File
@@ -25,6 +25,8 @@ fn models() -> Vec<AgentModelOption> {
["gpt-one", "gpt-two"]
.into_iter()
.map(|name| AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: name.into(),
alias: None,
is_alias: false,
+31 -9
View File
@@ -1173,8 +1173,15 @@ pub(crate) async fn update_agent_config(
claude_code_model_mappings: Option<ClaudeDesktopModelMappings>,
claude_desktop_model_mappings: Option<ClaudeDesktopModelMappings>,
) -> Result<AgentConfigActionResult, String> {
apply_agent_config(app, gui_config_state, client, model, oauth_configuration,
claude_code_model_mappings, claude_desktop_model_mappings).await
apply_agent_config(
app,
gui_config_state,
client,
model,
oauth_configuration,
claude_code_model_mappings,
claude_desktop_model_mappings,
).await
}
pub(crate) fn validate_agent_can_enable(
@@ -1224,11 +1231,18 @@ pub(crate) fn resolve_available_agent_model(
pub(crate) fn parse_agent_model_options(
payload: &serde_json::Value,
) -> Result<Vec<AgentModelOption>, String> {
let source = payload
.as_array()
let object_models = payload.get("models").and_then(serde_json::Value::as_object).map(|models| {
models.iter().map(|(id, value)| {
let mut entry = value.as_object().cloned().unwrap_or_default();
entry.entry("id").or_insert(serde_json::json!(id));
serde_json::Value::Object(entry)
}).collect::<Vec<_>>()
});
let source = payload.as_array()
.or_else(|| payload.get("data").and_then(serde_json::Value::as_array))
.or_else(|| payload.get("models").and_then(serde_json::Value::as_array))
.ok_or_else(|| "本机模型列表响应缺少 data 或 models 数组".to_string())?;
.or(object_models.as_ref())
.ok_or_else(|| "本机模型列表响应缺少 data 数组或 models 目录".to_string())?;
let mut models = Vec::new();
for item in source {
let name = if let Some(name) = item.as_str() {
@@ -1241,7 +1255,7 @@ pub(crate) fn parse_agent_model_options(
.trim()
.to_string()
};
let display_name = ["display_name", "displayName"]
let display_name = ["display_name", "displayName", "name"]
.into_iter()
.find_map(|key| item.get(key).and_then(serde_json::Value::as_str))
.map(str::trim)
@@ -1270,14 +1284,18 @@ pub(crate) fn parse_agent_model_options(
]
.into_iter()
.find_map(|key| item.get(key).and_then(json_positive_u64));
let input_modalities = codex_catalog::parse_modalities(item).or_else(|| {
codex_catalog::parse_modalities(&serde_json::json!({"input_modalities": item.get("input")}))
});
let harness_metadata = harness_api_metadata(item);
if let Some(model_alias) = model_alias {
if keep_original {
append_agent_model_option(&mut models, &name, display_name, false, context_window);
append_agent_model_option(&mut models, &name, display_name, false, context_window, input_modalities.clone(), harness_metadata.clone());
}
append_agent_model_option(&mut models, &model_alias, Some(name), true, context_window);
append_agent_model_option(&mut models, &model_alias, Some(name), true, context_window, input_modalities, harness_metadata);
} else {
append_agent_model_option(&mut models, &name, display_name, false, context_window);
append_agent_model_option(&mut models, &name, display_name, false, context_window, input_modalities, harness_metadata);
}
}
Ok(models)
@@ -1289,6 +1307,8 @@ pub(crate) fn append_agent_model_option(
alias: Option<String>,
is_alias: bool,
context_window: Option<u64>,
input_modalities: Option<Vec<String>>,
harness_metadata: Option<serde_json::Value>,
) {
let name = name.trim();
if name.is_empty()
@@ -1304,6 +1324,8 @@ pub(crate) fn append_agent_model_option(
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case(name))
.map(str::to_string);
models.push(AgentModelOption {
input_modalities,
harness_metadata,
name: name.to_string(),
alias,
is_alias,
+27 -2
View File
@@ -322,6 +322,13 @@ pub(crate) fn build_deepseek_harness_models(
.map_err(|error| format!("序列化 Harness 模型上下文失败: {error}"))?,
);
}
if let Some(input) = model.input_modalities {
entry.insert(
yaml_key("input"),
serde_norway::to_value(input)
.map_err(|_| "序列化 Harness 模型输入类型失败")?,
);
}
Ok(serde_norway::Value::Mapping(entry))
})
.collect::<Result<Vec<_>, String>>()?;
@@ -334,7 +341,21 @@ pub(crate) fn build_deepseek_harness_settings(
model: &str,
models: &[AgentModelOption],
) -> Result<String, String> {
let published_models = build_deepseek_harness_models(models, model)?;
build_deepseek_harness_catalog_settings(
existing,
base_url,
model,
models,
&mut DeepSeekHarnessCatalogState::default(),
)
}
pub(crate) fn render_deepseek_harness_settings(
existing: Option<&str>,
base_url: &str,
model: &str,
published_models: serde_norway::Value,
) -> Result<String, String> {
render_agent_yaml_mapping_update(existing, "DeepSeek Harness settings", |root| {
let llm = root
.entry(yaml_key("llm-pi-ai"))
@@ -369,6 +390,8 @@ pub(crate) fn build_deepseek_harness_settings(
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "DeepSeek Harness agent-default-model 必须是映射".to_string())?;
let same_model = selection.get(yaml_key("provider")).and_then(serde_norway::Value::as_str) == Some(DEEPSEEK_HARNESS_PROVIDER_ID)
&& selection.get(yaml_key("model")).and_then(serde_norway::Value::as_str) == Some(model);
selection.insert(
yaml_key("provider"),
serde_norway::Value::String(DEEPSEEK_HARNESS_PROVIDER_ID.to_string()),
@@ -377,7 +400,7 @@ pub(crate) fn build_deepseek_harness_settings(
yaml_key("model"),
serde_norway::Value::String(model.to_string()),
);
selection.remove(yaml_key("reasoningEffort"));
if !same_model { selection.remove(yaml_key("reasoningEffort")); }
Ok(())
})
}
@@ -693,6 +716,8 @@ pub(crate) fn ordered_agent_models(
ordered.push(selected.clone());
} else {
ordered.push(AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: selected_model.to_string(),
alias: None,
is_alias: false,
+681
View File
@@ -0,0 +1,681 @@
use super::*;
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
type Profile = Map<String, Value>;
static HARNESS_SCHEMA: std::sync::LazyLock<Value> = std::sync::LazyLock::new(|| {
serde_json::from_str(include_str!(
"../../../src/services/deepSeekHarnessSchema.json"
))
.unwrap()
});
pub(crate) fn harness_fields(group: &str) -> impl Iterator<Item = &str> {
HARNESS_SCHEMA[group]
.as_array()
.unwrap()
.iter()
.map(|field| field["name"].as_str().unwrap())
}
#[derive(Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct DeepSeekHarnessCatalogState {
models: BTreeMap<String, Profile>,
provider: Profile,
generated_models: BTreeMap<String, Profile>,
generated_provider: Option<Profile>,
}
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct HarnessEditorModel {
pub id: String,
pub defaults: Profile,
pub configuration: Profile,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct HarnessEditorSnapshot {
pub revision: String,
pub models: Vec<HarnessEditorModel>,
pub provider: Profile,
base_url: String,
default_model: Option<String>,
configured: bool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct HarnessEditorRequest {
pub revision: String,
pub models: Vec<HarnessEditorModel>,
pub provider: Profile,
}
fn read_harness_state(bytes: Option<&[u8]>) -> Result<DeepSeekHarnessCatalogState, String> {
bytes
.map(|bytes| {
serde_json::from_slice(bytes)
.map_err(|_| "读取 DSH 模型配置状态失败,请检查 catalog-state.json".into())
})
.unwrap_or_else(|| Ok(Default::default()))
}
fn harness_provider(root: &Value) -> Option<&Value> {
root.get("llm-pi-ai")?
.get("providers")?
.get(DEEPSEEK_HARNESS_PROVIDER_ID)
}
fn harness_root(existing: Option<&str>) -> Result<Value, String> {
serde_json::to_value(parse_agent_yaml_mapping(
existing,
"DeepSeek Harness settings",
)?)
.map_err(|_| "DSH settings 必须是有效的配置映射".into())
}
fn selected_fields(value: &Value, group: &str) -> Profile {
harness_fields(group)
.filter_map(|key| value.get(key).map(|value| (key.into(), value.clone())))
.collect()
}
fn import_changes(
current: &Profile,
generated: Option<&Profile>,
overrides: &mut Profile,
group: &str,
) {
for key in harness_fields(group) {
let current = current.get(key);
let previous = generated.and_then(|fields| fields.get(key));
if current != previous {
if let Some(value) = current {
overrides.insert(key.into(), value.clone());
} else {
overrides.remove(key);
}
}
}
}
fn import_harness_configuration(root: &Value, state: &mut DeepSeekHarnessCatalogState) {
let Some(provider) = harness_provider(root) else {
return;
};
import_changes(
&selected_fields(provider, "provider"),
state.generated_provider.as_ref(),
&mut state.provider,
"provider",
);
if let Some(models) = provider.get("models").and_then(Value::as_array) {
for model in models {
if let Some(id) = model.get("id").and_then(Value::as_str) {
import_changes(
&selected_fields(model, "model"),
state.generated_models.get(id),
state.models.entry(id.into()).or_default(),
"model",
);
}
}
}
state.models.retain(|_, profile| !profile.is_empty());
}
pub(crate) fn harness_api_metadata(item: &Value) -> Option<Value> {
let mut fields = Profile::new();
for (field, keys) in [
(
"maxTokens",
&[
"maxTokens",
"max_tokens",
"max_output_tokens",
"maxOutputTokens",
][..],
),
(
"reasoningEfforts",
&["reasoningEfforts", "reasoning_efforts"][..],
),
("compat", &["compat"][..]),
] {
if let Some(value) = keys.iter().find_map(|key| item.get(key)) {
let definition = HARNESS_SCHEMA["model"]
.as_array()
.unwrap()
.iter()
.find(|item| item["name"] == field)
.unwrap();
if validate_harness_field(value, definition, "", field).is_ok() {
fields.insert(field.into(), value.clone());
}
}
}
(!fields.is_empty()).then_some(Value::Object(fields))
}
fn harness_model_defaults(model: &AgentModelOption, api: &str) -> Profile {
let mut fields = model
.harness_metadata
.as_ref()
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
if let Some(compat) = fields.get_mut("compat").and_then(Value::as_object_mut) {
compat.retain(|key, _| {
HARNESS_SCHEMA["compat"]
.as_array()
.unwrap()
.iter()
.find(|field| field["name"] == key.as_str())
.is_some_and(|field| field["apis"].as_array().unwrap().contains(&json!(api)))
});
if compat.is_empty() {
fields.remove("compat");
}
}
if let Some(name) = &model.alias {
fields.insert("name".into(), json!(name));
}
if let Some(context) = model.context_window {
fields.insert("contextWindow".into(), json!(context));
}
if let Some(input) = &model.input_modalities {
fields.insert("input".into(), json!(input));
}
fields
}
fn merge_profile(mut defaults: Profile, configuration: &Profile) -> Profile {
for (key, value) in configuration {
if key == "compat" {
if let (Some(old), Some(new)) = (
defaults.get_mut(key).and_then(Value::as_object_mut),
value.as_object(),
) {
old.extend(new.clone());
continue;
}
}
defaults.insert(key.clone(), value.clone());
}
defaults
}
pub(crate) fn validate_harness_profile(
profile: &Profile,
group: &str,
api: &str,
path: &str,
) -> Result<(), String> {
let definitions = HARNESS_SCHEMA[group].as_array().unwrap();
for (key, value) in profile {
let field = definitions
.iter()
.find(|field| field["name"] == key.as_str())
.ok_or_else(|| format!("{path}.{key}: 不支持的配置字段"))?;
validate_harness_field(value, field, api, &format!("{path}.{key}"))?;
}
for field in definitions {
if field["required"] == true && !profile.contains_key(field["name"].as_str().unwrap()) {
return Err(format!(
"{path}.{}: 必须填写",
field["name"].as_str().unwrap()
));
}
}
if group == "backoff"
&& profile
.get("initialDelayMs")
.and_then(Value::as_f64)
.unwrap_or(500.0)
> profile
.get("maxDelayMs")
.and_then(Value::as_f64)
.unwrap_or(10000.0)
{
return Err(format!("{path}: initialDelayMs 不能大于 maxDelayMs"));
}
Ok(())
}
fn validate_harness_field(
value: &Value,
field: &Value,
api: &str,
path: &str,
) -> Result<(), String> {
let invalid = || format!("{path}: 配置值不符合 DSH 文档要求");
if !api.is_empty()
&& field
.get("apis")
.and_then(Value::as_array)
.is_some_and(|apis| !apis.contains(&json!(api)))
{
return Err(format!("{path}: 不适用于 {api},请恢复自动或选择对应协议"));
}
let valid = match field["kind"].as_str().unwrap() {
"group" => {
return validate_harness_profile(
value.as_object().ok_or_else(invalid)?,
field["group"].as_str().unwrap(),
api,
path,
)
}
"string" => value.as_str().is_some_and(|value| !value.trim().is_empty()),
"boolean" => value.is_boolean(),
"integer" | "number" => value.as_f64().is_some_and(|n| {
n.is_finite()
&& n.abs() <= 9007199254740991.0
&& (field["kind"] != "integer" || n.fract() == 0.0)
&& field["min"].as_f64().is_none_or(|min| n >= min)
&& field["exclusiveMin"].as_f64().is_none_or(|min| n > min)
&& field["max"].as_f64().is_none_or(|max| n <= max)
}),
"enum" => field["values"].as_array().unwrap().contains(value),
"modalities" | "strings" => value.as_array().is_some_and(|values| {
(!values.is_empty() || field["name"] == "input")
&& values.iter().enumerate().all(|(index, value)| {
value.as_str().is_some_and(|s| {
!s.trim().is_empty()
&& (field["kind"] != "modalities" || matches!(s, "text" | "image"))
}) && !values[..index].contains(value)
})
}),
"reasoning" => {
value == &json!(false)
|| value.as_object().is_some_and(|values| {
!values.is_empty()
&& values.iter().all(|(key, value)| {
["off", "minimal", "low", "medium", "high", "xhigh", "max"]
.contains(&key.as_str())
&& ((key == "off" && value.is_null())
|| value.as_str().is_some_and(|s| !s.trim().is_empty()))
})
})
}
"headers" => value.as_object().is_some_and(|values| {
values.iter().all(|(key, value)| {
reqwest::header::HeaderName::from_bytes(key.as_bytes()).is_ok()
&& value.as_str().is_some_and(|s| {
s.chars().all(|c| (c as u32) <= 255)
&& reqwest::header::HeaderValue::from_str(s).is_ok()
})
})
}),
"kwargs" => value.as_object().is_some_and(|values| {
values.values().all(|value| {
!value.is_array()
&& (!value.is_object()
|| value.as_object().is_some_and(|object| {
object
.keys()
.all(|key| matches!(key.as_str(), "$var" | "omitWhenOff"))
&& value["$var"].as_str().is_some_and(|s| {
["thinking.enabled", "thinking.effort", "thinking.budget"]
.contains(&s)
})
&& value.get("omitWhenOff").is_none_or(Value::is_boolean)
}))
})
}),
_ => false,
};
if valid {
Ok(())
} else {
Err(invalid())
}
}
fn render_harness_profiles(
existing: Option<&str>,
base_url: &str,
models: &[AgentModelOption],
state: &mut DeepSeekHarnessCatalogState,
) -> Result<String, String> {
let root = harness_root(existing)?;
let old = harness_provider(&root);
let api = state
.provider
.get("api")
.and_then(Value::as_str)
.unwrap_or("openai-completions");
validate_harness_profile(&state.provider, "provider", api, "provider")?;
let mut published = Vec::new();
let mut generated = BTreeMap::new();
for model in models {
let profile = merge_profile(
harness_model_defaults(model, api),
state.models.get(&model.name).unwrap_or(&Profile::new()),
);
validate_harness_profile(&profile, "model", api, &model.name)?;
let mut entry = old
.and_then(|p| p.get("models"))
.and_then(Value::as_array)
.and_then(|entries| entries.iter().find(|entry| entry["id"] == model.name))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for key in harness_fields("model") {
entry.remove(key);
}
entry.extend(profile.clone());
entry.insert("id".into(), json!(model.name));
generated.insert(model.name.clone(), profile);
published.push(entry);
}
let mut provider = state.provider.clone();
provider
.entry("displayName")
.or_insert(json!("EasyCLIProxyAPI"));
provider.entry("api").or_insert(json!("openai-completions"));
let rendered =
render_agent_yaml_mapping_update(existing, "DeepSeek Harness settings", |root| {
let managed = root
.get_mut(yaml_key("llm-pi-ai"))
.and_then(|v| v.get_mut("providers"))
.and_then(|v| v.get_mut(DEEPSEEK_HARNESS_PROVIDER_ID))
.and_then(serde_norway::Value::as_mapping_mut)
.ok_or("DSH provider 配置缺失")?;
managed.insert(
yaml_key("baseURL"),
serde_norway::Value::String(
if api == "anthropic-messages" {
base_url.trim_end_matches("/v1")
} else {
base_url
}
.into(),
),
);
for key in harness_fields("provider") {
managed.remove(yaml_key(key));
}
for (key, value) in &provider {
managed.insert(
yaml_key(key),
serde_norway::to_value(value).map_err(|_| "序列化 DSH provider 失败")?,
);
}
managed.insert(
yaml_key("models"),
serde_norway::to_value(&published).map_err(|_| "序列化 DSH 模型失败")?,
);
Ok(())
})?;
state.generated_models = generated;
state.generated_provider = Some(provider);
Ok(rendered)
}
pub(crate) fn deepseek_harness_template_catalog_state(images: &Images) -> Result<Vec<u8>, String> {
let root = harness_root(text(
images.first().ok_or("缺少 DSH settings")?.1.as_deref(),
)?)?;
let mut state = DeepSeekHarnessCatalogState::default();
if let Some(provider) = harness_provider(&root) {
state.generated_provider = Some(selected_fields(provider, "provider"));
if let Some(models) = provider.get("models").and_then(Value::as_array) {
for model in models {
if let Some(id) = model.get("id").and_then(Value::as_str) {
state
.generated_models
.insert(id.into(), selected_fields(model, "model"));
}
}
}
}
serde_json::to_vec(&state).map_err(|_| "生成 DSH 模型配置状态失败".into())
}
pub(crate) fn build_deepseek_harness_catalog_settings(
existing: Option<&str>,
base_url: &str,
selected: &str,
models: &[AgentModelOption],
state: &mut DeepSeekHarnessCatalogState,
) -> Result<String, String> {
import_harness_configuration(&harness_root(existing)?, state);
let ordered = ordered_agent_models(models, selected);
let settings = render_deepseek_harness_settings(
existing,
base_url,
selected,
build_deepseek_harness_models(models, selected)?,
)?;
render_harness_profiles(Some(&settings), base_url, &ordered, state)
}
pub(crate) fn prepare_deepseek_harness_configuration(
home: &Path,
port: u16,
api_key: &str,
selected: &str,
models: &[AgentModelOption],
) -> Result<(Vec<PathBuf>, Images, Images), String> {
let mut paths = config_paths("deepseek-harness", home)?;
paths.push(deepseek_harness_catalog_state_path(&paths)?);
let before = config_images(&paths)?;
let mut state = read_harness_state(before[2].1.as_deref())?;
let settings = build_deepseek_harness_catalog_settings(
text(before[0].1.as_deref())?,
&format!("{}/v1", managed_core_loopback_origin(port)),
selected,
models,
&mut state,
)?;
let updates = vec![
AgentFileUpdate {
path: paths[0].clone(),
after: settings,
},
AgentFileUpdate {
path: paths[1].clone(),
after: build_deepseek_harness_credentials(text(before[1].1.as_deref())?, api_key)?,
},
];
let mut after = prepare_config_updates(
"deepseek-harness",
&paths[..2],
&before[..2].to_vec(),
&updates,
false,
)?;
after.push((
paths[2].clone(),
Some(serde_json::to_vec(&state).map_err(|_| "生成 DSH 模型配置状态失败")?),
));
Ok((paths, before, after))
}
pub(crate) fn apply_deepseek_harness_configuration(
home: &Path,
port: u16,
api_key: &str,
selected: &str,
models: &[AgentModelOption],
) -> Result<AgentConfigActionResult, String> {
let (paths, before, after) =
prepare_deepseek_harness_configuration(home, port, api_key, selected, models)?;
commit_config(
"deepseek-harness",
&paths,
&before,
&after,
"update",
Some(selected.into()),
)
}
fn harness_editor_from_images(
before: &Images,
models: &[AgentModelOption],
port: u16,
) -> Result<(HarnessEditorSnapshot, DeepSeekHarnessCatalogState), String> {
let root = harness_root(text(before[0].1.as_deref())?)?;
let mut state = read_harness_state(before[2].1.as_deref())?;
import_harness_configuration(&root, &mut state);
let api = state
.provider
.get("api")
.and_then(Value::as_str)
.unwrap_or("openai-completions");
let models = models
.iter()
.map(|model| HarnessEditorModel {
id: model.name.clone(),
defaults: harness_model_defaults(model, api),
configuration: state.models.get(&model.name).cloned().unwrap_or_default(),
})
.collect::<Vec<_>>();
let base_url = format!("{}/v1", managed_core_loopback_origin(port));
let revision = sha256_bytes(
&serde_json::to_vec(&(image_revision(before), &models, &base_url))
.map_err(|_| "生成 DSH 配置版本失败")?,
);
Ok((
HarnessEditorSnapshot {
revision,
models,
provider: state.provider.clone(),
base_url,
default_model: root
.get("agent-default-model")
.filter(|s| s["provider"] == DEEPSEEK_HARNESS_PROVIDER_ID)
.and_then(|s| s.get("model"))
.and_then(Value::as_str)
.map(str::to_string),
configured: harness_provider(&root).is_some(),
},
state,
))
}
pub(crate) fn harness_editor_snapshot(
home: &Path,
models: &[AgentModelOption],
port: u16,
) -> Result<HarnessEditorSnapshot, String> {
let mut paths = config_paths("deepseek-harness", home)?;
paths.push(deepseek_harness_catalog_state_path(&paths)?);
harness_editor_from_images(&config_images(&paths)?, models, port).map(|(snapshot, _)| snapshot)
}
pub(crate) fn save_harness_editor(
home: &Path,
models: &[AgentModelOption],
port: u16,
request: HarnessEditorRequest,
) -> Result<HarnessEditorSnapshot, String> {
let mut paths = config_paths("deepseek-harness", home)?;
paths.push(deepseek_harness_catalog_state_path(&paths)?);
let before = config_images(&paths)?;
let (snapshot, mut state) = harness_editor_from_images(&before, models, port)?;
if request.revision != snapshot.revision {
return Err("DSH_MODEL_CATALOG_CHANGED: 模型列表或配置已变化,请重新加载".into());
}
if request.models.len() != models.len() {
return Err("DSH 模型列表不完整,请重新加载".into());
}
let mut seen = std::collections::HashSet::new();
for model in request.models {
if !seen.insert(model.id.clone()) || !models.iter().any(|m| m.name == model.id) {
return Err("DSH 模型列表包含重复或不可用的 ID".into());
}
state.models.insert(model.id, model.configuration);
}
state.provider = request.provider;
let api = state
.provider
.get("api")
.and_then(Value::as_str)
.unwrap_or("openai-completions");
validate_harness_profile(&state.provider, "provider", api, "provider")?;
for model in models {
validate_harness_profile(
&merge_profile(
harness_model_defaults(model, api),
&state.models[&model.name],
),
"model",
api,
&model.name,
)?;
}
let mut after = before.clone();
if snapshot.configured {
if snapshot
.default_model
.as_ref()
.is_some_and(|id| !models.iter().any(|m| &m.name == id))
{
return Err("当前默认模型已不可用,请先更新默认模型".into());
}
let settings = render_harness_profiles(
text(before[0].1.as_deref())?,
&format!("{}/v1", managed_core_loopback_origin(port)),
models,
&mut state,
)?;
let updates = vec![AgentFileUpdate {
path: paths[0].clone(),
after: settings,
}];
let prepared = prepare_config_updates(
"deepseek-harness",
&paths[..2],
&before[..2].to_vec(),
&updates,
false,
)?;
after[0] = prepared[0].clone();
}
state.models.retain(|_, profile| !profile.is_empty());
after[2].1 = Some(serde_json::to_vec(&state).map_err(|_| "生成 DSH 模型配置状态失败")?);
commit_config(
"deepseek-harness",
&paths,
&before,
&after,
"catalog",
snapshot.default_model.clone(),
)?;
harness_editor_snapshot(home, models, port)
}
#[tauri::command]
pub(crate) async fn get_deepseek_harness_model_catalog_editor(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<HarnessEditorSnapshot, String> {
let config = gui_config_state.snapshot()?;
let models = fetch_agent_models(config.port, effective_agent_api_key(&config)).await?;
let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?;
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏")?;
harness_editor_snapshot(&home, &models, config.port)
}
#[tauri::command]
pub(crate) async fn save_deepseek_harness_model_catalog_editor(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
request: HarnessEditorRequest,
) -> Result<HarnessEditorSnapshot, String> {
let config = gui_config_state.snapshot()?;
let models = fetch_agent_models(config.port, effective_agent_api_key(&config)).await?;
let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?;
let _guard = AGENT_CONFIG_FILE_LOCK
.lock()
.map_err(|_| "智能体配置文件锁已损坏")?;
save_harness_editor(&home, &models, config.port, request)
}
+6 -2
View File
@@ -3855,7 +3855,11 @@ pub(crate) fn inspect_deepseek_harness_config(
.map(str::to_string)
})
.flatten();
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
let expected_base = if provider.and_then(|p| yaml_mapping_value(p, "api")).and_then(serde_norway::Value::as_str) == Some("anthropic-messages") {
managed_core_loopback_origin(port)
} else {
format!("{}/v1", managed_core_loopback_origin(port))
};
let credentials_layout_supported = deepseek_harness_credentials_layout_supported(&credentials);
let credential = yaml_mapping_value(&credentials, "refs")
.and_then(serde_norway::Value::as_mapping)
@@ -3882,7 +3886,7 @@ pub(crate) fn inspect_deepseek_harness_config(
&& provider
.and_then(|provider| yaml_mapping_value(provider, "api"))
.and_then(serde_norway::Value::as_str)
== Some("openai-completions")
.is_some_and(|api| matches!(api, "openai-completions" | "openai-responses" | "anthropic-messages"))
&& provider
.and_then(|provider| yaml_mapping_value(provider, "baseURL"))
.and_then(serde_norway::Value::as_str)
+7
View File
@@ -635,6 +635,8 @@ pub(crate) fn fresh_agent_contents(
model: &str,
) -> Result<Vec<String>, String> {
let models = [AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: model.to_string(),
alias: None,
is_alias: false,
@@ -1445,6 +1447,9 @@ pub(crate) fn apply_agent_configuration_with_oauth(
model: &str,
options: AgentConfigurationOptions<'_>,
) -> Result<AgentConfigActionResult, String> {
if client == AgentClient::DeepSeekHarness {
return apply_deepseek_harness_configuration(home, port, api_key, model, options.models);
}
let before = config_images(&config_paths(client.id(), home)?)?;
validate_config_images(&before)?;
let mappings = options.claude_desktop_model_mappings;
@@ -1469,6 +1474,8 @@ pub(crate) fn reset_agent_configuration_to_default(
api_key,
model,
models: &[AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: model.to_string(),
alias: None,
is_alias: false,
+13 -2
View File
@@ -181,6 +181,17 @@ fn commit_config_transaction(
}
let mut previous = before.clone();
let mut target = after.clone();
if client == "deepseek-harness" && matches!(source, "template" | "restore") {
let state_path = deepseek_harness_catalog_state_path(paths)?;
let state_before = read_agent_bytes(&state_path)?;
let state_after = if source == "template" {
Some(deepseek_harness_template_catalog_state(after)?)
} else {
None
};
previous.push((state_path.clone(), state_before));
target.push((state_path, state_after));
}
if client == "claude-desktop" {
let state_path = desktop_mapping_path(paths)?;
let state_before = read_agent_bytes(&state_path)?;
@@ -382,7 +393,7 @@ fn preserve_model_extensions(client: &str, path: &Path, before: &Value, after: &
"claude-desktop" => &["name", "contextWindow", "supports1m", "prefer1m"],
"opencode" | "zcode" => &["name"],
"openclaw" => &["id", "name", "alias"],
"deepseek-harness" => &["id", "name", "contextWindow"],
"deepseek-harness" => &["id", "name", "contextWindow", "input", "maxTokens", "reasoningEfforts", "compat"],
"kimi-code" => &[
"provider",
"model",
@@ -553,7 +564,7 @@ fn validate_unmanaged_preserved(
}
if client == "deepseek-harness" {
if path == &paths[0] {
for key in ["displayName", "apiKeyEnv", "api", "baseURL", "models"] {
for key in ["apiKeyEnv", "baseURL", "models"].into_iter().chain(harness_fields("provider")) {
set(
&mut value,
&[
+9 -1
View File
@@ -519,6 +519,8 @@ fn prepare_catalog_with_customizations(
let models = entries
.iter()
.map(|entry| AgentModelOption {
input_modalities: None,
harness_metadata: None,
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"))
@@ -681,7 +683,7 @@ fn enable_fast_mode(model: &mut Map<String, Value>) {
);
}
fn parse_modalities(value: &Value) -> Option<Vec<String>> {
pub(crate) fn parse_modalities(value: &Value) -> Option<Vec<String>> {
let raw = value
.get("input_modalities")
.or_else(|| value.get("inputModalities"))
@@ -1070,18 +1072,24 @@ mod tests {
fn runtime_context_windows_override_generic_model_metadata() {
let mut models = vec![
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "GPT-Test".to_string(),
alias: None,
is_alias: false,
context_window: Some(200_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "unmatched".to_string(),
alias: None,
is_alias: false,
context_window: Some(128_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "max-only".to_string(),
alias: None,
is_alias: false,
+7
View File
@@ -1121,8 +1121,13 @@ struct AgentModelOption {
is_alias: bool,
#[serde(default)]
context_window: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
input_modalities: Option<Vec<String>>,
#[serde(skip)]
harness_metadata: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ClaudeDesktopModelMappings {
@@ -2521,6 +2526,8 @@ fn main() {
uninstall_pi_provider,
check_codex_oauth_login,
update_codex_model_catalog,
get_deepseek_harness_model_catalog_editor,
save_deepseek_harness_model_catalog_editor,
get_codex_model_catalog_editor,
save_codex_model_catalog_editor,
get_thinking_aliases,
+1
View File
@@ -1,6 +1,7 @@
use super::*;
mod agent_configuration;
mod deepseek_harness_catalog;
mod agent_paths;
mod agent_state;
mod agent_transactions;
@@ -128,24 +128,32 @@ fn claude_code_role_mappings_drive_settings() {
};
let models = vec![
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "gpt-opus-base".to_string(),
alias: None,
is_alias: false,
context_window: Some(1_000_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: mappings.opus.clone(),
alias: Some("gpt-opus-base".to_string()),
is_alias: true,
context_window: Some(128_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: mappings.sonnet.clone(),
alias: None,
is_alias: false,
context_window: Some(272_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: mappings.haiku.clone(),
alias: None,
is_alias: false,
@@ -188,12 +196,16 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() {
};
let models = vec![
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "custom-pro".to_string(),
alias: Some("Custom Pro".to_string()),
is_alias: false,
context_window: Some(200_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "custom-flash".to_string(),
alias: Some("Custom Flash".to_string()),
is_alias: false,
@@ -251,6 +263,8 @@ fn claude_code_runtime_settings_keep_per_role_1m_suffixes() {
#[test]
fn claude_desktop_keeps_original_context_when_1m_is_off() {
let models = vec![AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "runtime-model".to_string(),
alias: None,
is_alias: false,
@@ -574,18 +588,24 @@ fn claude_desktop_profile_keeps_non_claude_models_internal() {
};
let models = vec![
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: mappings.opus.clone(),
alias: None,
is_alias: false,
context_window: Some(1_000_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: mappings.sonnet.clone(),
alias: None,
is_alias: false,
context_window: Some(272_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: mappings.haiku.clone(),
alias: None,
is_alias: false,
@@ -1358,30 +1378,40 @@ fn agent_model_list_parser_exposes_aliases_as_selectable_model_ids() {
models,
vec![
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "gpt-5".to_string(),
alias: Some("GPT 5".to_string()),
is_alias: false,
context_window: Some(272_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "claude-sonnet".to_string(),
alias: None,
is_alias: false,
context_window: Some(1_000_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "claude-sonnet-xhigh".to_string(),
alias: Some("claude-sonnet".to_string()),
is_alias: true,
context_window: Some(1_000_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "visible-alias".to_string(),
alias: Some("hidden-original".to_string()),
is_alias: true,
context_window: Some(128_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "deepseek-chat".to_string(),
alias: None,
is_alias: false,
@@ -1483,12 +1513,16 @@ agent-default-model:
"#;
let models = vec![
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "gpt-selected".to_string(),
alias: Some("Selected Model".to_string()),
is_alias: false,
context_window: Some(272_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "gpt-other".to_string(),
alias: None,
is_alias: false,
@@ -2067,12 +2101,16 @@ fn claude_desktop_uses_selected_alias_directly_with_original_context() {
};
let models = vec![
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "gpt-original".to_string(),
alias: None,
is_alias: false,
context_window: Some(1_000_000),
},
AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "gpt-high".to_string(),
alias: Some("gpt-original".to_string()),
is_alias: true,
@@ -2306,6 +2344,8 @@ fn codex_model_list_is_empty_when_cpa_has_no_writable_models() {
#[test]
fn agent_model_validation_only_accepts_models_in_current_list() {
let models = vec![AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: "gpt-5.4".to_string(),
alias: Some("GPT 5.4".to_string()),
is_alias: false,
@@ -0,0 +1,378 @@
use super::support::*;
use super::*;
use serde_json::{json, Value};
fn models() -> Vec<AgentModelOption> {
parse_agent_model_options(&json!({"data":[
{"id":"vision","input_modalities":["text","image"],"context_window":128000},
{"id":"text-only","inputModalities":["text"]},
{"id":"unknown"}
]}))
.unwrap()
}
fn root(home: &Path) -> Value {
let paths = agent_config_paths(AgentClient::DeepSeekHarness, home);
serde_json::to_value(
serde_norway::from_str::<serde_norway::Value>(&fs::read_to_string(&paths[0]).unwrap())
.unwrap(),
)
.unwrap()
}
fn provider(home: &Path) -> Value {
root(home)["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID].clone()
}
fn apply(home: &Path, models: &[AgentModelOption]) -> AgentConfigActionResult {
apply_agent_configuration(
AgentClient::DeepSeekHarness,
home,
8317,
"test-key",
"vision",
models,
None,
)
.unwrap()
}
fn request(snapshot: HarnessEditorSnapshot) -> HarnessEditorRequest {
HarnessEditorRequest {
revision: snapshot.revision,
models: snapshot.models,
provider: snapshot.provider,
}
}
#[test]
fn harness_api_defaults_support_aliases_and_model_maps_without_guessing() {
let models = parse_agent_model_options(&json!({"data":[
{"id":"original","alias":"alias","fork":true,"supported_input_modalities":[" TEXT ","IMAGE","image"],"max_output_tokens":8192,"reasoning_efforts":{"off":null,"high":"high"}},
{"id":"empty","input_modalities":[]}, {"id":"gpt-known-name"}
]})).unwrap();
assert_eq!(
models[0].input_modalities,
Some(vec!["text".into(), "image".into()])
);
assert_eq!(models[0].harness_metadata, models[1].harness_metadata);
assert_eq!(models[1].name, "alias");
assert_eq!(
models[1].harness_metadata.as_ref().unwrap()["maxTokens"],
8192
);
assert!(models[2..]
.iter()
.all(|model| model.input_modalities.is_none()));
let mapped =
parse_agent_model_options(&json!({"models":{"vision":{"input":["image"]},"unknown":{}}}))
.unwrap();
assert_eq!(
mapped
.iter()
.find(|m| m.name == "vision")
.unwrap()
.input_modalities,
Some(vec!["image".into()])
);
}
#[tokio::test]
async fn harness_discovery_uses_authenticated_models_endpoint() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut buffer = [0; 2048];
while !request.windows(4).any(|w| w == b"\r\n\r\n") {
let read = socket.read(&mut buffer).unwrap();
assert!(read > 0);
request.extend_from_slice(&buffer[..read]);
}
let request = String::from_utf8(request).unwrap().to_ascii_lowercase();
assert!(request.starts_with("get /v1/models http/1.1\r\n"));
assert!(request.contains("authorization: bearer test-harness-key\r\n"));
assert!(!request.contains("client_version"));
let body =
r#"{"data":[{"id":"vision","input_modalities":["text","image"]},{"id":"unknown"}]}"#;
write!(socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body).unwrap();
});
let models = fetch_agent_models(port, "test-harness-key").await.unwrap();
server.join().unwrap();
assert_eq!(models.len(), 2);
assert!(models[1].input_modalities.is_none());
}
#[test]
fn harness_refreshes_api_defaults_and_preserves_catalog_overrides() {
let home = agent_test_home("harness-catalog-refresh");
let mut models = models();
apply(&home, &models);
assert_eq!(
provider(&home)["models"][0]["input"],
json!(["text", "image"])
);
assert!(provider(&home)["models"][2].get("input").is_none());
assert_eq!(apply(&home, &models).outcome, "unchanged");
let initial = harness_editor_snapshot(&home, &models, 8317).unwrap();
assert!(initial.models.iter().all(|m| m.configuration.is_empty()));
let mut edit = request(initial);
edit.models[1].configuration = json!({"name":"Text model","maxTokens":4096,"reasoningEfforts":false,"compat":{"supportsDeveloperRole":false}}).as_object().unwrap().clone();
edit.models[2].configuration = json!({"input":["text","image"]})
.as_object()
.unwrap()
.clone();
edit.provider = json!({"defaultInput":["text","image"],"timeoutMs":60000,"retryPolicy":{"mode":"normal","maxRetries":3}}).as_object().unwrap().clone();
save_harness_editor(&home, &models, 8317, edit).unwrap();
models[0].input_modalities = Some(vec!["text".into()]);
models[0].context_window = Some(256000);
apply(&home, &models);
let provider = provider(&home);
assert_eq!(provider["models"][0]["input"], json!(["text"]));
assert_eq!(provider["models"][0]["contextWindow"], 256000);
assert_eq!(provider["models"][1]["name"], "Text model");
assert_eq!(provider["models"][1]["maxTokens"], 4096);
assert_eq!(provider["models"][2]["input"], json!(["text", "image"]));
assert_eq!(provider["defaultInput"], json!(["text", "image"]));
assert_eq!(root(&home)["agent-default-model"]["model"], "vision");
let mut reset = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
reset
.models
.iter_mut()
.for_each(|m| m.configuration.clear());
reset.provider.clear();
save_harness_editor(&home, &models, 8317, reset).unwrap();
apply(&home, &models);
let updated = self::provider(&home);
assert!(updated["models"][1].get("maxTokens").is_none());
assert!(updated["models"][1].get("compat").is_none());
assert!(updated["models"][2].get("input").is_none());
assert!(updated.get("defaultInput").is_none());
fs::remove_dir_all(home).unwrap();
}
#[test]
fn harness_saves_before_connection_and_applies_documented_protocol() {
let home = agent_test_home("harness-catalog-draft");
let mut models = models();
models[2].harness_metadata = harness_api_metadata(&json!({"compat":{
"supportsStore":false,"supportsMaxOutputTokens":false,"supportsTemperature":false
}}));
let mut edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
edit.provider = json!({"api":"openai-responses","compat":{"supportsMaxOutputTokens":false},"transport":"sse"}).as_object().unwrap().clone();
edit.models[2]
.configuration
.insert("input".into(), json!(["text", "image"]));
save_harness_editor(&home, &models, 8317, edit).unwrap();
let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home);
assert!(!paths[0].exists());
assert!(!paths[1].exists());
apply(&home, &models);
assert_eq!(provider(&home)["api"], "openai-responses");
assert_eq!(
provider(&home)["models"][2]["compat"],
json!({"supportsMaxOutputTokens":false})
);
assert_eq!(
provider(&home)["models"][2]["input"],
json!(["text", "image"])
);
assert!(
inspect_deepseek_harness_config(&paths, 8317, "test-key")
.unwrap()
.0
);
let mut edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
edit.provider = json!({"api":"anthropic-messages","compat":{"supportsTemperature":false}})
.as_object()
.unwrap()
.clone();
save_harness_editor(&home, &models, 8317, edit).unwrap();
assert_eq!(provider(&home)["baseURL"], "http://127.0.0.1:8317");
assert_eq!(
provider(&home)["models"][2]["compat"],
json!({"supportsTemperature":false})
);
assert!(
inspect_deepseek_harness_config(&paths, 8317, "test-key")
.unwrap()
.0
);
apply(&home, &models);
assert_eq!(provider(&home)["baseURL"], "http://127.0.0.1:8317");
fs::remove_dir_all(home).unwrap();
}
#[test]
fn harness_rejects_stale_catalog_and_invalid_fields_without_writing() {
let home = agent_test_home("harness-catalog-validation");
let models = models();
apply(&home, &models);
let mut changed_models = models.clone();
changed_models[0].context_window = Some(64);
let stale = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
assert!(save_harness_editor(&home, &changed_models, 8317, stale)
.err()
.unwrap()
.contains("DSH_MODEL_CATALOG_CHANGED"));
let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home);
let before = config_images(&paths).unwrap();
for configuration in [
json!({"contextWindow":0}),
json!({"maxTokens":1.5}),
json!({"input":["audio"]}),
json!({"reasoningEfforts":{"high":null}}),
json!({"compat":{"supportsTemperature":true}}),
json!({"compat":{"supportsStore":null}}),
json!({"compat":{"vllmPriority":0.5}}),
json!({"unknownField":true}),
] {
let mut edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
edit.models[0].configuration = configuration.as_object().unwrap().clone();
assert!(save_harness_editor(&home, &models, 8317, edit).is_err());
assert_eq!(config_images(&paths).unwrap(), before);
}
for configuration in [
json!({"defaultInput":[]}),
json!({"streamIdleTimeoutMs":2147483648_u64}),
json!({"headers":{"x-test":"bad\r\nheader"}}),
json!({"retryPolicy":{"mode":"normal","backoff":{"initialDelayMs":20000}}}),
json!({"apiKeyEnv":"UNMANAGED"}),
] {
let mut edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
edit.provider = configuration.as_object().unwrap().clone();
assert!(save_harness_editor(&home, &models, 8317, edit).is_err());
assert_eq!(config_images(&paths).unwrap(), before);
}
fs::remove_dir_all(home).unwrap();
}
#[test]
fn harness_imports_external_edits_and_keeps_default_selection_and_other_settings() {
let home = agent_test_home("harness-catalog-external");
let models = models();
apply(&home, &models);
let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home);
let stale = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
let mut document = root(&home);
document["agent-default-model"]["reasoningEffort"] = json!("high");
document["unrelated"] = json!({"enabled":true});
document["llm-pi-ai"]["providers"]["another"] = json!({"apiKeyEnv":"OTHER"});
document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0]["name"] =
json!("Manual name");
document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0]["input"] =
json!(["text"]);
document["llm-pi-ai"]["providers"][DEEPSEEK_HARNESS_PROVIDER_ID]["models"][0]
["futureExtension"] = json!({"keep":true});
fs::write(&paths[0], serde_norway::to_string(&document).unwrap()).unwrap();
assert!(save_harness_editor(&home, &models, 8317, stale).is_err());
let edit = request(harness_editor_snapshot(&home, &models, 8317).unwrap());
assert_eq!(edit.models[0].configuration["name"], "Manual name");
save_harness_editor(&home, &models, 8317, edit).unwrap();
apply(&home, &models);
assert_eq!(
root(&home)["agent-default-model"]["reasoningEffort"],
"high"
);
assert_eq!(root(&home)["unrelated"], json!({"enabled":true}));
assert_eq!(
provider(&home)["models"][0]["futureExtension"],
json!({"keep":true})
);
assert_eq!(provider(&home)["models"][0]["input"], json!(["text"]));
fs::remove_dir_all(home).unwrap();
}
#[test]
fn harness_state_failure_rolls_back_settings_and_credentials() {
let home = agent_test_home("harness-catalog-rollback");
apply(&home, &models());
let mut changed = models();
changed[0].input_modalities = Some(vec!["text".into()]);
let (paths, before, after) =
prepare_deepseek_harness_configuration(&home, 8317, "new-key", "vision", &changed).unwrap();
let mut failed = false;
let result = commit_config_with_writer(
"deepseek-harness",
&paths,
&before,
&after,
"update",
Some("vision".into()),
None,
&mut |client, images| {
for image in images {
if image.0 == paths[2] && !failed {
failed = true;
return Err("simulated failure".into());
}
write_config_images(client, &vec![image.clone()])?;
}
Ok(())
},
);
assert!(result.is_err());
assert_eq!(config_images(&paths).unwrap(), before);
fs::remove_dir_all(home).unwrap();
}
#[test]
fn harness_template_and_backup_restore_reconcile_catalog_state() {
let home = agent_test_home("harness-catalog-template");
let paths = agent_config_paths(AgentClient::DeepSeekHarness, &home);
let before = config_images(&paths).unwrap();
let settings =
build_deepseek_harness_settings(None, "http://127.0.0.1:8317/v1", "vision", &models())
.unwrap();
let after = vec![
(paths[0].clone(), Some(settings.into_bytes())),
(
paths[1].clone(),
Some(
build_deepseek_harness_credentials(None, "test-key")
.unwrap()
.into_bytes(),
),
),
];
commit_config(
"deepseek-harness",
&paths,
&before,
&after,
"template",
Some("vision".into()),
)
.unwrap();
assert!(harness_editor_snapshot(&home, &models(), 8317)
.unwrap()
.models
.iter()
.all(|m| m.configuration.is_empty()));
let state_path = deepseek_harness_catalog_state_path(&paths).unwrap();
assert!(state_path.is_file());
commit_config(
"deepseek-harness",
&paths,
&after,
&after,
"restore",
Some("vision".into()),
)
.unwrap();
assert!(!state_path.exists());
assert_eq!(
harness_editor_snapshot(&home, &models(), 8317)
.unwrap()
.models[0]
.configuration["input"],
json!(["text", "image"])
);
fs::remove_dir_all(home).unwrap();
}
+2
View File
@@ -17,6 +17,8 @@ pub(super) fn test_agent_models(names: &[&str]) -> Vec<AgentModelOption> {
names
.iter()
.map(|name| AgentModelOption {
input_modalities: None,
harness_metadata: None,
name: (*name).to_string(),
alias: None,
is_alias: false,
+98
View File
@@ -63,6 +63,104 @@ export const jaOverrides = {
'config.software.defaultTerminal': '既定のターミナル',
'config.software.defaultTerminalDescription': 'CLI エージェントの起動に使用するターミナルアプリ',
'config.software.terminal.auto': '自動選択',
"agents.harness.button": "モデル設定",
"agents.harness.providerInput": "プロバイダーの既定入力を使用",
"agents.harness.defaultModel": "デフォルトモデル",
"agents.harness.defaultHint": "新しいセッションの初期モデルです。DSH 内で自由に切り替えられます。",
"agents.harness.title": "DeepSeek Harness モデル設定",
"agents.harness.subtitle": "API のモデル一覧を取得し、モデル能力とプロバイダー設定を調整します。",
"agents.harness.provider": "プロバイダー設定",
"agents.harness.auto": "自動",
"agents.harness.apiSource": "API に従う",
"agents.harness.inheritHint": "空欄は API または DSH の既定値に従います。",
"agents.harness.apiInput": "API の入力形式:{value}",
"agents.harness.unknown": "未提供(非対応とは限りません)",
"agents.harness.managedConnection": "接続先:{url}。URL と認証は EasyCLIProxyAPI が管理します。",
"agents.harness.reset": "自動に戻す",
"agents.harness.resetGroup": "このグループをリセット",
"agents.harness.yes": "有効",
"agents.harness.no": "無効",
"agents.harness.textOnly": "テキストのみ",
"agents.harness.textImage": "テキストと画像",
"agents.harness.reasoningDisabled": "推論なし",
"agents.harness.reasoningCustom": "推論レベルを指定",
"agents.harness.noWireValue": "パラメーターを省略",
"agents.harness.invalid": "設定値を確認してください:{field}",
"agents.harness.changed": "モデル一覧または設定が変更されました。再読み込みしてください。",
"agents.harness.saved": "保存しました。次のリクエストから適用されます。デフォルトモデルは変更されません。",
"agents.harness.savedDraft": "保存しました。DSH 接続時に適用されます。",
"agents.harness.saveHint": "デフォルトモデルを変更せずに一覧の設定を保存します。",
"agents.harness.hint.input": "API の情報を使用し、未提供の場合はプロバイダーの既定値に従います。",
"agents.harness.hint.defaultInput": "API に入力情報がないモデルの既定値です。画像を有効にして、例外をモデルごとに変更できます。",
"agents.harness.hint.maxTokens": "設定すると、リクエストごとの既定の出力上限にもなります。",
"agents.harness.hint.defaultMaxTokens": "モデル能力の既定値です。リクエストの出力制限にはなりません。",
"agents.harness.hint.api": "全モデルに同じプロトコルを使用します。変更後は互換性設定を確認してください。",
"agents.harness.hint.headers": "ヘッダー名と値の JSON オブジェクト。空欄は既定値を使用します。",
"agents.harness.hint.reasoningEfforts": "利用可能なレベルと送信する reasoning_effort 値を指定します。off のみ空欄にできます。",
"agents.harness.hint.compat": "現在のプロトコルの互換性設定です。モデル設定が優先され、自動値は DSH が決定します。",
"agents.harness.hint.thinkingBudgets": "推論予算に対応するプロバイダーの各レベルのトークン数。",
"agents.harness.hint.retryPolicy": "normal は既定で最大 5 回再試行します。always は成功またはキャンセルまで続けます。",
"agents.harness.hint.backoff": "再試行間隔とジッターを設定します。初期間隔は最大間隔以下にしてください。",
"agents.harness.field.name": "表示名",
"agents.harness.field.contextWindow": "コンテキスト(tokens)",
"agents.harness.field.maxTokens": "出力上限(tokens)",
"agents.harness.field.input": "入力形式",
"agents.harness.field.reasoningEfforts": "推論レベル",
"agents.harness.field.compat": "プロトコル互換性",
"agents.harness.field.displayName": "プロバイダー名",
"agents.harness.field.api": "API プロトコル",
"agents.harness.field.defaultContextWindow": "既定コンテキスト(tokens)",
"agents.harness.field.defaultMaxTokens": "既定出力能力(tokens)",
"agents.harness.field.defaultInput": "既定入力形式",
"agents.harness.field.reasoning": "既定推論レベル",
"agents.harness.field.thinkingBudgets": "推論予算(tokens)",
"agents.harness.field.cacheRetention": "キャッシュ保持",
"agents.harness.field.transport": "ストリーミング方式",
"agents.harness.field.timeoutMs": "リクエストタイムアウト(ms)",
"agents.harness.field.websocketConnectTimeoutMs": "WebSocket 接続タイムアウト(ms)",
"agents.harness.field.streamIdleTimeoutMs": "ストリーム待機タイムアウト(ms)",
"agents.harness.field.maxRequestImageBytes": "リクエスト画像合計(bytes)",
"agents.harness.field.requestImagePixelBudget": "画像ピクセル予算",
"agents.harness.field.requestImageMaxBytes": "画像サイズ(bytes)",
"agents.harness.field.headers": "追加ヘッダー(JSON)",
"agents.harness.field.retryPolicy": "再試行ポリシー",
"agents.harness.field.minimal": "minimal",
"agents.harness.field.low": "low",
"agents.harness.field.medium": "medium",
"agents.harness.field.high": "high",
"agents.harness.field.mode": "再試行モード",
"agents.harness.field.maxRetries": "最大再試行回数",
"agents.harness.field.retryableCodes": "再試行するエラー(JSON)",
"agents.harness.field.backoff": "再試行間隔",
"agents.harness.field.initialDelayMs": "初期間隔(ms)",
"agents.harness.field.maxDelayMs": "最大間隔(ms)",
"agents.harness.field.jitterRatio": "ジッター比率(0–1)",
"agents.harness.field.supportsStore": "supportsStore",
"agents.harness.field.supportsDeveloperRole": "supportsDeveloperRole",
"agents.harness.field.supportsReasoningEffort": "supportsReasoningEffort",
"agents.harness.field.supportsUsageInStreaming": "supportsUsageInStreaming",
"agents.harness.field.supportsFinishReason": "supportsFinishReason",
"agents.harness.field.maxTokensField": "maxTokensField",
"agents.harness.field.requiresToolResultName": "requiresToolResultName",
"agents.harness.field.requiresAssistantAfterToolResult": "requiresAssistantAfterToolResult",
"agents.harness.field.requiresThinkingAsText": "requiresThinkingAsText",
"agents.harness.field.requiresReasoningContentOnAssistantMessages": "requiresReasoningContentOnAssistantMessages",
"agents.harness.field.thinkingFormat": "thinkingFormat",
"agents.harness.field.chatTemplateKwargs": "chatTemplateKwargs",
"agents.harness.field.chatTemplateArgs": "chatTemplateArgs",
"agents.harness.field.supportsThinkingTokenBudget": "supportsThinkingTokenBudget",
"agents.harness.field.thinkingTokenBudgetField": "thinkingTokenBudgetField",
"agents.harness.field.vllmPriority": "vllmPriority",
"agents.harness.field.supportsMaxOutputTokens": "supportsMaxOutputTokens",
"agents.harness.field.supportsStrictMode": "supportsStrictMode",
"agents.harness.field.cacheControlFormat": "cacheControlFormat",
"agents.harness.field.supportsLongCacheRetention": "supportsLongCacheRetention",
"agents.harness.field.supportsEagerToolInputStreaming": "supportsEagerToolInputStreaming",
"agents.harness.field.supportsCacheControlOnTools": "supportsCacheControlOnTools",
"agents.harness.field.supportsTemperature": "supportsTemperature",
"agents.harness.field.forceAdaptiveThinking": "forceAdaptiveThinking",
"agents.harness.field.allowEmptySignature": "allowEmptySignature",
"agents.harness.field.supportsStrictTools": "supportsStrictTools",
'agents.catalog.button': 'モデル一覧の設定',
'agents.catalog.title': 'Codex モデル一覧',
'agents.catalog.subtitle': 'コンテキストの既定値は CPA コア API から取得します。モデルのパラメーターを編集し、Codex モデル一覧に保存できます。',
+98
View File
@@ -13,6 +13,104 @@ export const en: Record<MessageKey, string> = {
'apiAccess.cloak.never': 'Never enabled',
'usage.unit.requests': 'requests',
'usage.unit.tokens': 'Token',
"agents.harness.button": "Configure models",
"agents.harness.providerInput": "Use provider default input",
"agents.harness.defaultModel": "Default model",
"agents.harness.defaultHint": "Used for new sessions. You can switch to any catalog model inside DSH.",
"agents.harness.title": "DeepSeek Harness model configuration",
"agents.harness.subtitle": "Load the model catalog from the API and customize models and provider settings.",
"agents.harness.provider": "Provider settings",
"agents.harness.auto": "Automatic",
"agents.harness.apiSource": "Follow API",
"agents.harness.inheritHint": "Leave fields empty to follow the API or DSH defaults.",
"agents.harness.apiInput": "API input modalities: {value}",
"agents.harness.unknown": "Not provided (capability unknown)",
"agents.harness.managedConnection": "Endpoint: {url}. EasyCLIProxyAPI manages the endpoint and credentials.",
"agents.harness.reset": "Restore automatic",
"agents.harness.resetGroup": "Reset this group",
"agents.harness.yes": "Enabled",
"agents.harness.no": "Disabled",
"agents.harness.textOnly": "Text only",
"agents.harness.textImage": "Text and image",
"agents.harness.reasoningDisabled": "No reasoning",
"agents.harness.reasoningCustom": "Custom reasoning levels",
"agents.harness.noWireValue": "Omit parameter",
"agents.harness.invalid": "Check the configuration value: {field}",
"agents.harness.changed": "The model list or configuration changed. Reload before saving.",
"agents.harness.saved": "Saved. Applies on the next model request; the default model is unchanged.",
"agents.harness.savedDraft": "Saved. These settings will be used when connecting DSH.",
"agents.harness.saveHint": "Save catalog settings without changing the selected default model.",
"agents.harness.hint.input": "Use API metadata automatically; otherwise inherit the provider default input.",
"agents.harness.hint.defaultInput": "Fallback for models without API input metadata. Set text and image here, then override exceptions per model.",
"agents.harness.hint.maxTokens": "An explicit value also becomes the model’s default output limit per request.",
"agents.harness.hint.defaultMaxTokens": "Fallback output capacity; does not set a per-request output limit.",
"agents.harness.hint.api": "One protocol applies to the whole provider. Review compatibility settings after changing it.",
"agents.harness.hint.headers": "JSON object of header names and values; empty uses default headers.",
"agents.harness.hint.reasoningEfforts": "Choose offered levels and their wire reasoning_effort values. Only off may have an empty wire value.",
"agents.harness.hint.compat": "Compatibility switches for the current protocol. Model settings override provider settings; DSH resolves automatic values.",
"agents.harness.hint.thinkingBudgets": "Token budgets for providers that support reasoning budgets.",
"agents.harness.hint.retryPolicy": "Normal retries up to five times by default. Always retries until success or cancellation.",
"agents.harness.hint.backoff": "Configure retry delays and jitter. Initial delay must not exceed maximum delay.",
"agents.harness.field.name": "Display name",
"agents.harness.field.contextWindow": "Context window (tokens)",
"agents.harness.field.maxTokens": "Maximum output (tokens)",
"agents.harness.field.input": "Input modalities",
"agents.harness.field.reasoningEfforts": "Reasoning levels",
"agents.harness.field.compat": "Protocol compatibility",
"agents.harness.field.displayName": "Provider name",
"agents.harness.field.api": "API protocol",
"agents.harness.field.defaultContextWindow": "Default context (tokens)",
"agents.harness.field.defaultMaxTokens": "Default output capacity (tokens)",
"agents.harness.field.defaultInput": "Default input modalities",
"agents.harness.field.reasoning": "Default reasoning level",
"agents.harness.field.thinkingBudgets": "Thinking budgets (tokens)",
"agents.harness.field.cacheRetention": "Cache retention",
"agents.harness.field.transport": "Streaming transport",
"agents.harness.field.timeoutMs": "Request timeout (ms)",
"agents.harness.field.websocketConnectTimeoutMs": "WebSocket connection timeout (ms)",
"agents.harness.field.streamIdleTimeoutMs": "Stream idle timeout (ms)",
"agents.harness.field.maxRequestImageBytes": "Total request image budget (bytes)",
"agents.harness.field.requestImagePixelBudget": "Pixels per image",
"agents.harness.field.requestImageMaxBytes": "Encoded bytes per image",
"agents.harness.field.headers": "Additional headers (JSON)",
"agents.harness.field.retryPolicy": "Retry policy",
"agents.harness.field.minimal": "minimal",
"agents.harness.field.low": "low",
"agents.harness.field.medium": "medium",
"agents.harness.field.high": "high",
"agents.harness.field.mode": "Retry mode",
"agents.harness.field.maxRetries": "Maximum retries",
"agents.harness.field.retryableCodes": "Retryable error codes (JSON)",
"agents.harness.field.backoff": "Retry backoff",
"agents.harness.field.initialDelayMs": "Initial delay (ms)",
"agents.harness.field.maxDelayMs": "Maximum delay (ms)",
"agents.harness.field.jitterRatio": "Jitter ratio (0–1)",
"agents.harness.field.supportsStore": "supportsStore",
"agents.harness.field.supportsDeveloperRole": "supportsDeveloperRole",
"agents.harness.field.supportsReasoningEffort": "supportsReasoningEffort",
"agents.harness.field.supportsUsageInStreaming": "supportsUsageInStreaming",
"agents.harness.field.supportsFinishReason": "supportsFinishReason",
"agents.harness.field.maxTokensField": "maxTokensField",
"agents.harness.field.requiresToolResultName": "requiresToolResultName",
"agents.harness.field.requiresAssistantAfterToolResult": "requiresAssistantAfterToolResult",
"agents.harness.field.requiresThinkingAsText": "requiresThinkingAsText",
"agents.harness.field.requiresReasoningContentOnAssistantMessages": "requiresReasoningContentOnAssistantMessages",
"agents.harness.field.thinkingFormat": "thinkingFormat",
"agents.harness.field.chatTemplateKwargs": "chatTemplateKwargs",
"agents.harness.field.chatTemplateArgs": "chatTemplateArgs",
"agents.harness.field.supportsThinkingTokenBudget": "supportsThinkingTokenBudget",
"agents.harness.field.thinkingTokenBudgetField": "thinkingTokenBudgetField",
"agents.harness.field.vllmPriority": "vllmPriority",
"agents.harness.field.supportsMaxOutputTokens": "supportsMaxOutputTokens",
"agents.harness.field.supportsStrictMode": "supportsStrictMode",
"agents.harness.field.cacheControlFormat": "cacheControlFormat",
"agents.harness.field.supportsLongCacheRetention": "supportsLongCacheRetention",
"agents.harness.field.supportsEagerToolInputStreaming": "supportsEagerToolInputStreaming",
"agents.harness.field.supportsCacheControlOnTools": "supportsCacheControlOnTools",
"agents.harness.field.supportsTemperature": "supportsTemperature",
"agents.harness.field.forceAdaptiveThinking": "forceAdaptiveThinking",
"agents.harness.field.allowEmptySignature": "allowEmptySignature",
"agents.harness.field.supportsStrictTools": "supportsStrictTools",
'agents.catalog.button': 'Model list settings',
'agents.catalog.title': 'Codex model catalog',
'agents.catalog.subtitle': 'Default context settings come from the CPA core API. Customize model parameters and save them to the Codex catalog.',
+98
View File
@@ -12,6 +12,104 @@ export const zhCN = {
'apiAccess.cloak.never': '从不启用',
'usage.unit.requests': '次请求',
'usage.unit.tokens': 'Token',
"agents.harness.button": "配置模型",
"agents.harness.providerInput": "使用供应商默认输入",
"agents.harness.defaultModel": "默认模型",
"agents.harness.defaultHint": "仅作为新会话的默认模型;启动 DSH 后仍可自由切换整个目录中的模型。",
"agents.harness.title": "DeepSeek Harness 模型配置",
"agents.harness.subtitle": "从 API 获取整个模型目录,按需覆盖模型能力和供应商参数。",
"agents.harness.provider": "供应商设置",
"agents.harness.auto": "自动",
"agents.harness.apiSource": "跟随 API",
"agents.harness.inheritHint": "留空即跟随 API 或 DSH 默认值。",
"agents.harness.apiInput": "API 声明的输入类型:{value}",
"agents.harness.unknown": "未提供(不代表不支持)",
"agents.harness.managedConnection": "连接地址:{url}。地址和鉴权由 EasyCLIProxyAPI 管理。",
"agents.harness.reset": "恢复自动",
"agents.harness.resetGroup": "此组恢复自动",
"agents.harness.yes": "开启",
"agents.harness.no": "关闭",
"agents.harness.textOnly": "仅文本",
"agents.harness.textImage": "文本和图像",
"agents.harness.reasoningDisabled": "不支持推理",
"agents.harness.reasoningCustom": "自定义推理等级",
"agents.harness.noWireValue": "不发送参数",
"agents.harness.invalid": "请检查配置值:{field}",
"agents.harness.changed": "模型列表或配置已变化,请重新加载后再保存。",
"agents.harness.saved": "已保存,下次模型请求时生效;默认模型保持不变。",
"agents.harness.savedDraft": "已保存,接入 DSH 时会使用这些配置。",
"agents.harness.saveHint": "保存整个目录的配置,不改变页面选择的默认模型。",
"agents.harness.hint.input": "自动使用 API 声明;未声明时继承供应商默认输入类型。",
"agents.harness.hint.defaultInput": "仅用于 API 未声明输入类型的模型。可统一设为文本和图像,再单独修正例外模型。",
"agents.harness.hint.maxTokens": "手动设置后也会成为该模型每次请求的默认输出上限。",
"agents.harness.hint.defaultMaxTokens": "模型的输出能力兜底值,不会自动成为每次请求的输出限制。",
"agents.harness.hint.api": "整个供应商使用同一种协议;切换协议后请检查兼容选项。",
"agents.harness.hint.headers": "JSON 键值对象;留空使用默认请求头。",
"agents.harness.hint.reasoningEfforts": "勾选 DSH 中可选的等级,并填写实际发送的 reasoning_effort 值。只有 off 可留空。",
"agents.harness.hint.compat": "仅显示当前协议的兼容开关。单个模型的设置优先于供应商设置,自动值由 DSH 决定。",
"agents.harness.hint.thinkingBudgets": "为支持推理预算的供应商指定各等级的 token 预算。",
"agents.harness.hint.retryPolicy": "normal 默认最多重试 5 次;always 会持续重试直到成功或取消。",
"agents.harness.hint.backoff": "配置重试间隔与抖动;初始间隔不能大于最大间隔。",
"agents.harness.field.name": "显示名称",
"agents.harness.field.contextWindow": "上下文窗口(tokens)",
"agents.harness.field.maxTokens": "输出上限(tokens)",
"agents.harness.field.input": "输入类型",
"agents.harness.field.reasoningEfforts": "推理等级",
"agents.harness.field.compat": "协议兼容选项",
"agents.harness.field.displayName": "供应商名称",
"agents.harness.field.api": "API 协议",
"agents.harness.field.defaultContextWindow": "默认上下文(tokens)",
"agents.harness.field.defaultMaxTokens": "默认输出能力(tokens)",
"agents.harness.field.defaultInput": "默认输入类型",
"agents.harness.field.reasoning": "默认推理等级",
"agents.harness.field.thinkingBudgets": "推理预算(tokens)",
"agents.harness.field.cacheRetention": "缓存保留",
"agents.harness.field.transport": "流式传输方式",
"agents.harness.field.timeoutMs": "请求超时(ms)",
"agents.harness.field.websocketConnectTimeoutMs": "WebSocket 连接超时(ms)",
"agents.harness.field.streamIdleTimeoutMs": "流式空闲超时(ms)",
"agents.harness.field.maxRequestImageBytes": "请求图像总预算(bytes)",
"agents.harness.field.requestImagePixelBudget": "单图像素预算",
"agents.harness.field.requestImageMaxBytes": "单图编码预算(bytes)",
"agents.harness.field.headers": "额外请求头(JSON)",
"agents.harness.field.retryPolicy": "重试策略",
"agents.harness.field.minimal": "minimal",
"agents.harness.field.low": "low",
"agents.harness.field.medium": "medium",
"agents.harness.field.high": "high",
"agents.harness.field.mode": "重试模式",
"agents.harness.field.maxRetries": "最大重试次数",
"agents.harness.field.retryableCodes": "可重试错误码(JSON)",
"agents.harness.field.backoff": "重试间隔",
"agents.harness.field.initialDelayMs": "初始间隔(ms)",
"agents.harness.field.maxDelayMs": "最大间隔(ms)",
"agents.harness.field.jitterRatio": "抖动比例(0–1)",
"agents.harness.field.supportsStore": "supportsStore",
"agents.harness.field.supportsDeveloperRole": "supportsDeveloperRole",
"agents.harness.field.supportsReasoningEffort": "supportsReasoningEffort",
"agents.harness.field.supportsUsageInStreaming": "supportsUsageInStreaming",
"agents.harness.field.supportsFinishReason": "supportsFinishReason",
"agents.harness.field.maxTokensField": "maxTokensField",
"agents.harness.field.requiresToolResultName": "requiresToolResultName",
"agents.harness.field.requiresAssistantAfterToolResult": "requiresAssistantAfterToolResult",
"agents.harness.field.requiresThinkingAsText": "requiresThinkingAsText",
"agents.harness.field.requiresReasoningContentOnAssistantMessages": "requiresReasoningContentOnAssistantMessages",
"agents.harness.field.thinkingFormat": "thinkingFormat",
"agents.harness.field.chatTemplateKwargs": "chatTemplateKwargs",
"agents.harness.field.chatTemplateArgs": "chatTemplateArgs",
"agents.harness.field.supportsThinkingTokenBudget": "supportsThinkingTokenBudget",
"agents.harness.field.thinkingTokenBudgetField": "thinkingTokenBudgetField",
"agents.harness.field.vllmPriority": "vllmPriority",
"agents.harness.field.supportsMaxOutputTokens": "supportsMaxOutputTokens",
"agents.harness.field.supportsStrictMode": "supportsStrictMode",
"agents.harness.field.cacheControlFormat": "cacheControlFormat",
"agents.harness.field.supportsLongCacheRetention": "supportsLongCacheRetention",
"agents.harness.field.supportsEagerToolInputStreaming": "supportsEagerToolInputStreaming",
"agents.harness.field.supportsCacheControlOnTools": "supportsCacheControlOnTools",
"agents.harness.field.supportsTemperature": "supportsTemperature",
"agents.harness.field.forceAdaptiveThinking": "forceAdaptiveThinking",
"agents.harness.field.allowEmptySignature": "allowEmptySignature",
"agents.harness.field.supportsStrictTools": "supportsStrictTools",
'agents.catalog.button': '模型列表设置',
'agents.catalog.title': 'Codex 模型列表',
'agents.catalog.subtitle': '默认上下文参数读取自 CPA 内核 API;可自定义当前模型参数并保存到 Codex 模型列表。',
+21 -3
View File
@@ -63,6 +63,7 @@ import type { ModelOption } from '../services/modelService';
import { getCurrentLocale, translate, useI18n } from '../i18n';
import { CodexSessionsPanel } from './CodexSessionsPanel';
import { CodexModelCatalogDialog } from './CodexModelCatalogDialog';
import { DeepSeekHarnessCatalogDialog } from './DeepSeekHarnessCatalogDialog';
import { AgentConfigBackupDialog } from './AgentConfigBackupDialog';
import { AgentConfigManagementPanel, AgentConfigurationFeedback, AgentRunControls } from './AgentControls';
@@ -753,6 +754,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const [launchDirectoryDialogOpen, setLaunchDirectoryDialogOpen] = useState(false);
const [codexCatalogDialogOpen, setCodexCatalogDialogOpen] = useState(false);
const [harnessCatalogDialogOpen, setHarnessCatalogDialogOpen] = useState(false);
const [launchDirectory, setLaunchDirectory] = useState('');
const [launchDirectoryTarget, setLaunchDirectoryTarget] = useState<AgentLaunchTarget | null>(null);
const [launchDirectoryError, setLaunchDirectoryError] = useState('');
@@ -1326,6 +1328,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
claudeModelMappingsDirtyRef.current[selected] = false;
}
await reloadStatusesAfterAction();
if (isDeepSeekHarnessClient) await loadModels('deepseek-harness');
setOauthConfigurationDraft(null);
setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.backup.unchanged' : 'agents.backup.updated'));
onConfigurationApplied?.();
@@ -1686,6 +1689,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
</button>
) : null;
const harnessCatalogButton = isDeepSeekHarnessClient ? (
<button type="button" className="secondary-button agent-codex-catalog-button"
onClick={() => setHarnessCatalogDialogOpen(true)} disabled={busy || configurationWriteBlocked}>
<SlidersHorizontal size={16} />{t('agents.harness.button')}
</button>
) : null;
return (
<section className={`page management-page agents-page${embedded ? ' agents-page-embedded' : ''}`}>
<header className="management-header">
@@ -1796,7 +1806,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ? <p className="agent-inline-message warning">{t('agents.backup.mappingRequired')}</p> : null}
<div className="agent-minimal-field">
<label htmlFor="embedded-agent-model">{t('agents.useModel')}</label>
<label htmlFor="embedded-agent-model">{t(isDeepSeekHarnessClient ? 'agents.harness.defaultModel' : 'agents.useModel')}</label>
<AgentModelPicker
models={isClaudeModelMappingClient ? claudeMappingModels : models}
value={isClaudeModelMappingClient ? claudeModelMappingsDraft.sonnet : selectedModel}
@@ -1806,9 +1816,11 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
onChange={selectEmbeddedModel}
onRefresh={refreshModels}
/>
{codexCatalogButton}
{codexCatalogButton}{harnessCatalogButton}
</div>
{isDeepSeekHarnessClient ? <p className="agent-model-hint">{t('agents.harness.defaultHint')}</p> : null}
<div className="agent-minimal-actions">
<button
type="button"
@@ -1894,7 +1906,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{!isClaudeModelMappingClient ? (
<section className="agent-core-setting-section agent-model-section">
<div className="agent-section-heading">
<div><strong>{t('agents.useModel')}</strong></div>
<div><strong>{t(isDeepSeekHarnessClient ? 'agents.harness.defaultModel' : 'agents.useModel')}</strong></div>
</div>
<AgentModelPicker
models={models}
@@ -1914,6 +1926,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
{modelHint}
</span>
) : null}
{isDeepSeekHarnessClient ? <p className="agent-model-hint">{t('agents.harness.defaultHint')}</p> : null}
{harnessCatalogButton}
{selected === 'codex' ? (
<div className="agent-codex-options">
<div className="agent-auth-method">
@@ -2140,6 +2154,10 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
setConfigurationNotice(t('agents.backup.restored'));
}} /> : null}
{harnessCatalogDialogOpen ? (
<DeepSeekHarnessCatalogDialog onClose={() => setHarnessCatalogDialogOpen(false)}
onSaved={async () => { await loadModels('deepseek-harness', selectedModel); await loadStatuses(true); }} />
) : null}
{codexCatalogDialogOpen ? (
<CodexModelCatalogDialog
onClose={() => setCodexCatalogDialogOpen(false)}
+169
View File
@@ -0,0 +1,169 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { LoaderCircle, RefreshCw, RotateCcw, Search, Settings2, X } from 'lucide-react';
import { useI18n } from '../i18n';
import type { MessageKey } from '../i18n/resources';
import {
harnessDraft, harnessReasoningLevels, harnessSchema, isHarnessRecord, parseHarnessDraft, sameHarnessDraft, updateHarnessDraft,
type HarnessDraft, type HarnessEditorSnapshot, type HarnessProfile,
} from '../services/deepSeekHarnessCatalog';
function HarnessFields({ group, draft, defaults = {}, prefix = '', api, onChange }: {
group: string; draft: HarnessDraft; defaults?: HarnessProfile; prefix?: string; api: string;
onChange: (next: HarnessDraft) => void;
}) {
const { t } = useI18n();
const label = (name: string) => t(`agents.harness.field.${name}` as MessageKey);
return <div className="codex-catalog-form harness-catalog-form">{harnessSchema[group].map(field => {
const key = `${prefix}${field.name}`;
const value = draft[key] ?? '';
const supplied = defaults[field.name] ?? field.default;
const automatic = `${t('agents.harness.auto')}${supplied === undefined ? '' : ` (${typeof supplied === 'string' ? supplied : JSON.stringify(supplied)})`}`;
const set = (next: string) => onChange(updateHarnessDraft(draft, key, next));
if (field.apis && !field.apis.includes(api) && !value) return null;
if (field.group) {
const customized = Object.keys(draft).some(k => k.startsWith(`${key}.`));
return <details className="harness-catalog-group" key={key}>
<summary>{label(field.name)}{customized ? ` · ${t('agents.catalog.customized')}` : ''}</summary>
<p className="codex-catalog-hint">{t(`agents.harness.hint.${field.name}` as MessageKey)}</p>
<HarnessFields group={field.group} draft={draft} defaults={isHarnessRecord(supplied) ? supplied : {}} prefix={`${key}.`} api={api} onChange={onChange} />
{customized ? <button type="button" className="secondary-button" onClick={() => onChange(Object.fromEntries(Object.entries(draft).filter(([k]) => !k.startsWith(`${key}.`))))}>{t('agents.harness.resetGroup')}</button> : null}
</details>;
}
if (field.kind === 'reasoning') {
let mapping: HarnessProfile = {};
try { const parsed: unknown = JSON.parse(value || '{}'); if (isHarnessRecord(parsed)) mapping = parsed; } catch {}
return <div className="harness-reasoning wide" key={key}>
<label><span>{label(field.name)}</span><select aria-label={label(field.name)} value={!value ? '' : value === 'false' ? 'false' : 'custom'}
onChange={e => set(e.currentTarget.value === 'custom' ? JSON.stringify({ off: null, low: 'low', medium: 'medium', high: 'high' }) : e.currentTarget.value)}>
<option value="">{automatic}</option><option value="false">{t('agents.harness.reasoningDisabled')}</option><option value="custom">{t('agents.harness.reasoningCustom')}</option>
</select></label>
{value && value !== 'false' ? <div className="harness-reasoning-levels">{harnessReasoningLevels.map(level => <div key={level}>
<label><input type="checkbox" checked={Object.prototype.hasOwnProperty.call(mapping, level)} onChange={e => {
const next = { ...mapping }; if (e.currentTarget.checked) next[level] = level === 'off' ? null : level; else delete next[level]; set(JSON.stringify(next));
}} />{level}</label>
{Object.prototype.hasOwnProperty.call(mapping, level) ? <input aria-label={`${level} reasoning_effort`} value={String(mapping[level] ?? '')} placeholder={level === 'off' ? t('agents.harness.noWireValue') : level}
onChange={e => set(JSON.stringify({ ...mapping, [level]: level === 'off' && !e.currentTarget.value ? null : e.currentTarget.value }))} /> : null}
</div>)}</div> : null}
<small>{t('agents.harness.hint.reasoningEfforts')}</small>
</div>;
}
const jsonField = ['headers', 'kwargs', 'strings'].includes(field.kind);
return <label key={key} className={jsonField ? 'wide' : undefined}>
<span title={field.name}>{label(field.name)}{Object.prototype.hasOwnProperty.call(draft, key) ? <small className="harness-override">{t('agents.catalog.customized')}</small> : null}</span>
{['enum', 'boolean', 'modalities'].includes(field.kind) ? <select aria-label={label(field.name)} value={value} onChange={e => set(e.currentTarget.value)}>
<option value="">{automatic}</option>
{field.kind === 'boolean' ? <><option value="true">{t('agents.harness.yes')}</option><option value="false">{t('agents.harness.no')}</option></> : null}
{field.kind === 'enum' ? field.values!.map(v => <option key={v} value={v}>{v}</option>) : null}
{field.kind === 'modalities' ? <>{field.name === 'input' ? <option value="[]">{t('agents.harness.providerInput')}</option> : null}<option value='["text"]'>{t('agents.harness.textOnly')}</option><option value='["text","image"]'>{t('agents.harness.textImage')}</option><option value='["image"]'>{t('agents.catalog.image')}</option></> : null}
</select> : jsonField ? <textarea aria-label={label(field.name)} rows={4} spellCheck={false} value={value} placeholder={JSON.stringify(supplied ?? field.example, null, 2) ?? automatic} onChange={e => set(e.currentTarget.value)} />
: <input aria-label={label(field.name)} type={['integer', 'number'].includes(field.kind) ? 'number' : 'text'} min={field.min ?? field.exclusiveMin} max={field.max} step={field.kind === 'integer' ? 1 : 'any'} value={value} placeholder={automatic} onChange={e => set(e.currentTarget.value)} />}
{['input', 'defaultInput', 'maxTokens', 'defaultMaxTokens', 'api', 'headers'].includes(field.name) ? <small>{t(`agents.harness.hint.${field.name}` as MessageKey)}</small> : null}
</label>;
})}</div>;
}
export function DeepSeekHarnessCatalogDialog({ onClose, onSaved }: { onClose: () => void; onSaved: () => void | Promise<void> }) {
const { t } = useI18n();
const [snapshot, setSnapshot] = useState<HarnessEditorSnapshot | null>(null);
const [drafts, setDrafts] = useState<Record<string, HarnessDraft>>({});
const [provider, setProvider] = useState<HarnessDraft>({});
const [selected, setSelected] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [confirmation, setConfirmation] = useState<'close' | 'reload' | null>(null);
const dialogRef = useRef<HTMLElement>(null);
const install = (next: HarnessEditorSnapshot) => {
setSnapshot(next);
setDrafts(Object.fromEntries(next.models.map(m => [m.id, harnessDraft(m.configuration, 'model')])));
setProvider(harnessDraft(next.provider, 'provider'));
};
const load = useCallback(async () => {
setLoading(true); setError(''); setNotice('');
try {
const next = await invoke<HarnessEditorSnapshot>('get_deepseek_harness_model_catalog_editor');
install(next);
setSelected(current => current && next.models.some(m => m.id === current) ? current : next.defaultModel ?? next.models[0]?.id ?? null);
} catch (e) { setError(String(e)); }
finally { setLoading(false); }
}, []);
useEffect(() => { void load(); }, [load]);
const dirty = !!snapshot && (!sameHarnessDraft(provider, harnessDraft(snapshot.provider, 'provider'))
|| snapshot.models.some(m => !sameHarnessDraft(drafts[m.id] ?? {}, harnessDraft(m.configuration, 'model'))));
const close = () => { if (!saving) { if (dirty) setConfirmation('close'); else onClose(); } };
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
dialogRef.current?.querySelector<HTMLElement>('button')?.focus();
return () => previous?.focus();
}, []);
useEffect(() => {
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') { event.stopPropagation(); if (confirmation) setConfirmation(null); else close(); }
if (event.key === 'Tab') {
const scope = confirmation ? dialogRef.current?.querySelector('[role="alertdialog"]') : dialogRef.current;
const targets = [...(scope?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), summary') ?? [])].filter(el => el.getClientRects().length);
const first = targets[0], last = targets[targets.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
});
const active = snapshot?.models.find(m => m.id === selected);
const api = provider.api || 'openai-completions';
const changed = () => { setNotice(''); setError(''); };
const save = async () => {
if (!snapshot || saving || loading) return;
let request;
try {
let configuration;
try { configuration = parseHarnessDraft(provider, 'provider', api); }
catch (e) { setSelected(null); throw e; }
request = { revision: snapshot.revision, provider: configuration, models: snapshot.models.map(m => {
try { return { ...m, configuration: parseHarnessDraft(drafts[m.id] ?? {}, 'model', api) }; }
catch (e) { setSelected(m.id); throw e; }
}) };
} catch (e) { setError(t('agents.harness.invalid', { field: String(e).replace(/^Error: /, '') })); return; }
setSaving(true); setError(''); setNotice('');
try {
const next = await invoke<HarnessEditorSnapshot>('save_deepseek_harness_model_catalog_editor', { request });
install(next); setNotice(t(next.configured ? 'agents.harness.saved' : 'agents.harness.savedDraft'));
await onSaved();
} catch (e) { setError(String(e).includes('DSH_MODEL_CATALOG_CHANGED') ? t('agents.harness.changed') : String(e)); }
finally { setSaving(false); }
};
return <div className="config-dialog-backdrop codex-catalog-backdrop" onMouseDown={e => { if (e.target === e.currentTarget) close(); }}>
<section ref={dialogRef} className="config-dialog codex-catalog-dialog harness-catalog-dialog" role="dialog" aria-modal="true" aria-labelledby="harness-catalog-title">
<header className="config-dialog-heading codex-catalog-heading"><div><h2 id="harness-catalog-title">{t('agents.harness.title')}</h2><p>{t('agents.harness.subtitle')}</p></div><button className="icon-button quiet" onClick={close} disabled={saving} aria-label={t('common.close')}><X size={18} /></button></header>
<div className="codex-catalog-body">
<aside className="codex-catalog-sidebar">
<button className={`secondary-button harness-provider-button${selected === null ? ' active' : ''}`} onClick={() => setSelected(null)}><Settings2 size={16} />{t('agents.harness.provider')}</button>
<div className="codex-catalog-search"><Search size={15} /><input aria-label={t('agents.catalog.search')} value={search} placeholder={t('agents.catalog.search')} onChange={e => setSearch(e.currentTarget.value)} /></div>
<div className="codex-catalog-models" role="listbox" aria-label={t('agents.harness.title')}>
{loading ? <div className="codex-catalog-state"><LoaderCircle className="spin" size={18} />{t('agents.catalog.loading')}</div> : snapshot?.models.filter(m => `${m.id} ${drafts[m.id]?.name ?? m.defaults.name ?? ''}`.toLowerCase().includes(search.toLowerCase())).map(m => <button role="option" aria-selected={selected === m.id} className={selected === m.id ? 'active' : ''} key={m.id} onClick={() => setSelected(m.id)}>
<strong>{drafts[m.id]?.name || String(m.defaults.name ?? m.id)}</strong><span>{m.id}</span><small>{t(Object.keys(drafts[m.id] ?? {}).length ? 'agents.catalog.customized' : 'agents.harness.apiSource')}{m.id === snapshot.defaultModel ? ` · ${t('agents.harness.defaultModel')}` : ''}</small>
</button>)}
{!loading && snapshot?.models.length === 0 ? <div className="codex-catalog-state">{t('agents.catalog.empty')}</div> : null}
</div>
<button className="secondary-button codex-catalog-reload" disabled={loading || saving} onClick={() => dirty ? setConfirmation('reload') : void load()}><RefreshCw size={15} />{t('agents.catalog.reload')}</button>
</aside>
<main className="codex-catalog-editor"><fieldset disabled={loading || saving} className="harness-editor-fieldset">
{snapshot && (active || selected === null) ? <>
<div className="codex-catalog-model-heading"><div><h3>{active ? active.id : t('agents.harness.provider')}</h3><span>{t('agents.harness.inheritHint')}</span></div><button className="secondary-button" onClick={() => { if (active) setDrafts(current => ({ ...current, [active.id]: {} })); else setProvider({}); changed(); }}><RotateCcw size={14} />{t('agents.harness.reset')}</button></div>
{active ? <p className="codex-catalog-hint" role="note">{t('agents.harness.apiInput', { value: Array.isArray(active.defaults.input) ? active.defaults.input.join(', ') : t('agents.harness.unknown') })}</p> : <p className="codex-catalog-hint">{t('agents.harness.managedConnection', { url: api === 'anthropic-messages' ? snapshot.baseUrl.replace(/\/v1$/, '') : snapshot.baseUrl })}</p>}
<HarnessFields key={active?.id ?? 'provider'} group={active ? 'model' : 'provider'} draft={active ? drafts[active.id] ?? {} : provider} defaults={active?.defaults} api={api} onChange={next => { if (active) setDrafts(current => ({ ...current, [active.id]: next })); else setProvider(next); changed(); }} />
</> : null}
</fieldset></main>
</div>
<footer className="codex-catalog-footer"><div aria-live="polite">{error ? <span className="agent-inline-message error" role="alert">{error}</span> : notice ? <span role="status">{notice}</span> : <span>{t(dirty ? 'agents.catalog.unsaved' : 'agents.harness.saveHint')}</span>}</div><div>
<button className="secondary-button" disabled={loading || saving || !snapshot} onClick={() => { setDrafts(Object.fromEntries(snapshot!.models.map(m => [m.id, {}]))); setProvider({}); changed(); }}>{t('agents.catalog.resetAll')}</button>
<button className="secondary-button" disabled={saving} onClick={close}>{t('common.cancel')}</button><button className="primary-button" disabled={!dirty || loading || saving} onClick={() => void save()}>{saving ? <LoaderCircle size={16} className="spin" /> : null}{t(saving ? 'common.saving' : 'common.save')}</button>
</div></footer>
{confirmation ? <div className="codex-catalog-confirm"><div role="alertdialog" aria-modal="true" aria-label={t('agents.catalog.unsaved')}><strong>{t('agents.catalog.unsaved')}</strong><span>{t('agents.catalog.discardHint')}</span><div><button autoFocus className="secondary-button" onClick={() => setConfirmation(null)}>{t('agents.catalog.keepEditing')}</button><button className="danger-button" onClick={() => { if (confirmation === 'close') onClose(); else { setConfirmation(null); void load(); } }}>{t('agents.catalog.discard')}</button></div></div></div> : null}
</section>
</div>;
}
+95
View File
@@ -0,0 +1,95 @@
import schemaSource from './deepSeekHarnessSchema.json';
export type HarnessProfile = Record<string, unknown>;
export type HarnessDraft = Record<string, string>;
export type HarnessField = {
name: string;
kind: string;
group?: string;
values?: string[];
apis?: string[];
default?: unknown;
example?: unknown;
min?: number;
max?: number;
exclusiveMin?: number;
};
export const harnessSchema = schemaSource as unknown as Record<string, HarnessField[]>;
export const harnessReasoningLevels = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
export type HarnessEditorModel = { id: string; defaults: HarnessProfile; configuration: HarnessProfile };
export type HarnessEditorSnapshot = {
revision: string;
models: HarnessEditorModel[];
provider: HarnessProfile;
baseUrl: string;
defaultModel: string | null;
configured: boolean;
};
export const isHarnessRecord = (value: unknown): value is HarnessProfile =>
typeof value === 'object' && value !== null && !Array.isArray(value);
export function harnessDraft(profile: HarnessProfile, group: string, prefix = ''): HarnessDraft {
return Object.fromEntries(harnessSchema[group].flatMap(field => {
const value = profile[field.name];
const key = `${prefix}${field.name}`;
if (value === undefined) return [];
if (field.group && isHarnessRecord(value)) return Object.entries(harnessDraft(value, field.group, `${key}.`));
return [[key, ['string', 'enum'].includes(field.kind) ? String(value) : JSON.stringify(value)]];
}));
}
export function updateHarnessDraft(draft: HarnessDraft, key: string, value: string): HarnessDraft {
const next = { ...draft };
if (!value.length) delete next[key];
else return { ...next, [key]: value };
return next;
}
export function parseHarnessDraft(draft: HarnessDraft, group: string, api: string, prefix = ''): HarnessProfile {
const profile: HarnessProfile = {};
for (const field of harnessSchema[group]) {
const key = `${prefix}${field.name}`;
if (field.group) {
const nested = parseHarnessDraft(draft, field.group, api, `${key}.`);
if (Object.keys(nested).length) profile[field.name] = nested;
continue;
}
if (!Object.prototype.hasOwnProperty.call(draft, key) || !draft[key].trim()) continue;
let value: unknown;
try {
value = ['string', 'enum'].includes(field.kind) ? draft[key].trim() : JSON.parse(draft[key]);
} catch { throw new Error(`${key}: JSON`); }
if (field.apis && !field.apis.includes(api)) throw new Error(`${key}: ${api}`);
let valid = true;
if (['integer', 'number'].includes(field.kind)) valid = typeof value === 'number' && Number.isFinite(value)
&& Math.abs(value) <= Number.MAX_SAFE_INTEGER
&& (field.kind !== 'integer' || Number.isInteger(value))
&& (field.min === undefined || value >= field.min)
&& (field.max === undefined || value <= field.max)
&& (field.exclusiveMin === undefined || value > field.exclusiveMin);
if (field.kind === 'enum') valid = field.values!.includes(value as string);
if (field.kind === 'boolean') valid = typeof value === 'boolean';
if (['modalities', 'strings'].includes(field.kind)) valid = Array.isArray(value) && (value.length > 0 || field.name === 'input')
&& value.every(v => typeof v === 'string' && v.trim() && (field.kind !== 'modalities' || ['text', 'image'].includes(v)))
&& new Set(value).size === value.length;
if (field.kind === 'reasoning') valid = value === false || (isHarnessRecord(value) && Object.keys(value).length > 0
&& Object.entries(value).every(([level, wire]) => harnessReasoningLevels.includes(level)
&& ((level === 'off' && wire === null) || (typeof wire === 'string' && wire.trim()))));
if (field.kind === 'headers') valid = isHarnessRecord(value)
&& Object.entries(value).every(([name, v]) => /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)
&& typeof v === 'string' && [...v].every(c => c.charCodeAt(0) <= 255) && !/[\r\n\0]/.test(v));
if (field.kind === 'kwargs') valid = isHarnessRecord(value) && Object.values(value).every(v => !Array.isArray(v)
&& (!isHarnessRecord(v) || (Object.keys(v).every(key => ['$var', 'omitWhenOff'].includes(key))
&& ['thinking.enabled', 'thinking.effort', 'thinking.budget'].includes(v.$var as string)
&& (v.omitWhenOff === undefined || typeof v.omitWhenOff === 'boolean'))));
if (!valid) throw new Error(key);
profile[field.name] = value;
}
if (group === 'retryPolicy' && Object.keys(profile).length && profile.mode === undefined) profile.mode = 'normal';
if (group === 'backoff' && Number(profile.initialDelayMs ?? 500) > Number(profile.maxDelayMs ?? 10000)) throw new Error(`${prefix}initialDelayMs > maxDelayMs`);
return profile;
}
export function sameHarnessDraft(left: HarnessDraft, right: HarnessDraft): boolean {
return Object.keys(left).length === Object.keys(right).length && Object.keys(left).every(key => left[key] === right[key]);
}
+80
View File
@@ -0,0 +1,80 @@
{
"sources": [
"https://deepseek-harness.github.io/deepseek-harness/guide/providers",
"https://deepseek-harness.github.io/deepseek-harness/reference/config-catalog#deepseek-ai-dsh-llm-pi-ai",
"https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/llm/llm/src/retry-policy.ts"
],
"model": [
{"name":"name","kind":"string"},
{"name":"contextWindow","kind":"integer","min":1},
{"name":"maxTokens","kind":"integer","min":1},
{"name":"input","kind":"modalities"},
{"name":"reasoningEfforts","kind":"reasoning","example":{"off":null,"low":"low","high":"high"}},
{"name":"compat","kind":"group","group":"compat"}
],
"provider": [
{"name":"displayName","kind":"string","default":"EasyCLIProxyAPI"},
{"name":"api","kind":"enum","values":["openai-completions","openai-responses","anthropic-messages"],"default":"openai-completions"},
{"name":"defaultContextWindow","kind":"integer","min":1,"default":262144},
{"name":"defaultMaxTokens","kind":"integer","min":1,"default":32768},
{"name":"defaultInput","kind":"modalities","default":["text"]},
{"name":"reasoning","kind":"enum","values":["off","minimal","low","medium","high","xhigh","max"]},
{"name":"thinkingBudgets","kind":"group","group":"thinkingBudgets"},
{"name":"cacheRetention","kind":"enum","values":["none","short","long"]},
{"name":"transport","kind":"enum","values":["sse","websocket","websocket-cached","auto"]},
{"name":"timeoutMs","kind":"integer","min":0},
{"name":"websocketConnectTimeoutMs","kind":"integer","min":0},
{"name":"streamIdleTimeoutMs","kind":"number","exclusiveMin":0,"max":2147483647,"default":300000},
{"name":"maxRequestImageBytes","kind":"integer","min":1,"default":20971520},
{"name":"requestImagePixelBudget","kind":"integer","min":1,"default":4194304},
{"name":"requestImageMaxBytes","kind":"integer","min":1,"default":1048576},
{"name":"headers","kind":"headers","example":{"X-Custom-Header":"value"}},
{"name":"retryPolicy","kind":"group","group":"retryPolicy"},
{"name":"compat","kind":"group","group":"compat"}
],
"thinkingBudgets": [
{"name":"minimal","kind":"number"},
{"name":"low","kind":"number"},
{"name":"medium","kind":"number"},
{"name":"high","kind":"number"}
],
"retryPolicy": [
{"name":"mode","kind":"enum","values":["normal","always"],"default":"normal","required":true},
{"name":"maxRetries","kind":"integer","min":0,"default":5},
{"name":"retryableCodes","kind":"strings","default":["EMPTY_RESPONSE","RATE_LIMIT","SERVER","TIMEOUT","TRANSPORT"]},
{"name":"backoff","kind":"group","group":"backoff"}
],
"backoff": [
{"name":"initialDelayMs","kind":"number","exclusiveMin":0,"max":2147483647,"default":500},
{"name":"maxDelayMs","kind":"number","exclusiveMin":0,"max":2147483647,"default":10000},
{"name":"jitterRatio","kind":"number","min":0,"max":1,"default":0.1}
],
"compat": [
{"name":"supportsStore","kind":"boolean","apis":["openai-completions"]},
{"name":"supportsDeveloperRole","kind":"boolean","apis":["openai-completions","openai-responses"]},
{"name":"supportsReasoningEffort","kind":"boolean","apis":["openai-completions"]},
{"name":"supportsUsageInStreaming","kind":"boolean","apis":["openai-completions"]},
{"name":"supportsFinishReason","kind":"boolean","apis":["openai-completions"]},
{"name":"maxTokensField","kind":"enum","values":["max_completion_tokens","max_tokens"],"apis":["openai-completions"]},
{"name":"requiresToolResultName","kind":"boolean","apis":["openai-completions"]},
{"name":"requiresAssistantAfterToolResult","kind":"boolean","apis":["openai-completions"]},
{"name":"requiresThinkingAsText","kind":"boolean","apis":["openai-completions"]},
{"name":"requiresReasoningContentOnAssistantMessages","kind":"boolean","apis":["openai-completions"]},
{"name":"thinkingFormat","kind":"enum","values":["openai","deepseek","openrouter","together","baseten","zai","qwen","chat-template","qwen-chat-template","string-thinking","ant-ling"],"apis":["openai-completions"]},
{"name":"chatTemplateKwargs","kind":"kwargs","example":{"enable_thinking":{"$var":"thinking.enabled"}},"apis":["openai-completions"]},
{"name":"chatTemplateArgs","kind":"kwargs","example":{"reasoning_effort":{"$var":"thinking.effort","omitWhenOff":true}},"apis":["openai-completions"]},
{"name":"supportsThinkingTokenBudget","kind":"boolean","apis":["openai-completions"]},
{"name":"thinkingTokenBudgetField","kind":"enum","values":["thinking_token_budget","thinking_budget","thinking_budget_tokens"],"apis":["openai-completions"]},
{"name":"vllmPriority","kind":"integer","apis":["openai-completions"]},
{"name":"supportsMaxOutputTokens","kind":"boolean","apis":["openai-responses"]},
{"name":"supportsStrictMode","kind":"boolean","apis":["openai-completions","openai-responses"]},
{"name":"cacheControlFormat","kind":"enum","values":["anthropic"],"apis":["openai-completions"]},
{"name":"supportsLongCacheRetention","kind":"boolean","apis":["openai-completions","openai-responses","anthropic-messages"]},
{"name":"supportsEagerToolInputStreaming","kind":"boolean","apis":["anthropic-messages"]},
{"name":"supportsCacheControlOnTools","kind":"boolean","apis":["anthropic-messages"]},
{"name":"supportsTemperature","kind":"boolean","apis":["anthropic-messages"]},
{"name":"forceAdaptiveThinking","kind":"boolean","apis":["anthropic-messages"]},
{"name":"allowEmptySignature","kind":"boolean","apis":["anthropic-messages"]},
{"name":"supportsStrictTools","kind":"boolean","apis":["anthropic-messages"]}
]
}
+1
View File
@@ -8,6 +8,7 @@ export type ModelOption = {
alias?: string;
isAlias?: boolean;
contextWindow?: number;
inputModalities?: Array<'text' | 'image'>;
thinking?: Record<string, unknown>;
};
export type ModelProvider = 'gemini' | 'codex' | 'claude' | 'openai';
+32
View File
@@ -13828,3 +13828,35 @@ body.table-col-resizing * {
flex: 0 0 42px;
padding: 0;
}
.harness-catalog-dialog .codex-catalog-sidebar { grid-template-rows: auto auto minmax(0,1fr) auto; }
.harness-editor-fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.harness-provider-button { flex-shrink: 0; justify-content: flex-start; }
.harness-provider-button.active { border-color: var(--theme-bd5e31); }
.harness-catalog-form > label > span { display: flex; flex-wrap: wrap; gap: 6px; overflow-wrap: anywhere; }
.harness-catalog-form small, .harness-reasoning small { color: var(--theme-716b61); font-size: 12px; line-height: 1.6; }
.harness-override { font-weight: 400; }
.harness-catalog-group { grid-column: 1 / -1; border-top: 1px solid var(--theme-d5d2cb); padding-top: 14px; min-width: 0; }
.harness-catalog-group > summary { cursor: pointer; font-size: 13px; font-weight: 600; padding: 4px 0; }
.harness-catalog-group > button { margin-top: 12px; }
.harness-reasoning { grid-column: 1 / -1; display: grid; gap: 10px; }
.harness-reasoning > label { display: grid; gap: 8px; font-size: 12px; font-weight: 600; }
.harness-reasoning-levels { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px 16px; }
.harness-reasoning-levels > div { display: flex; align-items: center; gap: 8px; min-width: 0; }
.harness-reasoning-levels label { display: flex; align-items: center; gap: 6px; flex: 0 0 75px; font-size: 12px; }
.harness-reasoning-levels input:not([type=checkbox]) { min-width: 0; width: 100%; }
.harness-catalog-form textarea { font-family: var(--font-mono, monospace); }
@media (max-width: 640px) { .harness-reasoning-levels { grid-template-columns: 1fr; } }
@media (max-width: 760px) {
.harness-catalog-dialog .codex-catalog-body { grid-template-rows: 210px minmax(0,1fr); }
.harness-catalog-dialog .codex-catalog-sidebar { grid-template-rows: auto auto minmax(0,1fr); }
.harness-catalog-dialog .harness-provider-button { grid-column: 1 / -1; }
.harness-catalog-dialog .codex-catalog-search { grid-column: 1; grid-row: 2; }
.harness-catalog-dialog .codex-catalog-reload { grid-column: 2; grid-row: 2; }
.harness-catalog-dialog .codex-catalog-models { grid-row: 3; }
}
.harness-catalog-dialog .secondary-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
.harness-catalog-form small { font-weight: 400; }
.harness-catalog-form { margin-top: 12px; }
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test';
import { harnessDraft, parseHarnessDraft, sameHarnessDraft, updateHarnessDraft } from '../src/services/deepSeekHarnessCatalog';
describe('DSH catalog overrides', () => {
test('empty values inherit while explicit false and zero remain overrides', () => {
const draft = harnessDraft({ timeoutMs: 0, retryPolicy: { mode: 'normal', maxRetries: 0 }, compat: { supportsStore: false } }, 'provider');
expect(parseHarnessDraft(draft, 'provider', 'openai-completions')).toEqual({ timeoutMs: 0, retryPolicy: { mode: 'normal', maxRetries: 0 }, compat: { supportsStore: false } });
expect(parseHarnessDraft(updateHarnessDraft(draft, 'timeoutMs', ''), 'provider', 'openai-completions').timeoutMs).toBeUndefined();
expect(parseHarnessDraft({}, 'model', 'openai-completions')).toEqual({});
expect(parseHarnessDraft({ input: '[]' }, 'model', 'openai-completions')).toEqual({ input: [] });
});
test('reasoning levels keep wire values and only off allows null', () => {
expect(parseHarnessDraft({ reasoningEfforts: '{"off":null,"high":"custom-high"}' }, 'model', 'openai-completions')).toEqual({ reasoningEfforts: { off: null, high: 'custom-high' } });
expect(parseHarnessDraft({ reasoningEfforts: 'false' }, 'model', 'openai-completions')).toEqual({ reasoningEfforts: false });
expect(() => parseHarnessDraft({ reasoningEfforts: '{"high":null}' }, 'model', 'openai-completions')).toThrow();
expect(() => parseHarnessDraft({ reasoningEfforts: '{}' }, 'model', 'openai-completions')).toThrow();
});
test('invalid numbers, JSON, headers and incompatible protocol switches are rejected', () => {
for (const draft of [{ maxTokens: '-1' }, { input: '["audio"]' }, { contextWindow: '2.5' }, { 'compat.supportsStore': 'null' }, { 'compat.supportsTemperature': 'true' }]) {
expect(() => parseHarnessDraft(draft, 'model', 'openai-completions')).toThrow();
}
expect(() => parseHarnessDraft({ headers: '{bad json}' }, 'provider', 'openai-completions')).toThrow();
expect(() => parseHarnessDraft({ headers: '{"x-test":"a\\r\\nb"}' }, 'provider', 'openai-completions')).toThrow();
expect(() => parseHarnessDraft({ 'retryPolicy.backoff.initialDelayMs': '20000' }, 'provider', 'openai-completions')).toThrow();
});
test('nested groups round trip and follow current protocol', () => {
const profile = { api: 'anthropic-messages', compat: { supportsTemperature: false }, retryPolicy: { mode: 'always', backoff: { maxDelayMs: 60000 } }, thinkingBudgets: { high: 8192 } };
expect(parseHarnessDraft(harnessDraft(profile, 'provider'), 'provider', 'anthropic-messages')).toEqual(profile);
expect(sameHarnessDraft({ input: '["text"]', name: 'A' }, { name: 'A', input: '["text"]' })).toBe(true);
});
});
+84
View File
@@ -0,0 +1,84 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
const open = async (query = '') => {
await page.goto(`http://127.0.0.1:1421/tests/fixtures/agent-backups.html?client=deepseek-harness&reset-selections&${query}`);
await page.getByRole('button', { name: '配置模型', exact: true }).click();
await page.getByRole('option', { name: /gpt-one/ }).waitFor();
};
const dialog = () => page.getByRole('dialog', { name: 'DeepSeek Harness 模型配置' });
const save = async () => {
await dialog().getByRole('button', { name: '保存', exact: true }).click();
await dialog().getByRole('status').filter({ hasText: /已保存/ }).waitFor();
};
try {
for (const mode of ['', 'embedded&']) {
await open(mode);
assert.equal(await dialog().getByRole('combobox', { name: '输入类型', exact: true }).inputValue(), '');
await dialog().getByRole('option', { name: /unknown-model/ }).click();
await dialog().getByText(/未提供(不代表不支持)/).waitFor();
await dialog().getByRole('combobox', { name: '输入类型', exact: true }).selectOption('["text","image"]');
await dialog().getByRole('option', { name: /gpt-two/ }).click();
await dialog().getByRole('spinbutton', { name: '输出上限(tokens)' }).fill('4096');
await dialog().getByRole('combobox', { name: '推理等级', exact: true }).selectOption('custom');
await dialog().getByRole('textbox', { name: 'high reasoning_effort' }).fill('custom-high');
await dialog().getByRole('button', { name: '供应商设置', exact: true }).click();
await dialog().getByRole('combobox', { name: '默认输入类型', exact: true }).selectOption('["text","image"]');
await dialog().getByRole('spinbutton', { name: '请求超时(ms)' }).fill('60000');
await save();
const request = await page.evaluate(() => window.fixtureCalls.filter(c => c.cmd === 'save_deepseek_harness_model_catalog_editor').at(-1).args.request);
assert.deepEqual(request.models.find(m => m.id === 'unknown-model').configuration.input, ['text','image']);
assert.equal(request.models.find(m => m.id === 'gpt-two').configuration.maxTokens, 4096);
assert.equal(request.models.find(m => m.id === 'gpt-two').configuration.reasoningEfforts.high, 'custom-high');
assert.equal(request.provider.timeoutMs, 60000);
assert.equal(await page.evaluate(() => window.fixtureCalls.some(c => c.cmd === 'update_agent_config')), false);
await dialog().getByRole('button', { name: '关闭', exact: true }).click();
assert.match(await page.locator('.agent-model-trigger').innerText(), /gpt-one/);
await page.evaluate(() => window.fixtureRemount());
await page.getByRole('button', { name: '配置模型', exact: true }).click();
await dialog().getByRole('option', { name: /unknown-model/ }).click();
assert.equal(await dialog().getByRole('combobox', { name: '输入类型', exact: true }).inputValue(), '["text","image"]');
await dialog().getByRole('button', { name: '恢复自动', exact: true }).click();
await save();
assert.equal(await dialog().getByRole('combobox', { name: '输入类型', exact: true }).inputValue(), '');
await dialog().getByRole('spinbutton', { name: '上下文窗口(tokens)' }).fill('0');
await dialog().getByRole('button', { name: '保存', exact: true }).click();
await dialog().getByRole('alert').filter({ hasText: /contextWindow/ }).waitFor();
await dialog().getByRole('spinbutton', { name: '上下文窗口(tokens)' }).fill('64000');
await page.evaluate(() => window.fixtureFailHarnessSave = true);
await dialog().getByRole('button', { name: '保存', exact: true }).click();
await dialog().getByRole('alert').filter({ hasText: /模拟模型配置保存失败/ }).waitFor();
assert.equal(await dialog().getByRole('spinbutton', { name: '上下文窗口(tokens)' }).inputValue(), '64000');
await page.evaluate(() => { window.fixtureFailHarnessSave = false; window.fixtureStaleHarnessSave = true; });
await dialog().getByRole('button', { name: '保存', exact: true }).click();
await dialog().getByRole('alert').filter({ hasText: /模型列表或配置已变化/ }).waitFor();
await page.evaluate(() => window.fixtureStaleHarnessSave = false);
await page.keyboard.press('Escape');
await page.getByRole('alertdialog').waitFor();
await page.getByRole('button', { name: '继续编辑', exact: true }).click();
await save();
}
const screenshots = path.join(os.tmpdir(), 'cpa-harness-catalog-ui');
fs.mkdirSync(screenshots, { recursive: true });
for (const width of [1280, 540]) for (const theme of ['light', 'dark']) {
await page.setViewportSize({ width, height: 900 });
await open(`theme=${theme}`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await page.screenshot({ path: path.join(screenshots, `${width}-${theme}-model.png`) });
await dialog().getByRole('button', { name: '供应商设置', exact: true }).click();
assert.equal(await page.evaluate(() => document.querySelector('.codex-catalog-editor').scrollWidth > document.querySelector('.codex-catalog-editor').clientWidth), false);
await page.screenshot({ path: path.join(screenshots, `${width}-${theme}-provider.png`) });
}
assert.deepEqual(errors, []);
console.log('PASS: default model preserved, catalog overrides, API reset, reasoning, validation, retry, stale save, responsive themes');
console.log(screenshots);
} finally { await browser.close(); }
})().catch(e => { console.error(e); process.exit(1); });
+22 -1
View File
@@ -12,6 +12,15 @@ localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') ||
const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','zcode','kimi-code','grok-build','pi'];
let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one';
let currentOauth=false; const currentMappings:Record<string,any>={};
let harnessProvider:Record<string,unknown>={};
const harnessConfigurations:Record<string,Record<string,unknown>>={};
let harnessRevision=1;
const harnessModels=[
{id:'gpt-one',defaults:{input:['text','image'],contextWindow:128000}},
{id:'gpt-two',defaults:{input:['text']}},
{id:'unknown-model',defaults:{}},
];
const harnessSnapshot=()=>({revision:String(harnessRevision),provider:harnessProvider,baseUrl:'http://127.0.0.1:8317/v1',defaultModel:currentModel,configured:!!currentModel,models:harnessModels.map(m=>({...m,configuration:harnessConfigurations[m.id]??{}}))});
let harnessStatus={running:params.has('running'),pid:params.has('running')?100:null as number|null,mode:params.has('running')?params.get('harness-mode')||'web':null as string|null};
let appliedCount=0;
const calls:any[]=[];(window as any).fixtureCalls=calls;
@@ -22,7 +31,10 @@ mockIPC(async (cmd,args:any) => {
if(cmd==='plugin:event|listen') return 1;
if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null;
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&&currentOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null}));
if(cmd==='get_agent_models') return [{name:'gpt-one'},{name:'gpt-two'}];
if(cmd==='get_agent_models') {
if(args.client==='deepseek-harness')return harnessModels.map(m=>({name:m.id,inputModalities:m.defaults.input}));
return [{name:'gpt-one'},{name:'gpt-two'}];
}
if(cmd==='list_codex_sessions') {
const {offset,limit}=args.request;
const sessionIds:string[]=[...(window as any).fixtureSessionIds];
@@ -63,6 +75,15 @@ mockIPC(async (cmd,args:any) => {
if(cmd==='preview_agent_config_template') return {revision:'template1',files:['C:/test/.codex/config.toml','C:/test/.codex/auth.json','C:/test/.codex/models.json']};
if(cmd==='apply_agent_config_template') {currentModel=args.model;return {outcome:'updated'};}
if(cmd==='check_pi_provider_update') return {installedVersion:'1.0',latestVersion:params.has('update')?'1.1':'1.0',updateAvailable:params.has('update')};
if(cmd==='get_deepseek_harness_model_catalog_editor')return harnessSnapshot();
if(cmd==='save_deepseek_harness_model_catalog_editor') {
if((window as any).fixtureFailHarnessSave)throw new Error('模拟模型配置保存失败');
if((window as any).fixtureStaleHarnessSave)throw new Error('DSH_MODEL_CATALOG_CHANGED');
if((window as any).fixtureDeferHarnessSave)await new Promise<void>(resolve=>{(window as any).fixtureFinishHarnessSave=resolve;});
harnessProvider=args.request.provider;
for(const m of args.request.models)harnessConfigurations[m.id]=m.configuration;
harnessRevision++;return harnessSnapshot();
}
if(cmd==='get_codex_model_catalog_editor') return {models:[],hiddenModels:[],customizations:{}};
if(cmd==='check_codex_oauth_login')return null;
throw new Error('Unhandled fixture command: '+cmd);