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,