Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions contracts/v3/agent-profile.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,32 @@
"items": {
"type": "string"
}
},
"reasoning_effort": {
"type": [
"string",
"null"
]
},
"thinking_mode": {
"type": [
"string",
"null"
]
},
"thinking_budget": {
"type": [
"integer",
"null"
],
"minimum": 1024,
"maximum": 9007199254740991
},
"effort_changed": {
"type": "boolean"
},
"thinking_changed": {
"type": "boolean"
}
}
},
Expand Down
48 changes: 48 additions & 0 deletions crates/vibehub-core/src/v3/opencode_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ pub struct OpenCodeModelView {
pub display_name: String,
pub declared_id: Option<String>,
pub reasoning: Option<bool>,
pub reasoning_effort: Option<String>,
pub thinking_mode: Option<String>,
pub thinking_budget: Option<u64>,
pub variants: Vec<String>,
/// Full variant values retained for the Tauri/UI round-trip. The editor
/// displays only the names, but saving an unrelated field must not rebuild
Expand Down Expand Up @@ -103,6 +106,12 @@ impl std::fmt::Debug for OpenCodeProviderPatch {
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenCodeOptionPatch {
pub path: Vec<String>,
pub value: Option<Value>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct OpenCodeModelPatch {
pub display_name: Option<String>,
Expand All @@ -111,6 +120,8 @@ pub struct OpenCodeModelPatch {
#[serde(default)]
pub clear_reasoning: bool,
pub variants: Option<BTreeMap<String, Value>>,
#[serde(default)]
pub option_patches: Vec<OpenCodeOptionPatch>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -524,6 +535,20 @@ fn model_view(model_id: &str, value: &Value) -> Result<OpenCodeModelView, Storag
.and_then(Value::as_str)
.map(str::to_owned),
reasoning: object.get("reasoning").and_then(Value::as_bool),
reasoning_effort: object
.get("options")
.and_then(|v| v.get("reasoningEffort").or_else(|| v.get("effort")))
.and_then(Value::as_str)
.map(str::to_owned),
thinking_mode: object
.get("options")
.and_then(|v| v.pointer("/thinking/type"))
.and_then(Value::as_str)
.map(str::to_owned),
thinking_budget: object
.get("options")
.and_then(|v| v.pointer("/thinking/budgetTokens"))
.and_then(Value::as_u64),
variants,
variant_values,
unknown_fields: object
Expand Down Expand Up @@ -726,6 +751,28 @@ impl JsoncEditor {
&mut replacements,
)?;
}
for option in &model_patch.option_patches {
if option.path.is_empty() {
continue;
}
let mut path = vec![
provider_key,
provider_id.as_str(),
"models",
model_id.as_str(),
"options",
];
path.extend(option.path.iter().map(String::as_str));
if let Some(value) = &option.value {
self.set_path(&path, value.clone(), &mut replacements)?;
} else {
self.remove_member(
&path[..path.len() - 1],
path[path.len() - 1],
&mut replacements,
)?;
}
}
if let Some(variants) = &model_patch.variants {
// None means variants were not edited. Some(empty) is an
// explicit request to clear them, so preserve this
Expand Down Expand Up @@ -1407,6 +1454,7 @@ mod tests {
reasoning: Some(true),
clear_reasoning: false,
variants: Some(variants),
..Default::default()
},
);
let mut providers = BTreeMap::new();
Expand Down
178 changes: 176 additions & 2 deletions src-tauri/src/agent_profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,18 @@ pub struct ModelProfileInput {
pub thinking: ThinkingProfileInput,
}

#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ThinkingProfileInput {
#[serde(default)]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub thinking_mode: Option<String>,
#[serde(default)]
pub thinking_budget: Option<u64>,
#[serde(default)]
pub effort_changed: bool,
#[serde(default)]
pub thinking_changed: bool,
pub supports_reasoning: Option<bool>,
pub supports_effort: Option<bool>,
pub selected: Option<String>,
Expand Down Expand Up @@ -1660,6 +1670,81 @@ fn patch_for(
})
}

fn opencode_thinking_patches(
protocol: Option<ProtocolKind>,
thinking: &ThinkingProfileInput,
) -> Result<Vec<v3::OpenCodeOptionPatch>, AgentProfileCommandError> {
let mut patches = Vec::new();
let invalid =
|message| AgentProfileCommandError::validation("AGENT_PROFILE_THINKING_INVALID", message);
if thinking.effort_changed {
let key = match protocol {
Some(ProtocolKind::OpenaiChatCompletions | ProtocolKind::OpenaiResponses) => {
"reasoningEffort"
}
Some(ProtocolKind::AnthropicMessages) => "effort",
_ => {
return Err(invalid(
"Select a supported provider protocol before editing effort",
))
}
};
let value = thinking
.reasoning_effort
.as_ref()
.filter(|v| !v.trim().is_empty())
.map(|v| Value::String(v.trim().to_owned()));
if value.is_some() && thinking.supports_effort == Some(false) {
return Err(invalid("Model declares effort unsupported"));
}
patches.push(v3::OpenCodeOptionPatch {
path: vec![key.into()],
value,
});
}
if thinking.thinking_changed {
if protocol != Some(ProtocolKind::AnthropicMessages) {
return Err(invalid(
"Thinking mode and budget require Anthropic Messages",
));
}
match thinking.thinking_mode.as_deref().filter(|v| !v.is_empty()) {
None => patches.push(v3::OpenCodeOptionPatch {
path: vec!["thinking".into()],
value: None,
}),
Some(mode @ ("enabled" | "adaptive" | "disabled")) => {
if mode != "disabled" && thinking.supports_reasoning == Some(false) {
return Err(invalid("Model declares reasoning unsupported"));
}
let budget = if mode == "enabled" {
let budget = thinking
.thinking_budget
.ok_or_else(|| invalid("Manual thinking requires a token budget"))?;
if !(1024..=9_007_199_254_740_991).contains(&budget) {
return Err(invalid(
"Thinking budget must be an integer of at least 1024 tokens",
));
}
Some(Value::from(budget))
} else {
None
};
patches.push(v3::OpenCodeOptionPatch {
path: vec!["thinking".into(), "type".into()],
value: Some(mode.into()),
});
patches.push(v3::OpenCodeOptionPatch {
path: vec!["thinking".into(), "budgetTokens".into()],
value: budget,
});
}
Some(_) => return Err(invalid("Unknown thinking mode")),
}
}
Ok(patches)
}

fn patch_for_opencode(
managed: &ManagedProfileInput,
) -> Result<OpenCodeConfigPatch, AgentProfileCommandError> {
Expand Down Expand Up @@ -1721,6 +1806,7 @@ fn patch_for_opencode(
reasoning: model.thinking.supports_reasoning,
clear_reasoning: model.thinking.supports_reasoning.is_none(),
variants,
option_patches: opencode_thinking_patches(protocol, &model.thinking)?,
},
);
}
Expand Down Expand Up @@ -2078,7 +2164,12 @@ fn opencode_document_parts(
"options":model.variants,
"custom_allowed":false,
"variant_values":model.variant_values,
"variant_values_changed":false
"variant_values_changed":false,
"reasoning_effort":model.reasoning_effort,
"thinking_mode":model.thinking_mode,
"thinking_budget":model.thinking_budget,
"effort_changed":false,
"thinking_changed":false
}
})
})
Expand Down Expand Up @@ -4073,6 +4164,86 @@ mod tests {
format!("http://{addr}")
}

#[test]
fn thinking_parameters_round_trip_without_replacing_unmanaged_options() {
let root = std::env::temp_dir().join(format!("vibehub-thinking-{}", Uuid::new_v4()));
fs::create_dir_all(root.join(".config/opencode")).unwrap();
let path = root.join(".config/opencode/opencode.jsonc");
fs::write(&path, r#"{"provider":{"p":{"npm":"@ai-sdk/anthropic","models":{"m":{"options":{"temperature":0.3,"thinking":{"type":"enabled","budgetTokens":2048,"display":"keep"}}}}}}}"#).unwrap();
let target = RuntimeTarget::host(root.clone());
let mut thinking = ThinkingProfileInput {
reasoning_effort: Some("high".into()),
effort_changed: true,
thinking_changed: true,
thinking_mode: Some("enabled".into()),
thinking_budget: Some(4096),
..Default::default()
};
for (mode, budget) in [
(Some("enabled"), Some(4096)),
(Some("adaptive"), None),
(None, None),
] {
thinking.thinking_mode = mode.map(str::to_owned);
thinking.thinking_budget = budget;
let before = v3::read_opencode_profile(&target, &path).unwrap();
let mut patch = OpenCodeConfigPatch::default();
patch.providers.insert(
"p".into(),
OpenCodeProviderPatch {
models: BTreeMap::from([(
"m".into(),
OpenCodeModelPatch {
option_patches: opencode_thinking_patches(
Some(ProtocolKind::AnthropicMessages),
&thinking,
)
.unwrap(),
..Default::default()
},
)]),
..Default::default()
},
);
v3::save_opencode_profile(&target, &path, Some(&before.revision), &patch).unwrap();
let after = v3::read_opencode_profile(&target, &path).unwrap();
assert_eq!(after.providers[0].models[0].thinking_mode.as_deref(), mode);
assert_eq!(after.providers[0].models[0].thinking_budget, budget);
assert_eq!(
after.providers[0].models[0].reasoning_effort.as_deref(),
Some("high")
);
let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
value.pointer("/provider/p/models/m/options/temperature"),
Some(&json!(0.3))
);
if mode.is_some() {
assert_eq!(
value.pointer("/provider/p/models/m/options/thinking/display"),
Some(&json!("keep"))
);
}
}
thinking.reasoning_effort = None;
assert_eq!(
opencode_thinking_patches(Some(ProtocolKind::AnthropicMessages), &thinking).unwrap()[0]
.value,
None
);
thinking.thinking_mode = Some("enabled".into());
thinking.thinking_budget = Some(1023);
assert!(
opencode_thinking_patches(Some(ProtocolKind::AnthropicMessages), &thinking).is_err()
);
assert!(
opencode_thinking_patches(Some(ProtocolKind::OpenaiChatCompletions), &thinking)
.is_err()
);
assert!(opencode_thinking_patches(None, &thinking).is_err());
fs::remove_dir_all(root).unwrap();
}

#[test]
fn opencode_patch_preserves_complex_variant_values_across_tauri_boundary() {
let mut variant_values = BTreeMap::new();
Expand Down Expand Up @@ -4119,6 +4290,7 @@ mod tests {
custom_allowed: false,
variant_values: Some(variant_values),
variant_values_changed: true,
..Default::default()
},
}],
}],
Expand Down Expand Up @@ -4339,6 +4511,7 @@ mod tests {
custom_allowed: false,
variant_values: None,
variant_values_changed: false,
..Default::default()
},
}],
}],
Expand Down Expand Up @@ -4394,6 +4567,7 @@ mod tests {
custom_allowed: false,
variant_values: None,
variant_values_changed: false,
..Default::default()
},
}],
}],
Expand Down
Loading
Loading