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:
parent
d0c6488099
commit
1a3c24fadb
7 changed files with 145 additions and 33 deletions
|
|
@ -114,6 +114,23 @@ pub struct Message {
|
||||||
/// Function name for `role: "tool"` messages (some providers require it).
|
/// Function name for `role: "tool"` messages (some providers require it).
|
||||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||||
pub name: Option<String>,
|
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 {
|
impl Message {
|
||||||
|
|
@ -125,6 +142,7 @@ impl Message {
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
name: None,
|
name: None,
|
||||||
|
thinking: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +154,7 @@ impl Message {
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
name: None,
|
name: None,
|
||||||
|
thinking: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,9 +166,26 @@ impl Message {
|
||||||
tool_calls: Some(calls),
|
tool_calls: Some(calls),
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
name: 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.
|
/// Tool-result message bound to a prior assistant tool_call by id.
|
||||||
pub fn tool_result(
|
pub fn tool_result(
|
||||||
tool_call_id: impl Into<String>,
|
tool_call_id: impl Into<String>,
|
||||||
|
|
@ -162,6 +198,7 @@ impl Message {
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: Some(tool_call_id.into()),
|
tool_call_id: Some(tool_call_id.into()),
|
||||||
name: Some(name.into()),
|
name: Some(name.into()),
|
||||||
|
thinking: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -324,6 +361,10 @@ pub struct StreamToolCallFunction {
|
||||||
pub struct CompletionResult {
|
pub struct CompletionResult {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub reasoning: Option<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 tool_calls: Vec<ParsedToolCall>,
|
||||||
pub finish_reason: Option<String>,
|
pub finish_reason: Option<String>,
|
||||||
pub usage: Option<Usage>,
|
pub usage: Option<Usage>,
|
||||||
|
|
@ -695,6 +736,8 @@ impl BifrostClient {
|
||||||
Ok(CompletionResult {
|
Ok(CompletionResult {
|
||||||
content,
|
content,
|
||||||
reasoning,
|
reasoning,
|
||||||
|
// OpenAI-shaped gateways return reasoning text unsigned.
|
||||||
|
reasoning_signature: None,
|
||||||
tool_calls,
|
tool_calls,
|
||||||
finish_reason: choice.finish_reason.clone(),
|
finish_reason: choice.finish_reason.clone(),
|
||||||
usage: parsed.usage,
|
usage: parsed.usage,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,37 @@ const CC_PLATFORM: &str = "claude_code_cli";
|
||||||
const FALLBACK_CC_VERSION: &str = "2.1.196";
|
const FALLBACK_CC_VERSION: &str = "2.1.196";
|
||||||
const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60);
|
const TOKEN_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60);
|
||||||
const DEFAULT_MAX_TOKENS: u32 = 8192;
|
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.
|
/// Direct Claude Code subscription inference provider.
|
||||||
pub struct ClaudeSubscriptionProvider {
|
pub struct ClaudeSubscriptionProvider {
|
||||||
|
|
@ -160,14 +191,27 @@ impl ClaudeSubscriptionProvider {
|
||||||
/// `/v1/messages` body.
|
/// `/v1/messages` body.
|
||||||
fn shape_body(&self, model: &str, request: &ChatCompletionRequest) -> Result<Vec<u8>> {
|
fn shape_body(&self, model: &str, request: &ChatCompletionRequest) -> Result<Vec<u8>> {
|
||||||
let (messages, system_text, first_user_text) = translate_messages(&request.messages);
|
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!({
|
let mut body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"max_tokens": request.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
|
"max_tokens": request.max_tokens.unwrap_or(default_max_tokens),
|
||||||
"stream": false,
|
"stream": false,
|
||||||
});
|
});
|
||||||
if let Some(t) = request.temperature {
|
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 {
|
if let Some(system) = system_text {
|
||||||
body["system"] = json!(system);
|
body["system"] = json!(system);
|
||||||
|
|
@ -598,6 +642,20 @@ fn translate_messages(openai: &[Message]) -> (Vec<Value>, Option<String>, String
|
||||||
push_merge(&mut merged, "user", block);
|
push_merge(&mut merged, "user", block);
|
||||||
}
|
}
|
||||||
"assistant" => {
|
"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() {
|
if !msg.content.is_empty() {
|
||||||
push_merge(
|
push_merge(
|
||||||
&mut merged,
|
&mut merged,
|
||||||
|
|
@ -706,25 +764,24 @@ fn parse_completion(text: &str) -> Result<CompletionResult> {
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let content = content_parts.concat();
|
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")
|
.get("content")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.and_then(|arr| {
|
.and_then(|arr| {
|
||||||
let r: Vec<String> = arr
|
arr.iter()
|
||||||
.iter()
|
.find(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
|
||||||
.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())
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
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
|
let tool_calls = value
|
||||||
.get("content")
|
.get("content")
|
||||||
|
|
@ -762,6 +819,7 @@ fn parse_completion(text: &str) -> Result<CompletionResult> {
|
||||||
Ok(CompletionResult {
|
Ok(CompletionResult {
|
||||||
content,
|
content,
|
||||||
reasoning,
|
reasoning,
|
||||||
|
reasoning_signature,
|
||||||
tool_calls,
|
tool_calls,
|
||||||
finish_reason,
|
finish_reason,
|
||||||
usage,
|
usage,
|
||||||
|
|
@ -899,6 +957,7 @@ mod tests {
|
||||||
}]),
|
}]),
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
name: None,
|
name: None,
|
||||||
|
thinking: None,
|
||||||
},
|
},
|
||||||
Message::tool_result("call_1", "read", "example-host"),
|
Message::tool_result("call_1", "read", "example-host"),
|
||||||
Message::text("user", "thanks"),
|
Message::text("user", "thanks"),
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,7 @@ pub fn accumulate_sse(body: &str) -> Result<CompletionResult> {
|
||||||
Ok(CompletionResult {
|
Ok(CompletionResult {
|
||||||
content,
|
content,
|
||||||
reasoning: (!reasoning.is_empty()).then_some(reasoning),
|
reasoning: (!reasoning.is_empty()).then_some(reasoning),
|
||||||
|
reasoning_signature: None,
|
||||||
tool_calls,
|
tool_calls,
|
||||||
finish_reason: Some(finish_reason.to_string()),
|
finish_reason: Some(finish_reason.to_string()),
|
||||||
usage,
|
usage,
|
||||||
|
|
|
||||||
|
|
@ -236,10 +236,12 @@ impl ReflectionEngine {
|
||||||
tc.arguments.to_string(),
|
tc.arguments.to_string(),
|
||||||
))
|
))
|
||||||
.collect();
|
.collect();
|
||||||
chat_messages.push(Message::assistant_tool_calls(
|
chat_messages.push(
|
||||||
response.content.clone(),
|
Message::assistant_tool_calls(response.content.clone(), calls).with_thinking(
|
||||||
calls,
|
response.reasoning.clone(),
|
||||||
));
|
response.reasoning_signature.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
for tc in &response.tool_calls {
|
for tc in &response.tool_calls {
|
||||||
let input_str = tc.arguments.to_string();
|
let input_str = tc.arguments.to_string();
|
||||||
|
|
|
||||||
|
|
@ -868,10 +868,12 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
||||||
tc.arguments.to_string(),
|
tc.arguments.to_string(),
|
||||||
))
|
))
|
||||||
.collect();
|
.collect();
|
||||||
messages.push(Message::assistant_tool_calls(
|
messages.push(
|
||||||
response.content.clone(),
|
Message::assistant_tool_calls(response.content.clone(), calls).with_thinking(
|
||||||
calls,
|
response.reasoning.clone(),
|
||||||
));
|
response.reasoning_signature.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Execute each tool call and bind the result by tool_call_id.
|
// Execute each tool call and bind the result by tool_call_id.
|
||||||
// Halt and intrusive are signal tools — we read them out of the
|
// Halt and intrusive are signal tools — we read them out of the
|
||||||
|
|
|
||||||
|
|
@ -166,10 +166,13 @@ impl SubagentRunner for ServerSubagentRunner {
|
||||||
tc.arguments.to_string(),
|
tc.arguments.to_string(),
|
||||||
))
|
))
|
||||||
.collect();
|
.collect();
|
||||||
messages.push(BifrostMessage::assistant_tool_calls(
|
messages.push(
|
||||||
response.content.clone(),
|
BifrostMessage::assistant_tool_calls(response.content.clone(), calls)
|
||||||
calls,
|
.with_thinking(
|
||||||
));
|
response.reasoning.clone(),
|
||||||
|
response.reasoning_signature.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Execute tools with context, bind each result by tool_call_id
|
// Execute tools with context, bind each result by tool_call_id
|
||||||
for tc in &response.tool_calls {
|
for tc in &response.tool_calls {
|
||||||
|
|
|
||||||
|
|
@ -523,10 +523,12 @@ pub(crate) async fn run_turn(
|
||||||
tc.arguments.to_string(),
|
tc.arguments.to_string(),
|
||||||
))
|
))
|
||||||
.collect();
|
.collect();
|
||||||
messages.push(BifrostMessage::assistant_tool_calls(
|
messages.push(
|
||||||
response.content.clone(),
|
BifrostMessage::assistant_tool_calls(response.content.clone(), calls).with_thinking(
|
||||||
calls,
|
response.reasoning.clone(),
|
||||||
));
|
response.reasoning_signature.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Stream any text the model produced alongside tool calls as italic
|
// Stream any text the model produced alongside tool calls as italic
|
||||||
// interstitial narration. Configurable via tui.show_interstitial.
|
// interstitial narration. Configurable via tui.show_interstitial.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue