mirror of
https://github.com/delibae/claude-prism.git
synced 2026-10-07 02:58:00 +00:00
Surface compatible provider reasoning content
This commit is contained in:
parent
b059e3bb07
commit
3a45b0a4ad
3 changed files with 208 additions and 19 deletions
|
|
@ -1996,6 +1996,7 @@ struct DirectToolOutput {
|
|||
struct DirectChatResponse {
|
||||
message: serde_json::Value,
|
||||
content: String,
|
||||
reasoning: String,
|
||||
tool_calls: Vec<DirectToolCall>,
|
||||
usage: serde_json::Value,
|
||||
streamed_text: bool,
|
||||
|
|
@ -2827,8 +2828,41 @@ fn direct_assistant_content(response: &serde_json::Value) -> String {
|
|||
String::new()
|
||||
}
|
||||
|
||||
fn direct_assistant_reasoning(response: &serde_json::Value) -> String {
|
||||
let Some(message) = response.pointer("/choices/0/message") else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
for key in ["reasoning_content", "reasoning", "reasoning_text"] {
|
||||
if let Some(text) = message.get(key).and_then(|v| v.as_str()) {
|
||||
return text.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parts) = message.get("content").and_then(|v| v.as_array()) {
|
||||
return parts
|
||||
.iter()
|
||||
.filter(|part| {
|
||||
matches!(
|
||||
part.get("type").and_then(|v| v.as_str()),
|
||||
Some("reasoning") | Some("thinking") | Some("reasoning_text")
|
||||
)
|
||||
})
|
||||
.filter_map(|part| {
|
||||
part.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| part.get("content").and_then(|v| v.as_str()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn direct_chat_response_from_value(response: serde_json::Value) -> DirectChatResponse {
|
||||
let content = direct_assistant_content(&response);
|
||||
let reasoning = direct_assistant_reasoning(&response);
|
||||
let tool_calls = parse_direct_tool_calls(&response);
|
||||
let usage = json_usage(&response);
|
||||
let message = response
|
||||
|
|
@ -2838,6 +2872,7 @@ fn direct_chat_response_from_value(response: serde_json::Value) -> DirectChatRes
|
|||
DirectChatResponse {
|
||||
message,
|
||||
content,
|
||||
reasoning,
|
||||
tool_calls,
|
||||
usage,
|
||||
streamed_text: false,
|
||||
|
|
@ -2865,10 +2900,50 @@ fn direct_stream_text_delta(delta: &serde_json::Value) -> String {
|
|||
String::new()
|
||||
}
|
||||
|
||||
fn emit_direct_streaming_delta(window: &WebviewWindow, tab_id: &str, text: &str) {
|
||||
if text.is_empty() {
|
||||
fn direct_stream_reasoning_delta(delta: &serde_json::Value) -> String {
|
||||
for key in ["reasoning_content", "reasoning", "reasoning_text"] {
|
||||
if let Some(text) = delta.get(key).and_then(|v| v.as_str()) {
|
||||
return text.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parts) = delta.get("content").and_then(|v| v.as_array()) {
|
||||
return parts
|
||||
.iter()
|
||||
.filter(|part| {
|
||||
matches!(
|
||||
part.get("type").and_then(|v| v.as_str()),
|
||||
Some("reasoning") | Some("thinking") | Some("reasoning_text")
|
||||
)
|
||||
})
|
||||
.filter_map(|part| {
|
||||
part.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| part.get("content").and_then(|v| v.as_str()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn emit_direct_streaming_delta(
|
||||
window: &WebviewWindow,
|
||||
tab_id: &str,
|
||||
text: &str,
|
||||
reasoning: &str,
|
||||
) {
|
||||
if text.is_empty() && reasoning.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut blocks = Vec::new();
|
||||
if !reasoning.is_empty() {
|
||||
blocks.push(json!({ "type": "thinking", "thinking": reasoning }));
|
||||
}
|
||||
if !text.is_empty() {
|
||||
blocks.push(json!({ "type": "text", "text": text }));
|
||||
}
|
||||
emit_direct_output(
|
||||
window,
|
||||
tab_id,
|
||||
|
|
@ -2876,7 +2951,7 @@ fn emit_direct_streaming_delta(window: &WebviewWindow, tab_id: &str, text: &str)
|
|||
"type": "assistant",
|
||||
"subtype": "streaming_delta",
|
||||
"message": {
|
||||
"content": [{ "type": "text", "text": text }],
|
||||
"content": blocks,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -2905,7 +2980,11 @@ fn direct_tool_calls_from_stream(
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn direct_message_from_parts(content: &str, tool_calls: &[DirectToolCall]) -> serde_json::Value {
|
||||
fn direct_message_from_parts(
|
||||
content: &str,
|
||||
reasoning: &str,
|
||||
tool_calls: &[DirectToolCall],
|
||||
) -> serde_json::Value {
|
||||
let mut message = json!({
|
||||
"role": "assistant",
|
||||
"content": if content.trim().is_empty() {
|
||||
|
|
@ -2914,6 +2993,11 @@ fn direct_message_from_parts(content: &str, tool_calls: &[DirectToolCall]) -> se
|
|||
json!(content)
|
||||
},
|
||||
});
|
||||
if !reasoning.trim().is_empty() {
|
||||
if let Some(object) = message.as_object_mut() {
|
||||
object.insert("reasoning_content".to_string(), json!(reasoning));
|
||||
}
|
||||
}
|
||||
if !tool_calls.is_empty() {
|
||||
if let Some(object) = message.as_object_mut() {
|
||||
object.insert(
|
||||
|
|
@ -3220,6 +3304,7 @@ async fn send_openai_compatible_streaming_chat_request(
|
|||
|
||||
let mut buffer = String::new();
|
||||
let mut content = String::new();
|
||||
let mut reasoning = String::new();
|
||||
let mut tool_calls: HashMap<usize, DirectStreamingToolCall> = HashMap::new();
|
||||
let mut usage = json!({ "input_tokens": 0, "output_tokens": 0 });
|
||||
let mut streamed_text = false;
|
||||
|
|
@ -3246,10 +3331,11 @@ async fn send_openai_compatible_streaming_chat_request(
|
|||
let data = line.trim_start_matches("data:").trim();
|
||||
if data == "[DONE]" {
|
||||
let tool_calls = direct_tool_calls_from_stream(tool_calls);
|
||||
let message = direct_message_from_parts(&content, &tool_calls);
|
||||
let message = direct_message_from_parts(&content, &reasoning, &tool_calls);
|
||||
return Ok(DirectChatResponse {
|
||||
message,
|
||||
content,
|
||||
reasoning,
|
||||
tool_calls,
|
||||
usage,
|
||||
streamed_text,
|
||||
|
|
@ -3271,11 +3357,16 @@ async fn send_openai_compatible_streaming_chat_request(
|
|||
};
|
||||
|
||||
let text_delta = direct_stream_text_delta(delta);
|
||||
let reasoning_delta = direct_stream_reasoning_delta(delta);
|
||||
if !text_delta.is_empty() {
|
||||
content.push_str(&text_delta);
|
||||
streamed_text = true;
|
||||
emit_direct_streaming_delta(window, tab_id, &text_delta);
|
||||
}
|
||||
if !reasoning_delta.is_empty() {
|
||||
reasoning.push_str(&reasoning_delta);
|
||||
streamed_text = true;
|
||||
}
|
||||
emit_direct_streaming_delta(window, tab_id, &text_delta, &reasoning_delta);
|
||||
|
||||
if let Some(calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for (fallback_idx, call) in calls.iter().enumerate() {
|
||||
|
|
@ -3304,10 +3395,11 @@ async fn send_openai_compatible_streaming_chat_request(
|
|||
}
|
||||
|
||||
let tool_calls = direct_tool_calls_from_stream(tool_calls);
|
||||
let message = direct_message_from_parts(&content, &tool_calls);
|
||||
let message = direct_message_from_parts(&content, &reasoning, &tool_calls);
|
||||
Ok(DirectChatResponse {
|
||||
message,
|
||||
content,
|
||||
reasoning,
|
||||
tool_calls,
|
||||
usage,
|
||||
streamed_text,
|
||||
|
|
@ -3430,11 +3522,18 @@ async fn execute_openai_compatible_provider(
|
|||
}
|
||||
|
||||
let content = response.content;
|
||||
let reasoning = response.reasoning;
|
||||
let tool_calls = response.tool_calls;
|
||||
let usage = response.usage;
|
||||
final_usage = usage.clone();
|
||||
|
||||
let mut content_blocks = Vec::new();
|
||||
if !reasoning.trim().is_empty() {
|
||||
content_blocks.push(json!({
|
||||
"type": "thinking",
|
||||
"thinking": reasoning,
|
||||
}));
|
||||
}
|
||||
if !content.trim().is_empty() {
|
||||
content_blocks.push(json!({ "type": "text", "text": content.clone() }));
|
||||
}
|
||||
|
|
@ -4465,15 +4564,46 @@ mod tests {
|
|||
);
|
||||
|
||||
let calls = direct_tool_calls_from_stream(calls);
|
||||
let message = direct_message_from_parts("Checking the file", &calls);
|
||||
let message = direct_message_from_parts("Checking the file", "Thinking aloud", &calls);
|
||||
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "Read");
|
||||
assert_eq!(calls[0].input["file_path"], "main.tex");
|
||||
assert_eq!(message["content"], "Checking the file");
|
||||
assert_eq!(message["reasoning_content"], "Thinking aloud");
|
||||
assert_eq!(message["tool_calls"][0]["function"]["name"], "Read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_provider_extracts_reasoning_content() {
|
||||
let response = json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"reasoning_content": "First reason about structure.",
|
||||
"content": "Then answer."
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
direct_assistant_reasoning(&response),
|
||||
"First reason about structure."
|
||||
);
|
||||
assert_eq!(direct_assistant_content(&response), "Then answer.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_provider_extracts_stream_reasoning_delta() {
|
||||
let delta = json!({
|
||||
"reasoning_content": "Step one.",
|
||||
"content": "Answer part."
|
||||
});
|
||||
|
||||
assert_eq!(direct_stream_reasoning_delta(&delta), "Step one.");
|
||||
assert_eq!(direct_stream_text_delta(&delta), "Answer part.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_provider_detects_tool_unsupported_errors() {
|
||||
assert!(provider_error_allows_toolless_retry(
|
||||
|
|
|
|||
|
|
@ -290,6 +290,39 @@ describe("Multi-tab merge triggers", () => {
|
|||
expect(messages[0].message?.content?.[0].text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("_appendMessage merges direct provider streaming thinking deltas", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
|
||||
chat._appendMessage("tab-default", {
|
||||
type: "assistant",
|
||||
subtype: "streaming_delta",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Reason A. " },
|
||||
{ type: "text", text: "Hello" },
|
||||
],
|
||||
},
|
||||
});
|
||||
chat._appendMessage("tab-default", {
|
||||
type: "assistant",
|
||||
subtype: "streaming_delta",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Reason B." },
|
||||
{ type: "text", text: " world" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const messages = useClaudeChatStore.getState().messages;
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].message?.content?.[0].type).toBe("thinking");
|
||||
expect(messages[0].message?.content?.[0].thinking).toBe(
|
||||
"Reason A. Reason B.",
|
||||
);
|
||||
expect(messages[0].message?.content?.[1].text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("_appendMessage replaces streaming deltas with final direct provider message", () => {
|
||||
const chat = useClaudeChatStore.getState();
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,41 @@ function applyTabUpdate(
|
|||
|
||||
// ─── State Interface ───
|
||||
|
||||
function mergeStreamingContent(
|
||||
existing: ContentBlock[],
|
||||
incoming: ContentBlock[],
|
||||
): ContentBlock[] {
|
||||
let merged = [...existing];
|
||||
for (const block of incoming) {
|
||||
if (block.type === "text" && block.text) {
|
||||
const idx = merged.findIndex((item) => item.type === "text");
|
||||
if (idx >= 0) {
|
||||
merged = merged.map((item, itemIdx) =>
|
||||
itemIdx === idx
|
||||
? { ...item, text: `${item.text ?? ""}${block.text}` }
|
||||
: item,
|
||||
);
|
||||
} else {
|
||||
merged.push(block);
|
||||
}
|
||||
} else if (block.type === "thinking" && block.thinking) {
|
||||
const idx = merged.findIndex((item) => item.type === "thinking");
|
||||
if (idx >= 0) {
|
||||
merged = merged.map((item, itemIdx) =>
|
||||
itemIdx === idx
|
||||
? { ...item, thinking: `${item.thinking ?? ""}${block.thinking}` }
|
||||
: item,
|
||||
);
|
||||
} else {
|
||||
merged.unshift(block);
|
||||
}
|
||||
} else {
|
||||
merged.push(block);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_ID = nextTabId();
|
||||
|
||||
interface ClaudeChatState {
|
||||
|
|
@ -594,21 +629,12 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
if (last?.type === "assistant" && last.subtype === "streaming_delta") {
|
||||
const existing = last.message?.content ?? [];
|
||||
const incoming = msg.message?.content ?? [];
|
||||
const existingText = existing.find((block) => block.type === "text");
|
||||
const incomingText = incoming.find((block) => block.type === "text");
|
||||
if (existingText && incomingText?.text) {
|
||||
if (incoming.length > 0) {
|
||||
const merged: ClaudeStreamMessage = {
|
||||
...last,
|
||||
message: {
|
||||
...last.message,
|
||||
content: existing.map((block) =>
|
||||
block === existingText
|
||||
? {
|
||||
...block,
|
||||
text: `${block.text ?? ""}${incomingText.text}`,
|
||||
}
|
||||
: block,
|
||||
),
|
||||
content: mergeStreamingContent(existing, incoming),
|
||||
},
|
||||
};
|
||||
return applyTabUpdate(state, tabId, {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue