feat: v0.4.0 - Fix Claude Code tool_use parsing and add version management

- Fix tool_use input parsing: use HashMap to track concurrent tool calls
- Correctly accumulate input fragments for each tool_use event
- Fix tool_result conversion in openai_to_cw.rs
- Add streaming response text block for Claude Code compatibility
- Add model mappings for claude-opus-4-5-20251101 and claude-haiku-4-5-20251001
- Add debug logging for CodeWhisperer requests
- Add unified version management script (scripts/update-version.sh)
- Apply cargo fmt and prettier formatting
This commit is contained in:
coso
2025-12-14 12:32:43 +08:00
parent 9682d405b6
commit 97e6ba2651
14 changed files with 591 additions and 265 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.3.0",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# 统一版本管理脚本
# 用法: ./scripts/update-version.sh <new_version>
# 示例: ./scripts/update-version.sh 0.5.0
set -e
if [ -z "$1" ]; then
echo "用法: $0 <new_version>"
echo "示例: $0 0.5.0"
exit 1
fi
NEW_VERSION="$1"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
echo "更新版本到 $NEW_VERSION..."
# 更新 package.json
sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$NEW_VERSION\"/" "$PROJECT_ROOT/package.json"
echo "✓ package.json"
# 更新 Cargo.toml
sed -i '' "s/^version = \"[^\"]*\"/version = \"$NEW_VERSION\"/" "$PROJECT_ROOT/src-tauri/Cargo.toml"
echo "✓ src-tauri/Cargo.toml"
# 更新 tauri.conf.json
sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$NEW_VERSION\"/" "$PROJECT_ROOT/src-tauri/tauri.conf.json"
echo "✓ src-tauri/tauri.conf.json"
echo ""
echo "版本已更新到 $NEW_VERSION"
echo ""
echo "下一步:"
echo " git add -A"
echo " git commit -m 'chore: bump version to $NEW_VERSION'"
echo " git tag v$NEW_VERSION"
echo " git push origin main --tags"
+1 -1
View File
@@ -2789,7 +2789,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"async-stream",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.3.0"
version = "0.4.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+3 -3
View File
@@ -14,7 +14,7 @@ pub fn convert_cw_event_to_openai_chunk(
) -> Option<ChatCompletionChunk> {
let created = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs();
if let Some(resp_event) = &event.assistant_response_event {
@@ -78,7 +78,7 @@ pub fn create_openai_response(
) -> ChatCompletionResponse {
let created = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs();
let finish_reason = if tool_calls.is_some() {
@@ -117,7 +117,7 @@ pub fn create_openai_response(
pub fn create_stream_end_chunk(model: &str, response_id: &str) -> ChatCompletionChunk {
let created = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs();
ChatCompletionChunk {
+208 -111
View File
@@ -8,7 +8,9 @@ use uuid::Uuid;
pub fn get_model_map() -> HashMap<&'static str, &'static str> {
let mut map = HashMap::new();
map.insert("claude-opus-4-5", "claude-opus-4.5");
map.insert("claude-opus-4-5-20251101", "claude-opus-4.5");
map.insert("claude-haiku-4-5", "claude-haiku-4.5");
map.insert("claude-haiku-4-5-20251001", "claude-haiku-4.5");
map.insert("claude-sonnet-4-5", "CLAUDE_SONNET_4_5_20250929_V1_0");
map.insert(
"claude-sonnet-4-5-20250929",
@@ -32,6 +34,111 @@ pub fn get_model_map() -> HashMap<&'static str, &'static str> {
pub const DEFAULT_MODEL: &str = "CLAUDE_SONNET_4_5_20250929_V1_0";
/// 预处理消息:合并连续的 tool 消息到前一个 assistant 消息后的 user 消息
fn preprocess_messages(messages: &[&ChatMessage]) -> Vec<ProcessedMessage> {
let mut result: Vec<ProcessedMessage> = Vec::new();
let mut pending_tool_results: Vec<CWToolResult> = Vec::new();
for msg in messages {
match msg.role.as_str() {
"tool" => {
// 收集 tool 结果
let content = msg.get_content_text();
let tool_id = msg.tool_call_id.clone().unwrap_or_default();
pending_tool_results.push(CWToolResult {
content: vec![CWTextContent { text: content }],
status: "success".to_string(),
tool_use_id: tool_id,
});
}
"user" => {
// 如果有待处理的 tool results,合并到这个 user 消息
let content = msg.get_content_text();
let mut tool_results = pending_tool_results.clone();
pending_tool_results.clear();
// 去重 tool_results
let mut seen_ids = std::collections::HashSet::new();
tool_results.retain(|tr| seen_ids.insert(tr.tool_use_id.clone()));
result.push(ProcessedMessage {
role: "user".to_string(),
content,
tool_calls: None,
tool_results: if tool_results.is_empty() {
None
} else {
Some(tool_results)
},
});
}
"assistant" => {
// 如果有待处理的 tool results,先创建一个 user 消息
if !pending_tool_results.is_empty() {
let mut tool_results = pending_tool_results.clone();
pending_tool_results.clear();
// 去重 tool_results
let mut seen_ids = std::collections::HashSet::new();
tool_results.retain(|tr| seen_ids.insert(tr.tool_use_id.clone()));
result.push(ProcessedMessage {
role: "user".to_string(),
content: "Tool results provided.".to_string(),
tool_calls: None,
tool_results: Some(tool_results),
});
}
let content = msg.get_content_text();
let tool_calls = msg.tool_calls.as_ref().map(|calls| {
calls
.iter()
.map(|tc| CWToolUse {
input: serde_json::from_str(&tc.function.arguments)
.unwrap_or(serde_json::json!({})),
name: tc.function.name.clone(),
tool_use_id: tc.id.clone(),
})
.collect()
});
result.push(ProcessedMessage {
role: "assistant".to_string(),
content,
tool_calls,
tool_results: None,
});
}
_ => {}
}
}
// 处理末尾的 tool results
if !pending_tool_results.is_empty() {
let mut tool_results = pending_tool_results;
let mut seen_ids = std::collections::HashSet::new();
tool_results.retain(|tr| seen_ids.insert(tr.tool_use_id.clone()));
result.push(ProcessedMessage {
role: "user".to_string(),
content: "Tool results provided.".to_string(),
tool_calls: None,
tool_results: Some(tool_results),
});
}
result
}
#[derive(Debug, Clone)]
struct ProcessedMessage {
role: String,
content: String,
tool_calls: Option<Vec<CWToolUse>>,
tool_results: Option<Vec<CWToolResult>>,
}
/// 将 OpenAI ChatCompletionRequest 转换为 CodeWhisperer 请求
pub fn convert_openai_to_codewhisperer(
request: &ChatCompletionRequest,
@@ -47,32 +154,46 @@ pub fn convert_openai_to_codewhisperer(
// 提取 system prompt 和消息
let mut system_prompt = String::new();
let mut messages: Vec<&ChatMessage> = Vec::new();
let mut raw_messages: Vec<&ChatMessage> = Vec::new();
for msg in &request.messages {
if msg.role == "system" {
system_prompt = msg.get_content_text();
} else {
messages.push(msg);
raw_messages.push(msg);
}
}
// 预处理消息:合并 tool 消息
let messages = preprocess_messages(&raw_messages);
// 构建历史记录
let mut history: Vec<HistoryItem> = Vec::new();
let mut start_idx = 0;
// 处理 system prompt - 合并到第一条用户消息
if !system_prompt.is_empty() && !messages.is_empty() && messages[0].role == "user" {
let first_content = messages[0].get_content_text();
let first_content = &messages[0].content;
let combined = format!("{system_prompt}\n\n{first_content}");
let mut user_msg = UserInputMessage {
content: combined,
model_id: cw_model.clone(),
origin: "AI_EDITOR".to_string(),
images: None,
user_input_message_context: None,
};
// 如果第一条消息有 tool_results,也要包含
if let Some(ref tool_results) = messages[0].tool_results {
user_msg.user_input_message_context = Some(UserInputMessageContext {
tools: None,
tool_results: Some(tool_results.clone()),
});
}
history.push(HistoryItem::User(UserHistoryItem {
user_input_message: UserInputMessage {
content: combined,
model_id: cw_model.clone(),
origin: "AI_EDITOR".to_string(),
images: None,
user_input_message_context: None,
},
user_input_message: user_msg,
}));
start_idx = 1;
}
@@ -85,29 +206,28 @@ pub fn convert_openai_to_codewhisperer(
{
match msg.role.as_str() {
"user" => {
let content = msg.get_content_text();
let tool_results = extract_tool_results(msg);
let content = if msg.content.is_empty() {
if msg.tool_results.is_some() {
"Tool results provided.".to_string()
} else {
"Continue".to_string()
}
} else {
msg.content.clone()
};
let mut user_msg = UserInputMessage {
content: if content.is_empty() {
if tool_results.is_some() {
"Tool results provided.".to_string()
} else {
"Continue".to_string()
}
} else {
content
},
content,
model_id: cw_model.clone(),
origin: "AI_EDITOR".to_string(),
images: None,
user_input_message_context: None,
};
if tool_results.is_some() {
if let Some(ref tool_results) = msg.tool_results {
user_msg.user_input_message_context = Some(UserInputMessageContext {
tools: None,
tool_results,
tool_results: Some(tool_results.clone()),
});
}
@@ -116,41 +236,16 @@ pub fn convert_openai_to_codewhisperer(
}));
}
"assistant" => {
let content = msg.get_content_text();
let tool_uses = extract_tool_uses(msg);
let content = if msg.content.is_empty() {
"I understand.".to_string()
} else {
msg.content.clone()
};
history.push(HistoryItem::Assistant(AssistantHistoryItem {
assistant_response_message: AssistantResponseMessage {
content: if content.is_empty() {
"I understand.".to_string()
} else {
content
},
tool_uses,
},
}));
}
"tool" => {
let tool_content = msg.get_content_text();
let tool_id = msg.tool_call_id.clone().unwrap_or_default();
history.push(HistoryItem::User(UserHistoryItem {
user_input_message: UserInputMessage {
content: format!(
"Tool result: {}",
&tool_content[..tool_content.len().min(200)]
),
model_id: cw_model.clone(),
origin: "AI_EDITOR".to_string(),
images: None,
user_input_message_context: Some(UserInputMessageContext {
tools: None,
tool_results: Some(vec![CWToolResult {
content: vec![CWTextContent { text: tool_content }],
status: "success".to_string(),
tool_use_id: tool_id,
}]),
}),
content,
tool_uses: msg.tool_calls.clone(),
},
}));
}
@@ -162,20 +257,23 @@ pub fn convert_openai_to_codewhisperer(
let history = fix_history_alternation(history, &cw_model);
// 构建当前消息
let current_content = if messages.is_empty() {
"Continue".to_string()
} else {
let last_msg = messages.last().unwrap();
let (current_content, current_tool_results) = if let Some(last_msg) = messages.last() {
if last_msg.role == "assistant" {
"Continue".to_string()
("Continue".to_string(), None)
} else {
let content = last_msg.get_content_text();
if content.is_empty() {
"Continue".to_string()
let content = if last_msg.content.is_empty() {
if last_msg.tool_results.is_some() {
"Tool results provided.".to_string()
} else {
"Continue".to_string()
}
} else {
content
}
last_msg.content.clone()
};
(content, last_msg.tool_results.clone())
}
} else {
("Continue".to_string(), None)
};
// 构建 tools
@@ -211,13 +309,6 @@ pub fn convert_openai_to_codewhisperer(
.collect()
});
let current_tool_results = if !messages.is_empty() {
let last_msg = messages.last().unwrap();
extract_tool_results(last_msg)
} else {
None
};
let user_input_message_context = if tools.is_some() || current_tool_results.is_some() {
Some(UserInputMessageContext {
tools,
@@ -250,36 +341,6 @@ pub fn convert_openai_to_codewhisperer(
}
}
fn extract_tool_results(msg: &ChatMessage) -> Option<Vec<CWToolResult>> {
if msg.role == "tool" {
let content = msg.get_content_text();
let tool_id = msg.tool_call_id.clone().unwrap_or_default();
return Some(vec![CWToolResult {
content: vec![CWTextContent { text: content }],
status: "success".to_string(),
tool_use_id: tool_id,
}]);
}
None
}
fn extract_tool_uses(msg: &ChatMessage) -> Option<Vec<CWToolUse>> {
msg.tool_calls.as_ref().map(|calls| {
calls
.iter()
.map(|tc| {
let input: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::json!({}));
CWToolUse {
input,
name: tc.function.name.clone(),
tool_use_id: tc.id.clone(),
}
})
.collect()
})
}
/// 修复历史记录,确保 user/assistant 严格交替
fn fix_history_alternation(history: Vec<HistoryItem>, model_id: &str) -> Vec<HistoryItem> {
if history.is_empty() {
@@ -290,15 +351,51 @@ fn fix_history_alternation(history: Vec<HistoryItem>, model_id: &str) -> Vec<His
for item in history {
match &item {
HistoryItem::User(_) => {
// 如果上一条也是 user,插入占位 assistant
if let Some(HistoryItem::User(_)) = fixed.last() {
fixed.push(HistoryItem::Assistant(AssistantHistoryItem {
assistant_response_message: AssistantResponseMessage {
content: "I understand.".to_string(),
tool_uses: None,
},
}));
HistoryItem::User(user_item) => {
// 如果上一条也是 user,合并 tool_results 或插入占位 assistant
if let Some(HistoryItem::User(last_user)) = fixed.last_mut() {
// 尝试合并 tool_results
let has_tool_results = user_item
.user_input_message
.user_input_message_context
.as_ref()
.map(|ctx| ctx.tool_results.is_some())
.unwrap_or(false);
if has_tool_results {
// 合并 tool_results 到上一个 user 消息
let new_results = user_item
.user_input_message
.user_input_message_context
.as_ref()
.and_then(|ctx| ctx.tool_results.clone())
.unwrap_or_default();
if let Some(ref mut ctx) =
last_user.user_input_message.user_input_message_context
{
if let Some(ref mut existing) = ctx.tool_results {
existing.extend(new_results);
} else {
ctx.tool_results = Some(new_results);
}
} else {
last_user.user_input_message.user_input_message_context =
Some(UserInputMessageContext {
tools: None,
tool_results: Some(new_results),
});
}
continue;
} else {
// 插入占位 assistant
fixed.push(HistoryItem::Assistant(AssistantHistoryItem {
assistant_response_message: AssistantResponseMessage {
content: "I understand.".to_string(),
tool_uses: None,
},
}));
}
}
fixed.push(item);
}
+15 -12
View File
@@ -228,21 +228,24 @@ async fn get_env_variables(state: tauri::State<'_, AppState>) -> Result<Vec<EnvV
}
fn mask_token(token: &str) -> String {
if token.len() <= 12 {
let chars: Vec<char> = token.chars().collect();
if chars.len() <= 12 {
"****".to_string()
} else {
format!("{}****{}", &token[..6], &token[token.len() - 4..])
let prefix: String = chars[..6].iter().collect();
let suffix: String = chars[chars.len() - 4..].iter().collect();
format!("{}****{}", prefix, suffix)
}
}
#[tauri::command]
async fn get_token_file_hash() -> Result<String, String> {
let path = providers::kiro::KiroProvider::default_creds_path();
if !path.exists() {
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
return Ok("".to_string());
}
let content = std::fs::read(&path).map_err(|e| e.to_string())?;
let content = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let hash = format!("{:x}", md5::compute(&content));
Ok(hash)
}
@@ -256,7 +259,7 @@ async fn check_and_reload_credentials(
) -> Result<CheckResult, String> {
let path = providers::kiro::KiroProvider::default_creds_path();
if !path.exists() {
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
return Ok(CheckResult {
changed: false,
new_hash: "".to_string(),
@@ -264,7 +267,7 @@ async fn check_and_reload_credentials(
});
}
let content = std::fs::read(&path).map_err(|e| e.to_string())?;
let content = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let new_hash = format!("{:x}", md5::compute(&content));
if !last_hash.is_empty() && new_hash != last_hash {
@@ -414,11 +417,11 @@ async fn get_gemini_env_variables(
#[tauri::command]
async fn get_gemini_token_file_hash() -> Result<String, String> {
let path = providers::gemini::GeminiProvider::default_creds_path();
if !path.exists() {
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
return Ok("".to_string());
}
let content = std::fs::read(&path).map_err(|e| e.to_string())?;
let content = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let hash = format!("{:x}", md5::compute(&content));
Ok(hash)
}
@@ -431,7 +434,7 @@ async fn check_and_reload_gemini_credentials(
) -> Result<CheckResult, String> {
let path = providers::gemini::GeminiProvider::default_creds_path();
if !path.exists() {
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
return Ok(CheckResult {
changed: false,
new_hash: "".to_string(),
@@ -439,7 +442,7 @@ async fn check_and_reload_gemini_credentials(
});
}
let content = std::fs::read(&path).map_err(|e| e.to_string())?;
let content = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let new_hash = format!("{:x}", md5::compute(&content));
if !last_hash.is_empty() && new_hash != last_hash {
@@ -590,11 +593,11 @@ async fn get_qwen_env_variables(
#[tauri::command]
async fn get_qwen_token_file_hash() -> Result<String, String> {
let path = providers::qwen::QwenProvider::default_creds_path();
if !path.exists() {
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
return Ok("".to_string());
}
let content = std::fs::read(&path).map_err(|e| e.to_string())?;
let content = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let hash = format!("{:x}", md5::compute(&content));
Ok(hash)
}
+53 -3
View File
@@ -1,6 +1,9 @@
//! 日志管理模块
use chrono::Utc;
use chrono::{Local, Utc};
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
@@ -14,13 +17,26 @@ pub struct LogEntry {
pub struct LogStore {
logs: Vec<LogEntry>,
max_logs: usize,
log_file_path: Option<PathBuf>,
}
impl Default for LogStore {
fn default() -> Self {
// 默认日志文件路径: ~/.proxycast/logs/proxycast.log
let log_dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".proxycast")
.join("logs");
// 创建日志目录
let _ = fs::create_dir_all(&log_dir);
let log_file = log_dir.join("proxycast.log");
Self {
logs: Vec::new(),
max_logs: 1000,
log_file_path: Some(log_file),
}
}
}
@@ -31,13 +47,24 @@ impl LogStore {
}
pub fn add(&mut self, level: &str, message: &str) {
let now = Utc::now();
let entry = LogEntry {
timestamp: Utc::now().to_rfc3339(),
timestamp: now.to_rfc3339(),
level: level.to_string(),
message: message.to_string(),
};
self.logs.push(entry);
self.logs.push(entry.clone());
// 写入日志文件
if let Some(ref path) = self.log_file_path {
let local_time = Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
let log_line = format!("{} [{}] {}\n", local_time, level.to_uppercase(), message);
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = file.write_all(log_line.as_bytes());
}
}
// 保持日志数量在限制内
if self.logs.len() > self.max_logs {
@@ -45,6 +72,23 @@ impl LogStore {
}
}
/// 记录原始响应到单独的文件(用于调试)
pub fn log_raw_response(&self, request_id: &str, body: &str) {
if let Some(ref log_path) = self.log_file_path {
let log_dir = log_path.parent().unwrap_or(std::path::Path::new("."));
let raw_file = log_dir.join(format!("raw_response_{request_id}.txt"));
if let Ok(mut file) = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&raw_file)
{
let _ = file.write_all(body.as_bytes());
}
}
}
pub fn get_logs(&self) -> Vec<LogEntry> {
self.logs.clone()
}
@@ -52,6 +96,12 @@ impl LogStore {
pub fn clear(&mut self) {
self.logs.clear();
}
pub fn get_log_file_path(&self) -> Option<String> {
self.log_file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
}
}
#[allow(dead_code)]
+6 -6
View File
@@ -145,11 +145,11 @@ impl GeminiProvider {
.join(CREDENTIALS_FILE)
}
pub fn load_credentials(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
pub async fn load_credentials(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let path = Self::default_creds_path();
if path.exists() {
let content = std::fs::read_to_string(&path)?;
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
let content = tokio::fs::read_to_string(&path).await?;
let creds: GeminiCredentials = serde_json::from_str(&content)?;
self.credentials = creds;
}
@@ -157,13 +157,13 @@ impl GeminiProvider {
Ok(())
}
pub fn save_credentials(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
pub async fn save_credentials(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let path = Self::default_creds_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
tokio::fs::create_dir_all(parent).await?;
}
let content = serde_json::to_string_pretty(&self.credentials)?;
std::fs::write(&path, content)?;
tokio::fs::write(&path, content).await?;
Ok(())
}
+51 -12
View File
@@ -62,27 +62,27 @@ impl KiroProvider {
.join("kiro-auth-token.json")
}
pub fn load_credentials(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
pub async fn load_credentials(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let path = Self::default_creds_path();
let dir = path.parent().unwrap();
let dir = path.parent().ok_or("Invalid path: no parent directory")?;
let mut merged = KiroCredentials::default();
// 读取主凭证文件
if path.exists() {
let content = std::fs::read_to_string(&path)?;
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
let content = tokio::fs::read_to_string(&path).await?;
let creds: KiroCredentials = serde_json::from_str(&content)?;
merge_credentials(&mut merged, &creds);
}
// 读取目录中其他 JSON 文件
if dir.is_dir() {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if tokio::fs::try_exists(dir).await.unwrap_or(false) {
let mut entries = tokio::fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let file_path = entry.path();
if file_path.extension().map(|e| e == "json").unwrap_or(false) && file_path != path
{
if let Ok(content) = std::fs::read_to_string(&file_path) {
if let Ok(content) = tokio::fs::read_to_string(&file_path).await {
if let Ok(creds) = serde_json::from_str::<KiroCredentials>(&content) {
merge_credentials(&mut merged, &creds);
}
@@ -177,12 +177,12 @@ impl KiroProvider {
Ok(new_token.to_string())
}
pub fn save_credentials(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
pub async fn save_credentials(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let path = Self::default_creds_path();
// 读取现有文件内容
let mut existing: serde_json::Value = if path.exists() {
let content = std::fs::read_to_string(&path)?;
let mut existing: serde_json::Value = if tokio::fs::try_exists(&path).await.unwrap_or(false) {
let content = tokio::fs::read_to_string(&path).await?;
serde_json::from_str(&content).unwrap_or(serde_json::json!({}))
} else {
serde_json::json!({})
@@ -201,7 +201,7 @@ impl KiroProvider {
// 写回文件
let content = serde_json::to_string_pretty(&existing)?;
std::fs::write(&path, content)?;
tokio::fs::write(&path, content).await?;
Ok(())
}
@@ -225,6 +225,45 @@ impl KiroProvider {
let cw_request = convert_openai_to_codewhisperer(request, profile_arn);
let url = self.get_base_url();
// Debug: 记录转换后的请求
if let Ok(json_str) = serde_json::to_string_pretty(&cw_request) {
// 保存到文件用于调试
let uuid_prefix = uuid::Uuid::new_v4()
.to_string()
.split('-')
.next()
.unwrap_or("unknown")
.to_string();
let debug_path = dirs::home_dir()
.unwrap_or_default()
.join(".proxycast")
.join("logs")
.join(format!("cw_request_{}.json", uuid_prefix));
let _ = tokio::fs::write(&debug_path, &json_str).await;
tracing::debug!("[CW_REQ] Request saved to {:?}", debug_path);
// 记录历史消息数量和 tool_results 情况
let history_len = cw_request
.conversation_state
.history
.as_ref()
.map(|h| h.len())
.unwrap_or(0);
let current_has_tools = cw_request
.conversation_state
.current_message
.user_input_message
.user_input_message_context
.as_ref()
.map(|ctx| ctx.tool_results.as_ref().map(|tr| tr.len()).unwrap_or(0))
.unwrap_or(0);
tracing::info!(
"[CW_REQ] history={} current_tool_results={}",
history_len,
current_has_tools
);
}
let resp = self
.client
.post(&url)
+7 -9
View File
@@ -58,11 +58,11 @@ impl QwenProvider {
.join(CREDENTIALS_FILE)
}
pub fn load_credentials(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
pub async fn load_credentials(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let path = Self::default_creds_path();
if path.exists() {
let content = std::fs::read_to_string(&path)?;
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
let content = tokio::fs::read_to_string(&path).await?;
let creds: QwenCredentials = serde_json::from_str(&content)?;
self.credentials = creds;
}
@@ -70,13 +70,13 @@ impl QwenProvider {
Ok(())
}
pub fn save_credentials(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
pub async fn save_credentials(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let path = Self::default_creds_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
tokio::fs::create_dir_all(parent).await?;
}
let content = serde_json::to_string_pretty(&self.credentials)?;
std::fs::write(&path, content)?;
tokio::fs::write(&path, content).await?;
Ok(())
}
@@ -119,9 +119,7 @@ impl QwenProvider {
.ok_or("No refresh token available")?;
let client_id = std::env::var("QWEN_OAUTH_CLIENT_ID")
.ok()
.or_else(|| Some("f0304373b74a44d2b584a3fb70ca9e56".to_string()))
.unwrap();
.unwrap_or_else(|_| "f0304373b74a44d2b584a3fb70ca9e56".to_string());
let body = serde_json::json!({
"grant_type": "refresh_token",
+202 -104
View File
@@ -22,6 +22,16 @@ use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::{oneshot, RwLock};
/// 安全截断字符串到指定字符数,避免 UTF-8 边界问题
fn safe_truncate(s: &str, max_chars: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max_chars {
s.to_string()
} else {
chars[..max_chars].iter().collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerStatus {
pub running: bool,
@@ -135,6 +145,9 @@ struct AppState {
api_key: String,
kiro: Arc<RwLock<KiroProvider>>,
logs: Arc<RwLock<LogStore>>,
kiro_refresh_lock: Arc<tokio::sync::Mutex<()>>,
gemini_refresh_lock: Arc<tokio::sync::Mutex<()>>,
qwen_refresh_lock: Arc<tokio::sync::Mutex<()>>,
}
async fn run_server(
@@ -149,6 +162,9 @@ async fn run_server(
api_key: api_key.to_string(),
kiro: Arc::new(RwLock::new(kiro)),
logs,
kiro_refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
gemini_refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
qwen_refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
};
let app = Router::new()
@@ -256,6 +272,7 @@ async fn chat_completions(
// 检查是否需要刷新 token
{
let _guard = state.kiro_refresh_lock.lock().await;
let mut kiro = state.kiro.write().await;
if kiro.credentials.access_token.is_none() {
if let Err(e) = kiro.refresh_token().await {
@@ -320,7 +337,7 @@ async fn chat_completions(
"object": "chat.completion",
"created": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs(),
"model": request.model,
"choices": [{
@@ -345,6 +362,7 @@ async fn chat_completions(
} else if status.as_u16() == 403 {
// Token 过期,尝试刷新
drop(kiro);
let _guard = state.kiro_refresh_lock.lock().await;
let mut kiro = state.kiro.write().await;
state
.logs
@@ -392,7 +410,7 @@ async fn chat_completions(
"object": "chat.completion",
"created": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.unwrap_or_default()
.as_secs(),
"model": request.model,
"choices": [{
@@ -435,11 +453,7 @@ async fn chat_completions(
let body = resp.text().await.unwrap_or_default();
state.logs.write().await.add(
"error",
&format!(
"Upstream error {}: {}",
status,
&body[..body.len().min(200)]
),
&format!("Upstream error {}: {}", status, safe_truncate(&body, 200)),
);
(
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
@@ -516,6 +530,7 @@ async fn anthropic_messages(
// 检查是否需要刷新 token
{
let _guard = state.kiro_refresh_lock.lock().await;
let mut kiro = state.kiro.write().await;
if kiro.credentials.access_token.is_none() {
state
@@ -571,12 +586,29 @@ async fn anthropic_messages(
if status.is_success() {
match resp.text().await {
Ok(body) => {
// 记录原始响应长度和预览
// 记录原始响应长度
state.logs.write().await.add(
"debug",
&format!("[RESP] Raw body length: {} bytes", body.len()),
);
// 保存原始响应到文件用于调试
let request_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
state.logs.read().await.log_raw_response(&request_id, &body);
state.logs.write().await.add(
"debug",
&format!("[RESP] Raw response saved to raw_response_{request_id}.txt"),
);
// 记录响应的前500字符用于调试
state.logs.write().await.add(
"debug",
&format!(
"[RESP] Body preview: {}",
&body.chars().take(500).collect::<String>()
),
);
let parsed = parse_cw_response(&body);
// 详细记录解析结果
@@ -633,6 +665,7 @@ async fn anthropic_messages(
} else if status.as_u16() == 403 {
// Token 过期,尝试刷新
drop(kiro);
let _guard = state.kiro_refresh_lock.lock().await;
let mut kiro = state.kiro.write().await;
state.logs.write().await.add(
"warn",
@@ -697,7 +730,7 @@ async fn anthropic_messages(
"error",
&format!(
"[RETRY] Failed with status {retry_status}: {}",
&body[..body.len().min(500)]
safe_truncate(&body, 500)
),
);
(
@@ -740,7 +773,7 @@ async fn anthropic_messages(
&format!(
"[ERROR] Upstream error HTTP {}: {}",
status,
&body[..body.len().min(500)]
safe_truncate(&body, 500)
),
);
(
@@ -847,18 +880,18 @@ fn build_anthropic_stream_response(
let mut block_index = 0;
// 2. 文本内容块
if !content.is_empty() {
// content_block_start
let block_start = serde_json::json!({
"type": "content_block_start",
"index": block_index,
"content_block": {"type": "text", "text": ""}
});
events.push(format!(
"event: content_block_start\ndata: {block_start}\n\n"
));
// 2. 文本内容块 - 即使为空也要发送,Claude Code 需要至少一个 content block
// content_block_start
let block_start = serde_json::json!({
"type": "content_block_start",
"index": block_index,
"content_block": {"type": "text", "text": ""}
});
events.push(format!(
"event: content_block_start\ndata: {block_start}\n\n"
));
if !content.is_empty() {
// content_block_delta - 发送完整内容
let block_delta = serde_json::json!({
"type": "content_block_delta",
@@ -868,17 +901,17 @@ fn build_anthropic_stream_response(
events.push(format!(
"event: content_block_delta\ndata: {block_delta}\n\n"
));
// content_block_stop
let block_stop = serde_json::json!({
"type": "content_block_stop",
"index": block_index
});
events.push(format!("event: content_block_stop\ndata: {block_stop}\n\n"));
block_index += 1;
}
// content_block_stop
let block_stop = serde_json::json!({
"type": "content_block_stop",
"index": block_index
});
events.push(format!("event: content_block_stop\ndata: {block_stop}\n\n"));
block_index += 1;
// 3. Tool use 块
for tc in &tool_calls {
let input: serde_json::Value =
@@ -947,7 +980,13 @@ fn build_anthropic_stream_response(
.header(header::CACHE_CONTROL, "no-cache")
.header(header::CONNECTION, "keep-alive")
.body(body)
.unwrap()
.unwrap_or_else(|e| {
tracing::error!("Failed to build SSE response: {}", e);
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::empty())
.unwrap_or_default()
})
}
async fn count_tokens(
@@ -974,109 +1013,123 @@ struct CWParsedResponse {
}
/// 解析 CodeWhisperer AWS Event Stream 响应
/// AWS Event Stream 是二进制格式,JSON payload 嵌入在二进制头部之间
fn parse_cw_response(body: &str) -> CWParsedResponse {
let mut result = CWParsedResponse::default();
let mut current_tool: Option<(String, String, String)> = None; // (id, name, input)
// 使用 HashMap 来跟踪多个并发的 tool calls
// key: toolUseId, value: (name, input_accumulated)
let mut tool_map: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::new();
// 解析所有 JSON 事件
let patterns = [
r#"\{"content":"#,
r#"\{"name":"#,
r#"\{"input":"#,
r#"\{"stop":"#,
// 将字符串转换为字节,因为 AWS Event Stream 包含二进制数据
let bytes = body.as_bytes();
// 搜索所有 JSON 对象的模式
// AWS Event Stream 格式: [binary headers]{"content":"..."}[binary trailer]
let json_patterns: &[&[u8]] = &[
b"{\"content\":",
b"{\"name\":",
b"{\"input\":",
b"{\"stop\":",
b"{\"followupPrompt\":",
b"{\"toolUseId\":",
];
let mut pos = 0;
while pos < body.len() {
while pos < bytes.len() {
// 找到下一个 JSON 对象的开始
let mut next_start = body.len();
for pattern in &patterns {
if let Some(idx) = body[pos..].find(pattern) {
next_start = next_start.min(pos + idx);
let mut next_start: Option<usize> = None;
for pattern in json_patterns {
if let Some(idx) = find_subsequence(&bytes[pos..], pattern) {
let abs_pos = pos + idx;
if next_start.map_or(true, |start| abs_pos < start) {
next_start = Some(abs_pos);
}
}
}
if next_start >= body.len() {
break;
}
let start = match next_start {
Some(s) => s,
None => break,
};
// 找到匹配的 }
if let Some(json_str) = extract_json_object(&body[next_start..]) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(json_str) {
// 从 start 位置提取完整的 JSON 对象
if let Some(json_str) = extract_json_from_bytes(&bytes[start..]) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&json_str) {
// 处理 content 事件
if let Some(content) = value.get("content").and_then(|v| v.as_str()) {
// 跳过 followupPrompt
if value.get("followupPrompt").is_none() {
let unescaped = content
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\\"", "\"")
.replace("\\\\", "\\");
result.content.push_str(&unescaped);
result.content.push_str(content);
}
}
// 处理 tool use 开始事件
else if let (Some(name), Some(tool_use_id)) = (
value.get("name").and_then(|v| v.as_str()),
value.get("toolUseId").and_then(|v| v.as_str()),
) {
let input = value
// 处理 tool use 事件 (包含 toolUseId)
else if let Some(tool_use_id) = value.get("toolUseId").and_then(|v| v.as_str()) {
let name = value
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let input_chunk = value
.get("input")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
current_tool = Some((tool_use_id.to_string(), name.to_string(), input));
let is_stop = value.get("stop").and_then(|v| v.as_bool()).unwrap_or(false);
// 如果同时有 stop,直接完成
if value.get("stop").and_then(|v| v.as_bool()).unwrap_or(false) {
if let Some((id, name, input)) = current_tool.take() {
result.tool_calls.push(ToolCall {
id,
call_type: "function".to_string(),
function: FunctionCall {
name,
arguments: input,
},
});
// 获取或创建 tool entry
let entry = tool_map
.entry(tool_use_id.to_string())
.or_insert_with(|| (String::new(), String::new()));
// 更新 name(如果有)
if !name.is_empty() {
entry.0 = name;
}
// 累积 input
entry.1.push_str(&input_chunk);
// 如果是 stop 事件,完成这个 tool call
if is_stop {
if let Some((name, input)) = tool_map.remove(tool_use_id) {
if !name.is_empty() {
result.tool_calls.push(ToolCall {
id: tool_use_id.to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name,
arguments: input,
},
});
}
}
}
}
// 处理 input 续传事件
else if let Some(input) = value.get("input").and_then(|v| v.as_str()) {
if let Some((_, _, ref mut current_input)) = current_tool {
current_input.push_str(input);
}
}
// 处理 stop 事件
// 处理独立的 stop 事件(没有 toolUseId)
else if value.get("stop").and_then(|v| v.as_bool()).unwrap_or(false) {
if let Some((id, name, input)) = current_tool.take() {
result.tool_calls.push(ToolCall {
id,
call_type: "function".to_string(),
function: FunctionCall {
name,
arguments: input,
},
});
}
// 这种情况不应该发生,但以防万一
}
}
pos = next_start + json_str.len();
pos = start + json_str.len();
} else {
pos = next_start + 1;
pos = start + 1;
}
}
// 处理未完成的 tool call
if let Some((id, name, input)) = current_tool {
result.tool_calls.push(ToolCall {
id,
call_type: "function".to_string(),
function: FunctionCall {
name,
arguments: input,
},
});
// 处理未完成的 tool calls(没有收到 stop 事件的)
for (id, (name, input)) in tool_map {
if !name.is_empty() {
result.tool_calls.push(ToolCall {
id,
call_type: "function".to_string(),
function: FunctionCall {
name,
arguments: input,
},
});
}
}
// 解析 bracket 格式的 tool calls: [Called xxx with args: {...}]
@@ -1085,7 +1138,50 @@ fn parse_cw_response(body: &str) -> CWParsedResponse {
result
}
/// 从字符串中提取完整的 JSON 对象
/// 在字节数组中查找子序列
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
/// 从字节数组中提取 JSON 对象字符串
fn extract_json_from_bytes(bytes: &[u8]) -> Option<String> {
if bytes.is_empty() || bytes[0] != b'{' {
return None;
}
let mut brace_count = 0;
let mut in_string = false;
let mut escape_next = false;
let mut end_pos = None;
for (i, &b) in bytes.iter().enumerate() {
if escape_next {
escape_next = false;
continue;
}
match b {
b'\\' if in_string => escape_next = true,
b'"' => in_string = !in_string,
b'{' if !in_string => brace_count += 1,
b'}' if !in_string => {
brace_count -= 1;
if brace_count == 0 {
end_pos = Some(i + 1);
break;
}
}
_ => {}
}
}
end_pos.and_then(|end| String::from_utf8(bytes[..end].to_vec()).ok())
}
/// 从字符串中提取完整的 JSON 对象 (保留用于兼容)
#[allow(dead_code)]
fn extract_json_object(s: &str) -> Option<&str> {
if !s.starts_with('{') {
return None;
@@ -1139,7 +1235,9 @@ fn parse_bracket_tool_calls(result: &mut CWParsedResponse) {
arguments: args.as_str().to_string(),
},
});
to_remove.push(cap.get(0).unwrap().as_str().to_string());
if let Some(full_match) = cap.get(0) {
to_remove.push(full_match.as_str().to_string());
}
}
}
// 从 content 中移除 tool call 文本
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.3.0",
"version": "0.4.0",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+3 -1
View File
@@ -285,7 +285,9 @@ export function Settings() {
)}
<span>
{r.model.includes("tool_call") ? (
<span className="font-medium text-purple-600">{r.model}</span>
<span className="font-medium text-purple-600">
{r.model}
</span>
) : (
r.model
)}