Watch
1
0
Fork
You've already forked souveraine
0

claude: fix Opus 5 wire incompatibilities

Drop temperature for models that removed sampling params (400 otherwise).
Preserve signed thinking blocks across tool rounds — Anthropic rejects a
tool_use whose preceding thinking block was dropped. Raise default
max_tokens to 32k for thinking-by-default models and request summarized
display so the reasoning pane is fed.
This commit is contained in:
Fimeg 2026-08-04 11:27:13 -04:00
commit 1a3c24fadb
7 changed files with 145 additions and 33 deletions

View file

@ -114,6 +114,23 @@ pub struct Message {
/// Function name for `role: "tool"` messages (some providers require it).
#[serde(skip_serializing_if = "Option::is_none", default)]
pub name: Option<String>,
/// Signed thinking block from the assistant turn, carried so it can be
/// replayed. Anthropic requires the thinking block that preceded a
/// `tool_use` to come back with it, byte-identical and with its signature,
/// or the follow-up request is rejected. Only providers that return a
/// signed block populate this; it is skipped on the wire when absent, so
/// OpenAI-shaped gateways never see it.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub thinking: Option<ThinkingBlock>,
}
/// An Anthropic thinking block preserved verbatim for replay. Both halves are
/// required — text without its signature cannot be replayed, so this is only
/// ever constructed when the provider returned both.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThinkingBlock {
pub text: String,
pub signature: String,
}
impl Message {
@ -125,6 +142,7 @@ impl Message {
tool_calls: None,
tool_call_id: None,
name: None,
thinking: None,
}
}
@ -136,6 +154,7 @@ impl Message {
tool_calls: None,
tool_call_id: None,
name: None,
thinking: None,
}
}
@ -147,9 +166,26 @@ impl Message {
tool_calls: Some(calls),
tool_call_id: None,
name: None,
thinking: None,
}
}
/// Attach a signed thinking block for replay. No-op unless both the text
/// and the signature are present.
pub fn with_thinking(
mut self,
text: Option<impl Into<String>>,
signature: Option<impl Into<String>>,
) -> Self {
if let (Some(text), Some(signature)) = (text, signature) {
self.thinking = Some(ThinkingBlock {
text: text.into(),
signature: signature.into(),
});
}
self
}
/// Tool-result message bound to a prior assistant tool_call by id.
pub fn tool_result(
tool_call_id: impl Into<String>,
@ -162,6 +198,7 @@ impl Message {
tool_calls: None,
tool_call_id: Some(tool_call_id.into()),
name: Some(name.into()),
thinking: None,
}
}
}
@ -324,6 +361,10 @@ pub struct StreamToolCallFunction {
pub struct CompletionResult {
pub content: String,
pub reasoning: Option<String>,
/// Opaque signature for the thinking block in `reasoning`. Anthropic
/// returns it and requires it back verbatim when the same assistant turn
/// carried a `tool_use`. `None` for providers that don't sign thinking.
pub reasoning_signature: Option<String>,
pub tool_calls: Vec<ParsedToolCall>,
pub finish_reason: Option<String>,
pub usage: Option<Usage>,
@ -695,6 +736,8 @@ impl BifrostClient {
Ok(CompletionResult {
content,
reasoning,
// OpenAI-shaped gateways return reasoning text unsigned.
reasoning_signature: None,
tool_calls,
finish_reason: choice.finish_reason.clone(),
usage: parsed.usage,

View file

@ -51,6 +51,37 @@ const CC_PLATFORM: &str = "claude_code_cli";
const FALLBACK_CC_VERSION: &str = "2.1.196";
const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60);
const DEFAULT_MAX_TOKENS: u32 = 8192;
/// Models that think by default cap thinking *plus* response text with
/// `max_tokens`, so 8192 truncates mid-answer.
const THINKING_MAX_TOKENS: u32 = 32000;
/// Models that removed the sampling parameters. Sending `temperature`,
/// `top_p` or `top_k` to one of these is a 400, so they are dropped rather
/// than passed through.
fn rejects_sampling_params(model: &str) -> bool {
const REMOVED: &[&str] = &[
"claude-opus-5",
"claude-opus-4-7",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-fable-5",
"claude-mythos-5",
"claude-mythos-preview",
];
REMOVED.iter().any(|m| model.starts_with(m))
}
/// Models where omitting `thinking` still runs adaptive thinking, which shares
/// the `max_tokens` budget with the visible response.
fn thinks_by_default(model: &str) -> bool {
const THINKS: &[&str] = &[
"claude-opus-5",
"claude-sonnet-5",
"claude-fable-5",
"claude-mythos-5",
];
THINKS.iter().any(|m| model.starts_with(m))
}
/// Direct Claude Code subscription inference provider.
pub struct ClaudeSubscriptionProvider {
@ -160,14 +191,27 @@ impl ClaudeSubscriptionProvider {
/// `/v1/messages` body.
fn shape_body(&self, model: &str, request: &ChatCompletionRequest) -> Result<Vec<u8>> {
let (messages, system_text, first_user_text) = translate_messages(&request.messages);
let default_max_tokens = if thinks_by_default(model) {
THINKING_MAX_TOKENS
} else {
DEFAULT_MAX_TOKENS
};
let mut body = json!({
"model": model,
"messages": messages,
"max_tokens": request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
"max_tokens": request.max_tokens.unwrap_or(default_max_tokens),
"stream": false,
});
if let Some(t) = request.temperature {
body["temperature"] = json!(t);
if !rejects_sampling_params(model) {
body["temperature"] = json!(t);
}
}
if thinks_by_default(model) {
// Thinking runs either way on these models; the default display is
// "omitted", which returns signed blocks whose text is empty and
// leaves the reasoning pane blank. Ask for the summary instead.
body["thinking"] = json!({ "type": "adaptive", "display": "summarized" });
}
if let Some(system) = system_text {
body["system"] = json!(system);
@ -598,6 +642,20 @@ fn translate_messages(openai: &[Message]) -> (Vec<Value>, Option<String>, String
push_merge(&mut merged, "user", block);
}
"assistant" => {
// The thinking block must lead the turn and must come back
// unmodified — Anthropic rejects a tool_use whose preceding
// thinking block was dropped or edited.
if let Some(thinking) = &msg.thinking {
push_merge(
&mut merged,
"assistant",
json!({
"type": "thinking",
"thinking": thinking.text,
"signature": thinking.signature,
}),
);
}
if !msg.content.is_empty() {
push_merge(
&mut merged,
@ -706,25 +764,24 @@ fn parse_completion(text: &str) -> Result<CompletionResult> {
.unwrap_or_default();
let content = content_parts.concat();
let reasoning = value
// The first thinking block carries the signature that has to be replayed
// alongside any tool_use in this same turn. Keep its text and signature
// paired — a concatenation of several blocks could not be signed.
let thinking_block = value
.get("content")
.and_then(Value::as_array)
.and_then(|arr| {
let r: Vec<String> = arr
.iter()
.filter(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
.filter_map(|b| {
b.get("thinking")
.and_then(Value::as_str)
.map(str::to_string)
})
.collect();
if r.is_empty() {
None
} else {
Some(r.concat())
}
arr.iter()
.find(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
});
let reasoning = thinking_block
.and_then(|b| b.get("thinking"))
.and_then(Value::as_str)
.map(str::to_string);
let reasoning_signature = thinking_block
.and_then(|b| b.get("signature"))
.and_then(Value::as_str)
.map(str::to_string);
let tool_calls = value
.get("content")
@ -762,6 +819,7 @@ fn parse_completion(text: &str) -> Result<CompletionResult> {
Ok(CompletionResult {
content,
reasoning,
reasoning_signature,
tool_calls,
finish_reason,
usage,
@ -899,6 +957,7 @@ mod tests {
}]),
tool_call_id: None,
name: None,
thinking: None,
},
Message::tool_result("call_1", "read", "example-host"),
Message::text("user", "thanks"),

View file

@ -186,6 +186,7 @@ pub fn accumulate_sse(body: &str) -> Result<CompletionResult> {
Ok(CompletionResult {
content,
reasoning: (!reasoning.is_empty()).then_some(reasoning),
reasoning_signature: None,
tool_calls,
finish_reason: Some(finish_reason.to_string()),
usage,

View file

@ -236,10 +236,12 @@ impl ReflectionEngine {
tc.arguments.to_string(),
))
.collect();
chat_messages.push(Message::assistant_tool_calls(
response.content.clone(),
calls,
));
chat_messages.push(
Message::assistant_tool_calls(response.content.clone(), calls).with_thinking(
response.reasoning.clone(),
response.reasoning_signature.clone(),
),
);
for tc in &response.tool_calls {
let input_str = tc.arguments.to_string();

View file

@ -868,10 +868,12 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
tc.arguments.to_string(),
))
.collect();
messages.push(Message::assistant_tool_calls(
response.content.clone(),
calls,
));
messages.push(
Message::assistant_tool_calls(response.content.clone(), calls).with_thinking(
response.reasoning.clone(),
response.reasoning_signature.clone(),
),
);
// Execute each tool call and bind the result by tool_call_id.
// Halt and intrusive are signal tools — we read them out of the

View file

@ -166,10 +166,13 @@ impl SubagentRunner for ServerSubagentRunner {
tc.arguments.to_string(),
))
.collect();
messages.push(BifrostMessage::assistant_tool_calls(
response.content.clone(),
calls,
));
messages.push(
BifrostMessage::assistant_tool_calls(response.content.clone(), calls)
.with_thinking(
response.reasoning.clone(),
response.reasoning_signature.clone(),
),
);
// Execute tools with context, bind each result by tool_call_id
for tc in &response.tool_calls {

View file

@ -523,10 +523,12 @@ pub(crate) async fn run_turn(
tc.arguments.to_string(),
))
.collect();
messages.push(BifrostMessage::assistant_tool_calls(
response.content.clone(),
calls,
));
messages.push(
BifrostMessage::assistant_tool_calls(response.content.clone(), calls).with_thinking(
response.reasoning.clone(),
response.reasoning_signature.clone(),
),
);
// Stream any text the model produced alongside tool calls as italic
// interstitial narration. Configurable via tui.show_interstitial.