- thinking (off, low, medium, high, xhigh, max) is an enum: serde rejects unknown levels in the config, and the schema of agent.chat lists them. reasoning_effort, a second name for the same setting, is gone. - agent.chat takes an optional thinking level for one turn. - off really switches thinking off: OpenAI gets the effort none, and Anthropic gets disabled thinking (newer Claude models think by default). A model that cannot switch thinking off gets low. - Unset means the model's default. Anthropic thinking blocks now return unless thinking is off, because newer models think by default. - The template sets thinking: medium and shows it in each example, with current model names. - Checked against the proxy with gpt-6-luna, glm-5.3-flash, and claude-haiku-5-5.
This commit is contained in:
+84
-36
@@ -3,6 +3,7 @@
|
||||
//! The conversation is stored in a neutral format, so the agent can switch
|
||||
//! between the three shapes.
|
||||
|
||||
use sausage_plugin::schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
#[cfg(target_os = "wasi")]
|
||||
@@ -22,17 +23,50 @@ pub struct Config {
|
||||
pub token: String,
|
||||
/// Permit an empty token and omit authentication headers.
|
||||
pub allow_no_token: bool,
|
||||
/// Optional reasoning effort for OpenAI models only (wins over `thinking` there).
|
||||
#[serde(default)]
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// An optional cap on generated tokens.
|
||||
#[serde(default)]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Thinking effort for all APIs: "low", "medium", "high", "xhigh", "max" (`LEVELS`), or "off"/unset for none.
|
||||
/// Anthropic: adaptive thinking + output_config.effort; OpenAI: reasoning.effort. A level the model rejects is
|
||||
/// replaced by the nearest accepted one (logged), never by "off".
|
||||
/// The thinking level of each turn. Unset: the model's default.
|
||||
#[serde(default)]
|
||||
pub thinking: Option<String>,
|
||||
pub thinking: Option<Thinking>,
|
||||
}
|
||||
|
||||
/// How much the model thinks before it answers.
|
||||
///
|
||||
/// OpenAI Responses gets `reasoning.effort`, Chat Completions gets `reasoning_effort`, and Anthropic
|
||||
/// gets adaptive thinking with `output_config.effort`. If the model rejects a level, the agent uses
|
||||
/// the nearest level that the model accepts, never `off`.
|
||||
#[schema]
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Thinking {
|
||||
/// No thinking: OpenAI gets the effort `none`, and Anthropic gets disabled thinking. If an
|
||||
/// OpenAI model cannot switch thinking off, the agent uses `low`.
|
||||
Off,
|
||||
/// The least thinking.
|
||||
Low,
|
||||
/// Moderate thinking.
|
||||
Medium,
|
||||
/// Much thinking.
|
||||
High,
|
||||
/// More thinking than `high`, where the model supports it.
|
||||
Xhigh,
|
||||
/// The most thinking that the model supports.
|
||||
Max,
|
||||
}
|
||||
|
||||
impl Thinking {
|
||||
/// The effort that an OpenAI request carries: `none`, or one of `LEVELS`.
|
||||
fn effort(self) -> &'static str {
|
||||
match self {
|
||||
Thinking::Off => "none",
|
||||
Thinking::Low => "low",
|
||||
Thinking::Medium => "medium",
|
||||
Thinking::High => "high",
|
||||
Thinking::Xhigh => "xhigh",
|
||||
Thinking::Max => "max",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -98,7 +132,11 @@ fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String>
|
||||
} else {
|
||||
LEVELS.to_vec()
|
||||
};
|
||||
let at = LEVELS.iter().position(|l| *l == want).unwrap_or(1) as i32;
|
||||
// `none` is below all levels, so its nearest level is `low`.
|
||||
let at = LEVELS
|
||||
.iter()
|
||||
.position(|l| *l == want)
|
||||
.map_or(-1, |i| i as i32);
|
||||
pool.into_iter()
|
||||
.filter(|l| *l != want && !tried.iter().any(|t| t == l))
|
||||
.min_by_key(|l| {
|
||||
@@ -130,6 +168,22 @@ mod tests {
|
||||
}
|
||||
let cfg = Config::parse(b"api: openai-chat-completions\nbase_url: http://localhost/v1\nmodel: local\ntoken: ''\nallow_no_token: true").unwrap();
|
||||
assert!(cfg.bearer().is_empty());
|
||||
assert_eq!(cfg.thinking, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_levels() {
|
||||
use super::{Config, LEVELS, Thinking};
|
||||
let base = "api: openai-responses\nbase_url: url\nmodel: model\ntoken: key\nthinking: ";
|
||||
let parse = |level: &str| Config::parse(format!("{base}{level}").as_bytes());
|
||||
assert_eq!(parse("off").unwrap().thinking, Some(Thinking::Off));
|
||||
assert_eq!(Thinking::Off.effort(), "none");
|
||||
for level in LEVELS {
|
||||
assert_eq!(parse(level).unwrap().thinking.unwrap().effort(), level);
|
||||
}
|
||||
let error = parse("hihg").err().unwrap();
|
||||
assert!(error.contains("/config/agent.yaml"), "{error}");
|
||||
assert!(error.contains("thinking"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -178,10 +232,16 @@ mod tests {
|
||||
fallback_effort("xhigh", anthropic, &["xhigh".into()]).as_deref(),
|
||||
Some("high")
|
||||
);
|
||||
let openai = "HTTP 400: Invalid value: 'ultra'. Supported values are: 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'.";
|
||||
let openai = "HTTP 400: Invalid value: 'max'. Supported values are: 'none', 'minimal', 'low', 'medium', and 'high'.";
|
||||
assert_eq!(
|
||||
fallback_effort("ultra", openai, &["ultra".into()]).as_deref(),
|
||||
Some("medium")
|
||||
fallback_effort("max", openai, &["max".into()]).as_deref(),
|
||||
Some("high")
|
||||
);
|
||||
// A thinking-only model rejects `none`: use the lowest level.
|
||||
let glm = "HTTP 400: GLM-5.3 is a thinking-only model; disabling thinking (reasoning_effort='none') is not supported.";
|
||||
assert_eq!(
|
||||
fallback_effort("none", glm, &["none".into()]).as_deref(),
|
||||
Some("low")
|
||||
);
|
||||
// No list in the error: step to the neighbour, never to off.
|
||||
assert_eq!(
|
||||
@@ -254,31 +314,14 @@ pub async fn complete(
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let want = match cfg.api.as_str() {
|
||||
"openai-responses" | "openai-chat-completions" => cfg
|
||||
.reasoning_effort
|
||||
.clone()
|
||||
.or_else(|| cfg.thinking.clone()),
|
||||
_ => cfg.thinking.clone(),
|
||||
}
|
||||
.filter(|t| t != "off");
|
||||
let want = cfg.thinking.map(|level| level.effort().to_string());
|
||||
let mut effort = want.clone();
|
||||
let mut tried = Vec::new();
|
||||
loop {
|
||||
let result = match cfg.api.as_str() {
|
||||
"openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await,
|
||||
"openai-chat-completions" => chat(cfg, system, tools, history, effort.as_deref()).await,
|
||||
"anthropic-messages" => {
|
||||
anthropic(
|
||||
cfg,
|
||||
system,
|
||||
tools,
|
||||
history,
|
||||
want.is_some(),
|
||||
effort.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"anthropic-messages" => anthropic(cfg, system, tools, history, effort.as_deref()).await,
|
||||
other => return Err(format!("unknown API shape {other:?}")),
|
||||
};
|
||||
match (result, effort.clone()) {
|
||||
@@ -430,9 +473,9 @@ async fn anthropic(
|
||||
system: &str,
|
||||
tools: &[Tool],
|
||||
history: &[Entry],
|
||||
thinking: bool,
|
||||
effort: Option<&str>,
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
let disabled = cfg.thinking == Some(Thinking::Off);
|
||||
// Merge consecutive blocks of the same role into one message.
|
||||
let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
|
||||
let mut push = |role: &str, block: Value| match messages.last_mut() {
|
||||
@@ -462,7 +505,7 @@ async fn anthropic(
|
||||
),
|
||||
// Thinking blocks go back unchanged (with their signature): required next to tool use.
|
||||
Entry::Opaque { api, model, item }
|
||||
if api == "anthropic-messages" && model == &cfg.model && thinking =>
|
||||
if api == "anthropic-messages" && model == &cfg.model && !disabled =>
|
||||
{
|
||||
push("assistant", item.clone())
|
||||
}
|
||||
@@ -486,10 +529,15 @@ async fn anthropic(
|
||||
"tools": tools,
|
||||
"max_tokens": cfg.max_tokens.unwrap_or(32000),
|
||||
});
|
||||
if thinking {
|
||||
body["thinking"] = json!({"type": "adaptive"});
|
||||
if let Some(effort) = effort {
|
||||
body["output_config"] = json!({"effort": effort});
|
||||
// Unset: the model's default. Newer models think by default.
|
||||
match cfg.thinking {
|
||||
None => {}
|
||||
Some(Thinking::Off) => body["thinking"] = json!({"type": "disabled"}),
|
||||
Some(_) => {
|
||||
body["thinking"] = json!({"type": "adaptive"});
|
||||
if let Some(effort) = effort {
|
||||
body["output_config"] = json!({"effort": effort});
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
|
||||
|
||||
+7
-2
@@ -4,7 +4,7 @@ mod llm;
|
||||
mod tools;
|
||||
|
||||
use history::{is_too_large, next_turn_start};
|
||||
use llm::{Config, Entry};
|
||||
use llm::{Config, Entry, Thinking};
|
||||
use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema};
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
@@ -22,6 +22,10 @@ struct Agent;
|
||||
struct ChatParams {
|
||||
/// The complete text of the person's request.
|
||||
text: String,
|
||||
/// The thinking level of this turn only. Without it, the turn uses `thinking` from
|
||||
/// `/config/agent.yaml`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
thinking: Option<Thinking>,
|
||||
}
|
||||
|
||||
#[rpc(grace_seconds = 600)]
|
||||
@@ -32,7 +36,8 @@ impl Agent {
|
||||
async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result<String> {
|
||||
let _guard = lock(HISTORY).await?;
|
||||
std::fs::create_dir_all("/state/agent")?;
|
||||
let cfg = Config::load().map_err(|error| Error::new(-32603, error))?;
|
||||
let mut cfg = Config::load().map_err(|error| Error::new(-32603, error))?;
|
||||
cfg.thinking = p.thinking.or(cfg.thinking);
|
||||
let mut entries = load_history(HISTORY)?;
|
||||
repair_tools(&mut entries)?;
|
||||
let current = entries.len();
|
||||
|
||||
Reference in New Issue
Block a user