model-reasoning-control実装

This commit is contained in:
Keisuke Hirata 2026-04-27 22:25:27 +09:00
parent 34d5c3aab6
commit f8948be43d
9 changed files with 353 additions and 106 deletions

View File

@ -8,7 +8,7 @@
//! 1. scheme 実装側の `model_id → ModelCapability` 静的テーブル(既知モデル) //! 1. scheme 実装側の `model_id → ModelCapability` 静的テーブル(既知モデル)
//! 2. `ModelConfig::capability` での明示 override未知モデル、または上書き //! 2. `ModelConfig::capability` での明示 override未知モデル、または上書き
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// モデル能力メタデータ /// モデル能力メタデータ
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -80,23 +80,90 @@ pub enum CacheStrategy {
Auto, Auto,
} }
/// Reasoning 制御共通型、scheme 側で各社形式に投影) /// Reasoning 制御共通型、scheme 側で各社形式に投影)
/// ///
/// `effort` / `budget_tokens` はユーザー設定から任意で渡される。Scheme /// 文字列は provider-native な effort label、数値は provider-native な
/// 側は自身の `ReasoningSupport` に応じて片方だけ使う。両方が宣言 /// thinking budget token として扱う。どちらか一方だけを型で表現する。
/// されている場合の優先順位は scheme 実装が決める。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(untagged)]
pub struct ReasoningControl { pub enum ReasoningControl {
#[serde(default)] Effort(ReasoningEffort),
pub effort: Option<ReasoningEffort>, BudgetTokens(i32),
#[serde(default)]
pub budget_tokens: Option<u32>,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ReasoningEffort { pub enum ReasoningEffort {
Minimal,
Low, Low,
Medium, Medium,
High, High,
XHigh,
Other(String),
}
impl ReasoningEffort {
pub fn as_str(&self) -> &str {
match self {
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Other(label) => label.as_str(),
}
}
}
impl From<String> for ReasoningEffort {
fn from(value: String) -> Self {
match value.as_str() {
"minimal" => Self::Minimal,
"low" => Self::Low,
"medium" => Self::Medium,
"high" => Self::High,
"xhigh" => Self::XHigh,
_ => Self::Other(value),
}
}
}
impl Serialize for ReasoningEffort {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ReasoningEffort {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
String::deserialize(deserializer).map(Self::from)
}
}
#[cfg(test)]
mod tests {
use super::{ReasoningControl, ReasoningEffort};
#[test]
fn reasoning_control_deserializes_effort_labels() {
let known: ReasoningControl = serde_json::from_str(r#""xhigh""#).unwrap();
assert_eq!(known, ReasoningControl::Effort(ReasoningEffort::XHigh));
let unknown: ReasoningControl = serde_json::from_str(r#""provider-native""#).unwrap();
assert_eq!(
unknown,
ReasoningControl::Effort(ReasoningEffort::Other("provider-native".into()))
);
}
#[test]
fn reasoning_control_deserializes_signed_budget() {
let dynamic: ReasoningControl = serde_json::from_str("-1").unwrap();
assert_eq!(dynamic, ReasoningControl::BudgetTokens(-1));
}
} }

View File

@ -7,9 +7,9 @@ use std::collections::BTreeSet;
use serde::Serialize; use serde::Serialize;
use crate::llm_client::{ use crate::llm_client::{
capability::{CacheStrategy, ModelCapability, ReasoningControl, ReasoningSupport},
types::{parse_tool_arguments, ContentPart, Item, Role, ToolDefinition},
Request, Request,
capability::{CacheStrategy, ModelCapability, ReasoningSupport},
types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments},
}; };
use super::AnthropicScheme; use super::AnthropicScheme;
@ -41,7 +41,7 @@ pub(crate) struct AnthropicRequest {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum AnthropicThinking { pub(crate) enum AnthropicThinking {
Enabled { budget_tokens: u32 }, Enabled { budget_tokens: i32 },
} }
/// Anthropic message /// Anthropic message
@ -170,9 +170,13 @@ impl AnthropicScheme {
.config .config
.reasoning .reasoning
.as_ref() .as_ref()
.and_then(|rc| rc.budget_tokens)
.filter(|_| supports_budget_tokens) .filter(|_| supports_budget_tokens)
.map(|budget_tokens| AnthropicThinking::Enabled { budget_tokens }); .and_then(|rc| match rc {
ReasoningControl::BudgetTokens(budget_tokens) => Some(AnthropicThinking::Enabled {
budget_tokens: *budget_tokens,
}),
ReasoningControl::Effort(_) => None,
});
AnthropicRequest { AnthropicRequest {
model: model.to_string(), model: model.to_string(),
@ -218,7 +222,12 @@ impl AnthropicScheme {
for (i, item) in items.iter().enumerate() { for (i, item) in items.iter().enumerate() {
match item { match item {
Item::Message { role, content, .. } => { Item::Message { role, content, .. } => {
flush_pending(&mut messages, &mut pending_assistant, "assistant", &mut locations); flush_pending(
&mut messages,
&mut pending_assistant,
"assistant",
&mut locations,
);
flush_pending(&mut messages, &mut pending_user, "user", &mut locations); flush_pending(&mut messages, &mut pending_user, "user", &mut locations);
let anthropic_role = match role { let anthropic_role = match role {
@ -229,9 +238,7 @@ impl AnthropicScheme {
let parts: Vec<AnthropicContentPart> = content let parts: Vec<AnthropicContentPart> = content
.iter() .iter()
.map(|p| match p { .map(|p| match p {
ContentPart::Text { text } => { ContentPart::Text { text } => AnthropicContentPart::text(text.clone()),
AnthropicContentPart::text(text.clone())
}
ContentPart::Refusal { refusal } => { ContentPart::Refusal { refusal } => {
AnthropicContentPart::text(refusal.clone()) AnthropicContentPart::text(refusal.clone())
} }
@ -284,15 +291,18 @@ impl AnthropicScheme {
content, content,
.. ..
} => { } => {
flush_pending(&mut messages, &mut pending_assistant, "assistant", &mut locations); flush_pending(
&mut messages,
&mut pending_assistant,
"assistant",
&mut locations,
);
let text = match content { let text = match content {
Some(c) => format!("{summary}\n{c}"), Some(c) => format!("{summary}\n{c}"),
None => summary.clone(), None => summary.clone(),
}; };
pending_user.push(( pending_user
i, .push((i, AnthropicContentPart::tool_result(call_id.clone(), text)));
AnthropicContentPart::tool_result(call_id.clone(), text),
));
} }
Item::Reasoning { text, .. } => { Item::Reasoning { text, .. } => {
@ -304,7 +314,12 @@ impl AnthropicScheme {
} }
} }
flush_pending(&mut messages, &mut pending_assistant, "assistant", &mut locations); flush_pending(
&mut messages,
&mut pending_assistant,
"assistant",
&mut locations,
);
flush_pending(&mut messages, &mut pending_user, "user", &mut locations); flush_pending(&mut messages, &mut pending_user, "user", &mut locations);
// Apply cache_control markers at each breakpoint item's last part. // Apply cache_control markers at each breakpoint item's last part.
@ -400,7 +415,7 @@ fn compute_breakpoints(items: &[Item], cache_anchor: Option<usize>) -> BTreeSet<
mod tests { mod tests {
use super::*; use super::*;
use crate::llm_client::capability::{ use crate::llm_client::capability::{
CacheStrategy, StructuredOutput, ToolCallingSupport, CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
}; };
/// cache_control が有効になる既定の capability。 /// cache_control が有効になる既定の capability。
@ -422,6 +437,13 @@ mod tests {
} }
} }
fn cap_budget_reasoning() -> ModelCapability {
ModelCapability {
reasoning: Some(ReasoningSupport::BudgetTokens),
..cap_explicit()
}
}
#[test] #[test]
fn test_build_simple_request() { fn test_build_simple_request() {
let scheme = AnthropicScheme::new(); let scheme = AnthropicScheme::new();
@ -429,7 +451,8 @@ mod tests {
.system("You are a helpful assistant.") .system("You are a helpful assistant.")
.user("Hello!"); .user("Hello!");
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let anthropic_req =
scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
assert_eq!(anthropic_req.model, "claude-sonnet-4-20250514"); assert_eq!(anthropic_req.model, "claude-sonnet-4-20250514");
assert_eq!( assert_eq!(
@ -455,12 +478,45 @@ mod tests {
})), })),
); );
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let anthropic_req =
scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
assert_eq!(anthropic_req.tools.len(), 1); assert_eq!(anthropic_req.tools.len(), 1);
assert_eq!(anthropic_req.tools[0].name, "get_weather"); assert_eq!(anthropic_req.tools[0].name, "get_weather");
} }
#[test]
fn thinking_budget_projected_when_supported() {
let scheme = AnthropicScheme::new();
let mut request = Request::new().user("think");
request.config.reasoning = Some(ReasoningControl::BudgetTokens(4096));
let req = scheme.build_request(
"claude-sonnet-4-20250514",
&request,
&cap_budget_reasoning(),
);
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["thinking"]["type"], "enabled");
assert_eq!(json["thinking"]["budget_tokens"], 4096);
}
#[test]
fn effort_reasoning_not_projected_to_anthropic() {
let scheme = AnthropicScheme::new();
let mut request = Request::new().user("think");
request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
let req = scheme.build_request(
"claude-sonnet-4-20250514",
&request,
&cap_budget_reasoning(),
);
assert!(req.thinking.is_none());
}
#[test] #[test]
fn test_tool_call_and_result() { fn test_tool_call_and_result() {
let scheme = AnthropicScheme::new(); let scheme = AnthropicScheme::new();
@ -473,7 +529,8 @@ mod tests {
)) ))
.item(Item::tool_result("call_123", "Sunny, 25°C")); .item(Item::tool_result("call_123", "Sunny, 25°C"));
let anthropic_req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let anthropic_req =
scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
assert_eq!(anthropic_req.messages.len(), 3); assert_eq!(anthropic_req.messages.len(), 3);
assert_eq!(anthropic_req.messages[0].role, "user"); assert_eq!(anthropic_req.messages[0].role, "user");
@ -543,7 +600,7 @@ mod tests {
let scheme = AnthropicScheme::new(); let scheme = AnthropicScheme::new();
let mut items = completed_turn(); let mut items = completed_turn();
items.push(Item::user_message("next turn")); // index 5 = latest user items.push(Item::user_message("next turn")); // index 5 = latest user
// cache_anchor=None, turn_end=4, head=5. // cache_anchor=None, turn_end=4, head=5.
let request = Request::new().items(items); let request = Request::new().items(items);
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
@ -607,9 +664,7 @@ mod tests {
// so we don't bloat requests with wrapper arrays. Here the Head // so we don't bloat requests with wrapper arrays. Here the Head
// lands on items[1], leaving items[0] without a marker. // lands on items[1], leaving items[0] without a marker.
let scheme = AnthropicScheme::new(); let scheme = AnthropicScheme::new();
let request = Request::new() let request = Request::new().user("hello").assistant("hi there");
.user("hello")
.assistant("hi there");
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit()); let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
assert!( assert!(
matches!(req.messages[0].content, AnthropicContent::Text(_)), matches!(req.messages[0].content, AnthropicContent::Text(_)),
@ -628,10 +683,7 @@ mod tests {
match &req.messages[0].content { match &req.messages[0].content {
AnthropicContent::Parts(parts) => { AnthropicContent::Parts(parts) => {
assert_eq!(parts.len(), 1); assert_eq!(parts.len(), 1);
assert_eq!( assert_eq!(part_cache_control(&parts[0]), Some(CacheControl::Ephemeral));
part_cache_control(&parts[0]),
Some(CacheControl::Ephemeral)
);
} }
AnthropicContent::Text(_) => panic!("breakpoint item should use Parts form"), AnthropicContent::Text(_) => panic!("breakpoint item should use Parts form"),
} }
@ -668,7 +720,8 @@ mod tests {
#[test] #[test]
fn empty_items_produce_no_breakpoints() { fn empty_items_produce_no_breakpoints() {
let scheme = AnthropicScheme::new(); let scheme = AnthropicScheme::new();
let req = scheme.build_request("claude-sonnet-4-20250514", &Request::new(), &cap_explicit()); let req =
scheme.build_request("claude-sonnet-4-20250514", &Request::new(), &cap_explicit());
assert!(req.messages.is_empty()); assert!(req.messages.is_empty());
assert!(breakpoint_positions(&req).is_empty()); assert!(breakpoint_positions(&req).is_empty());
} }

View File

@ -6,9 +6,9 @@ use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use crate::llm_client::{ use crate::llm_client::{
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
types::{parse_tool_arguments, Item, Role, ToolDefinition},
Request, Request,
capability::{ModelCapability, ReasoningSupport},
types::{Item, Role, ToolDefinition, parse_tool_arguments},
}; };
use super::GeminiScheme; use super::GeminiScheme;
@ -203,10 +203,12 @@ impl GeminiScheme {
.config .config
.reasoning .reasoning
.as_ref() .as_ref()
.and_then(|rc| rc.budget_tokens)
.filter(|_| supports_budget) .filter(|_| supports_budget)
.map(|budget| GeminiThinkingConfig { .and_then(|rc| match rc {
thinking_budget: budget as i32, ReasoningControl::BudgetTokens(budget) => Some(GeminiThinkingConfig {
thinking_budget: *budget,
}),
ReasoningControl::Effort(_) => None,
}); });
// Generation config // Generation config
@ -374,7 +376,9 @@ impl GeminiScheme {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::llm_client::capability::{CacheStrategy, StructuredOutput, ToolCallingSupport}; use crate::llm_client::capability::{
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
};
fn cap() -> ModelCapability { fn cap() -> ModelCapability {
ModelCapability { ModelCapability {
@ -386,6 +390,13 @@ mod tests {
} }
} }
fn cap_budget_reasoning() -> ModelCapability {
ModelCapability {
reasoning: Some(ReasoningSupport::BudgetTokens),
..cap()
}
}
#[test] #[test]
fn test_build_simple_request() { fn test_build_simple_request() {
let scheme = GeminiScheme::new(); let scheme = GeminiScheme::new();
@ -457,4 +468,29 @@ mod tests {
assert_eq!(gemini_req.contents[1].role, "model"); assert_eq!(gemini_req.contents[1].role, "model");
assert_eq!(gemini_req.contents[2].role, "user"); assert_eq!(gemini_req.contents[2].role, "user");
} }
#[test]
fn thinking_budget_projected_when_supported() {
let scheme = GeminiScheme::new();
let mut request = Request::new().user("think");
request.config.reasoning = Some(ReasoningControl::BudgetTokens(-1));
let gemini_req = scheme.build_request(&request, &cap_budget_reasoning());
let config = gemini_req.generation_config.expect("generation config");
let thinking = config.thinking_config.expect("thinking config");
assert_eq!(thinking.thinking_budget, -1);
}
#[test]
fn effort_reasoning_not_projected_to_gemini() {
let scheme = GeminiScheme::new();
let mut request = Request::new().user("think");
request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Medium));
let gemini_req = scheme.build_request(&request, &cap_budget_reasoning());
let config = gemini_req.generation_config.expect("generation config");
assert!(config.thinking_config.is_none());
}
} }

View File

@ -6,9 +6,9 @@ use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use crate::llm_client::{ use crate::llm_client::{
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
types::{parse_tool_arguments, Item, Role, ToolDefinition},
Request, Request,
capability::{ModelCapability, ReasoningEffort, ReasoningSupport},
types::{Item, Role, ToolDefinition, parse_tool_arguments},
}; };
use super::OpenAIScheme; use super::OpenAIScheme;
@ -37,7 +37,7 @@ pub(crate) struct OpenAIRequest {
pub tool_choice: Option<String>, pub tool_choice: Option<String>,
/// Reasoning efforto1 / o3 / o4 / gpt-5 系で有効)。 /// Reasoning efforto1 / o3 / o4 / gpt-5 系で有効)。
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<&'static str>, pub reasoning_effort: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -154,12 +154,10 @@ impl OpenAIScheme {
.config .config
.reasoning .reasoning
.as_ref() .as_ref()
.and_then(|rc| rc.effort)
.filter(|_| supports_effort) .filter(|_| supports_effort)
.map(|effort| match effort { .and_then(|rc| match rc {
ReasoningEffort::Low => "low", ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()),
ReasoningEffort::Medium => "medium", ReasoningControl::BudgetTokens(_) => None,
ReasoningEffort::High => "high",
}); });
OpenAIRequest { OpenAIRequest {
@ -322,7 +320,9 @@ impl OpenAIScheme {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::llm_client::capability::{CacheStrategy, StructuredOutput, ToolCallingSupport}; use crate::llm_client::capability::{
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
};
fn cap() -> ModelCapability { fn cap() -> ModelCapability {
ModelCapability { ModelCapability {
@ -387,6 +387,38 @@ mod tests {
assert!(body.max_tokens.is_none()); assert!(body.max_tokens.is_none());
} }
#[test]
fn reasoning_effort_projected_when_supported() {
let scheme = OpenAIScheme::new();
let mut request = Request::new().user("Hello");
request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Other(
"provider-native".into(),
)));
let capability = ModelCapability {
reasoning: Some(ReasoningSupport::Effort),
..cap()
};
let body = scheme.build_request("gpt-5", &request, &capability);
assert_eq!(body.reasoning_effort.as_deref(), Some("provider-native"));
}
#[test]
fn budget_reasoning_not_projected_to_openai_chat() {
let scheme = OpenAIScheme::new();
let mut request = Request::new().user("Hello");
request.config.reasoning = Some(ReasoningControl::BudgetTokens(4096));
let capability = ModelCapability {
reasoning: Some(ReasoningSupport::Both),
..cap()
};
let body = scheme.build_request("gpt-5", &request, &capability);
assert!(body.reasoning_effort.is_none());
}
#[test] #[test]
fn test_tool_call_and_result() { fn test_tool_call_and_result() {
let scheme = OpenAIScheme::new(); let scheme = OpenAIScheme::new();

View File

@ -9,7 +9,7 @@ use serde_json::Value;
use crate::llm_client::{ use crate::llm_client::{
Request, Request,
capability::{ModelCapability, ReasoningEffort, ReasoningSupport}, capability::{ModelCapability, ReasoningControl, ReasoningSupport},
types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments}, types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments},
}; };
@ -50,7 +50,7 @@ pub(crate) struct ResponsesRequest {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub(crate) struct ReasoningConfig { pub(crate) struct ReasoningConfig {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<&'static str>, pub effort: Option<String>,
/// summary の出力制御。`"auto"` 固定で summary_text を受け取る。 /// summary の出力制御。`"auto"` 固定で summary_text を受け取る。
pub summary: &'static str, pub summary: &'static str,
} }
@ -168,16 +168,15 @@ impl OpenAIResponsesScheme {
.config .config
.reasoning .reasoning
.as_ref() .as_ref()
.and_then(|rc| rc.effort)
.filter(|_| supports_effort) .filter(|_| supports_effort)
.map(|effort| ReasoningConfig { .map(|effort| ReasoningConfig {
effort: Some(match effort { effort: match effort {
ReasoningEffort::Low => "low", ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()),
ReasoningEffort::Medium => "medium", ReasoningControl::BudgetTokens(_) => None,
ReasoningEffort::High => "high", },
}),
summary: "auto", summary: "auto",
}); })
.filter(|reasoning| reasoning.effort.is_some());
let include: Vec<&'static str> = if self.include_encrypted_content { let include: Vec<&'static str> = if self.include_encrypted_content {
vec!["reasoning.encrypted_content"] vec!["reasoning.encrypted_content"]
@ -209,12 +208,12 @@ fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
for item in items { for item in items {
match item { match item {
Item::Message { role, content, .. } => { Item::Message { role, content, .. } => {
let (role_str, text_variant): (&'static str, fn(String) -> InputContent) = match role let (role_str, text_variant): (&'static str, fn(String) -> InputContent) =
{ match role {
Role::User => ("user", |t| InputContent::InputText { text: t }), Role::User => ("user", |t| InputContent::InputText { text: t }),
Role::Assistant => ("assistant", |t| InputContent::OutputText { text: t }), Role::Assistant => ("assistant", |t| InputContent::OutputText { text: t }),
Role::System => ("system", |t| InputContent::InputText { text: t }), Role::System => ("system", |t| InputContent::InputText { text: t }),
}; };
let parts: Vec<InputContent> = content let parts: Vec<InputContent> = content
.iter() .iter()
.map(|p| match p { .map(|p| match p {
@ -395,7 +394,10 @@ mod tests {
.item(Item::tool_result("c1", "ok")); .item(Item::tool_result("c1", "ok"));
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
assert!(matches!(body.input[1], InputItem::FunctionCall { .. })); assert!(matches!(body.input[1], InputItem::FunctionCall { .. }));
assert!(matches!(body.input[2], InputItem::FunctionCallOutput { .. })); assert!(matches!(
body.input[2],
InputItem::FunctionCallOutput { .. }
));
} }
#[test] #[test]
@ -425,13 +427,10 @@ mod tests {
fn reasoning_effort_projected_when_supported() { fn reasoning_effort_projected_when_supported() {
let scheme = OpenAIResponsesScheme::new(); let scheme = OpenAIResponsesScheme::new();
let mut req = Request::new().user("hi"); let mut req = Request::new().user("hi");
req.config.reasoning = Some(ReasoningControl { req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
effort: Some(ReasoningEffort::High),
budget_tokens: None,
});
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning()); let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
let reasoning = body.reasoning.expect("reasoning should be set"); let reasoning = body.reasoning.expect("reasoning should be set");
assert_eq!(reasoning.effort, Some("high")); assert_eq!(reasoning.effort.as_deref(), Some("high"));
assert_eq!(reasoning.summary, "auto"); assert_eq!(reasoning.summary, "auto");
} }
@ -439,10 +438,7 @@ mod tests {
fn reasoning_omitted_when_unsupported() { fn reasoning_omitted_when_unsupported() {
let scheme = OpenAIResponsesScheme::new(); let scheme = OpenAIResponsesScheme::new();
let mut req = Request::new().user("hi"); let mut req = Request::new().user("hi");
req.config.reasoning = Some(ReasoningControl { req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
effort: Some(ReasoningEffort::High),
budget_tokens: None,
});
let body = scheme.build_request("gpt-4o", &req, &cap_no_reasoning()); let body = scheme.build_request("gpt-4o", &req, &cap_no_reasoning());
assert!(body.reasoning.is_none()); assert!(body.reasoning.is_none());
} }

View File

@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::defaults; use crate::defaults;
use crate::model::{AuthRef, ModelManifest}; use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::{ use crate::{
CompactionConfig, MemoryConfig, PodManifest, PodMeta, ScopeConfig, ToolOutputLimits, CompactionConfig, MemoryConfig, PodManifest, PodMeta, ScopeConfig, ToolOutputLimits,
WorkerManifest, WorkerManifest,
@ -65,6 +65,8 @@ pub struct WorkerManifestConfig {
#[serde(default)] #[serde(default)]
pub temperature: Option<f32>, pub temperature: Option<f32>,
#[serde(default)] #[serde(default)]
pub reasoning: Option<ReasoningControl>,
#[serde(default)]
pub tool_output: ToolOutputLimitsPartial, pub tool_output: ToolOutputLimitsPartial,
} }
@ -103,10 +105,7 @@ pub enum ResolveError {
#[error("missing required field: {0}")] #[error("missing required field: {0}")]
MissingField(&'static str), MissingField(&'static str),
#[error("path must be absolute ({field}): {}", .path.display())] #[error("path must be absolute ({field}): {}", .path.display())]
RelativePath { RelativePath { field: &'static str, path: PathBuf },
field: &'static str,
path: PathBuf,
},
} }
impl PodManifestConfig { impl PodManifestConfig {
@ -227,6 +226,7 @@ impl WorkerManifestConfig {
max_tokens: upper.max_tokens.or(self.max_tokens), max_tokens: upper.max_tokens.or(self.max_tokens),
max_turns: upper.max_turns.or(self.max_turns), max_turns: upper.max_turns.or(self.max_turns),
temperature: upper.temperature.or(self.temperature), temperature: upper.temperature.or(self.temperature),
reasoning: upper.reasoning.or(self.reasoning),
tool_output: self.tool_output.merge(upper.tool_output), tool_output: self.tool_output.merge(upper.tool_output),
} }
} }
@ -323,10 +323,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
type Error = ResolveError; type Error = ResolveError;
fn try_from(cfg: PodManifestConfig) -> Result<Self, Self::Error> { fn try_from(cfg: PodManifestConfig) -> Result<Self, Self::Error> {
let name = cfg let name = cfg.pod.name.ok_or(ResolveError::MissingField("pod.name"))?;
.pod
.name
.ok_or(ResolveError::MissingField("pod.name"))?;
let prompt_pack = cfg.pod.prompt_pack; let prompt_pack = cfg.pod.prompt_pack;
if let Some(ref p) = prompt_pack { if let Some(ref p) = prompt_pack {
ensure_absolute("pod.prompt_pack", p)?; ensure_absolute("pod.prompt_pack", p)?;
@ -342,6 +339,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
max_tokens: cfg.worker.max_tokens, max_tokens: cfg.worker.max_tokens,
max_turns: cfg.worker.max_turns, max_turns: cfg.worker.max_turns,
temperature: cfg.worker.temperature, temperature: cfg.worker.temperature,
reasoning: cfg.worker.reasoning,
tool_output: ToolOutputLimits { tool_output: ToolOutputLimits {
default_max_bytes: cfg default_max_bytes: cfg
.worker .worker
@ -372,9 +370,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
prune_protected_turns: c prune_protected_turns: c
.prune_protected_turns .prune_protected_turns
.unwrap_or(defaults::PRUNE_PROTECTED_TURNS), .unwrap_or(defaults::PRUNE_PROTECTED_TURNS),
prune_min_savings: c prune_min_savings: c.prune_min_savings.unwrap_or(defaults::PRUNE_MIN_SAVINGS),
.prune_min_savings
.unwrap_or(defaults::PRUNE_MIN_SAVINGS),
compact_threshold: c.compact_threshold, compact_threshold: c.compact_threshold,
compact_request_threshold: c.compact_request_threshold, compact_request_threshold: c.compact_request_threshold,
compact_retained_tokens: c compact_retained_tokens: c
@ -406,7 +402,7 @@ impl TryFrom<PodManifestConfig> for PodManifest {
mod tests { mod tests {
use super::*; use super::*;
use crate::model::SchemeKind; use crate::model::SchemeKind;
use crate::{Permission, ScopeRule}; use crate::{Permission, ReasoningEffort, ScopeRule};
fn abs(path: &str) -> PathBuf { fn abs(path: &str) -> PathBuf {
PathBuf::from(format!("/tmp/insomnia-test{path}")) PathBuf::from(format!("/tmp/insomnia-test{path}"))
@ -565,6 +561,31 @@ mod tests {
assert_eq!(merged.model.model_id.as_deref(), Some("lower-model")); assert_eq!(merged.model.model_id.as_deref(), Some("lower-model"));
} }
#[test]
fn merge_worker_reasoning_upper_wins() {
let lower = PodManifestConfig {
worker: WorkerManifestConfig {
reasoning: Some(ReasoningControl::Effort(ReasoningEffort::Low)),
..Default::default()
},
..Default::default()
};
let upper = PodManifestConfig {
worker: WorkerManifestConfig {
reasoning: Some(ReasoningControl::BudgetTokens(4096)),
..Default::default()
},
..Default::default()
};
let merged = lower.merge(upper);
assert_eq!(
merged.worker.reasoning,
Some(ReasoningControl::BudgetTokens(4096))
);
}
#[test] #[test]
fn merge_scope_accumulates_allow_and_deny() { fn merge_scope_accumulates_allow_and_deny() {
let lower = PodManifestConfig { let lower = PodManifestConfig {
@ -614,12 +635,9 @@ mod tests {
worker: WorkerManifestConfig { worker: WorkerManifestConfig {
tool_output: ToolOutputLimitsPartial { tool_output: ToolOutputLimitsPartial {
default_max_bytes: None, default_max_bytes: None,
per_tool: [ per_tool: [("Read".to_string(), 2048), ("Grep".to_string(), 512)]
("Read".to_string(), 2048), .into_iter()
("Grep".to_string(), 512), .collect(),
]
.into_iter()
.collect(),
}, },
..Default::default() ..Default::default()
}, },
@ -687,6 +705,33 @@ unknown_future_field = "tolerated"
assert_eq!(cfg.worker.max_tokens, Some(1000)); assert_eq!(cfg.worker.max_tokens, Some(1000));
} }
#[test]
fn from_toml_accepts_worker_reasoning_string_or_integer() {
let effort = PodManifestConfig::from_toml(
r#"
[worker]
reasoning = "xhigh"
"#,
)
.unwrap();
assert_eq!(
effort.worker.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::XHigh))
);
let budget = PodManifestConfig::from_toml(
r#"
[worker]
reasoning = -1
"#,
)
.unwrap();
assert_eq!(
budget.worker.reasoning,
Some(ReasoningControl::BudgetTokens(-1))
);
}
#[test] #[test]
fn from_toml_partial_layer_succeeds() { fn from_toml_partial_layer_succeeds() {
// A project-layer manifest with only scope set must parse fine. // A project-layer manifest with only scope set must parse fine.

View File

@ -6,12 +6,14 @@ pub mod paths;
mod scope; mod scope;
pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer}; pub use cascade::{LayerLoadError, find_project_manifest_from, load_layer};
pub use paths::user_manifest_path;
pub use config::{ pub use config::{
CompactionConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError, CompactionConfigPartial, PodManifestConfig, PodMetaConfig, ResolveError,
ToolOutputLimitsPartial, WorkerManifestConfig, ToolOutputLimitsPartial, WorkerManifestConfig,
}; };
pub use model::{AuthRef, ModelCapability, ModelManifest, SchemeKind}; pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
};
pub use paths::user_manifest_path;
pub use protocol::{Permission, ScopeRule}; pub use protocol::{Permission, ScopeRule};
pub use scope::{Scope, ScopeError}; pub use scope::{Scope, ScopeError};
@ -99,6 +101,8 @@ pub struct WorkerManifest {
pub max_turns: Option<NonZeroU32>, pub max_turns: Option<NonZeroU32>,
#[serde(default)] #[serde(default)]
pub temperature: Option<f32>, pub temperature: Option<f32>,
#[serde(default)]
pub reasoning: Option<ReasoningControl>,
/// Byte-size caps applied to tool `content` before it reaches the /// Byte-size caps applied to tool `content` before it reaches the
/// conversation history. The section is optional in TOML — when /// conversation history. The section is optional in TOML — when
/// omitted, `ToolOutputLimits::default()` (16KB default cap, no /// omitted, `ToolOutputLimits::default()` (16KB default cap, no
@ -312,6 +316,7 @@ auth = { kind = "api_key", file = "/abs/keys/anthropic" }
instruction = "$user/reviewer" instruction = "$user/reviewer"
max_tokens = 4096 max_tokens = 4096
temperature = 0.3 temperature = 0.3
reasoning = "medium"
[[scope.allow]] [[scope.allow]]
target = "/abs/project" target = "/abs/project"
@ -336,6 +341,10 @@ permission = "write"
assert_eq!(manifest.worker.instruction, "$user/reviewer"); assert_eq!(manifest.worker.instruction, "$user/reviewer");
assert_eq!(manifest.worker.max_tokens, Some(4096)); assert_eq!(manifest.worker.max_tokens, Some(4096));
assert_eq!(manifest.worker.temperature, Some(0.3)); assert_eq!(manifest.worker.temperature, Some(0.3));
assert_eq!(
manifest.worker.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::Medium))
);
let allow = &manifest.scope.allow; let allow = &manifest.scope.allow;
assert_eq!(allow.len(), 2); assert_eq!(allow.len(), 2);
assert_eq!(allow[0].permission, Permission::Write); assert_eq!(allow[0].permission, Permission::Write);
@ -368,6 +377,16 @@ model_id = "claude-sonnet-4-20250514"
assert_eq!(manifest.worker.max_turns.unwrap().get(), 50); assert_eq!(manifest.worker.max_turns.unwrap().get(), 50);
} }
#[test]
fn parse_reasoning_budget() {
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nreasoning = -1\n");
let manifest = PodManifest::from_toml(&toml).unwrap();
assert_eq!(
manifest.worker.reasoning,
Some(ReasoningControl::BudgetTokens(-1))
);
}
#[test] #[test]
fn omitted_max_turns_is_none() { fn omitted_max_turns_is_none() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
@ -458,9 +477,7 @@ model_id = "claude-sonnet-4-20250514"
#[test] #[test]
fn memory_section_with_explicit_root() { fn memory_section_with_explicit_root() {
let toml = format!( let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
"{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n"
);
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = PodManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap(); let mem = manifest.memory.unwrap();
assert_eq!( assert_eq!(

View File

@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize};
// `ModelCapability` は `llm-worker` 側に定義される runtime 構造だが、 // `ModelCapability` は `llm-worker` 側に定義される runtime 構造だが、
// マニフェストで任意に override できるよう型だけ再エクスポートする。 // マニフェストで任意に override できるよう型だけ再エクスポートする。
pub use llm_worker::llm_client::capability::ModelCapability; pub use llm_worker::llm_client::capability::{ModelCapability, ReasoningControl, ReasoningEffort};
/// Pod マニフェストの `[model]` セクション。 /// Pod マニフェストの `[model]` セクション。
/// ///

View File

@ -1398,6 +1398,7 @@ pub fn apply_worker_manifest<C: LlmClient>(worker: &mut Worker<C>, wm: &WorkerMa
if let Some(temperature) = wm.temperature { if let Some(temperature) = wm.temperature {
config.temperature = Some(temperature); config.temperature = Some(temperature);
} }
config.reasoning = wm.reasoning.clone();
worker.set_request_config(config); worker.set_request_config(config);
worker.set_max_turns(wm.max_turns.map(|n| n.get())); worker.set_max_turns(wm.max_turns.map(|n| n.get()));
worker.set_tool_output_limits(Some(ToolOutputLimits { worker.set_tool_output_limits(Some(ToolOutputLimits {