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:
1 parent
145b585c3f
commit
4d8d549e04
28 files changed
+2047
-20
No files matched your search
@@ -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::*;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
&[
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user