516 lines
21 KiB
Rust
516 lines
21 KiB
Rust
|
|
//! Google Vertex AI and Gemini Native API Integration
|
||
|
|
//! Support for both OpenAI-compatible endpoints and native Google AI Studio / Vertex AI endpoints.
|
||
|
|
|
||
|
|
use log::{error, info, trace};
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use std::sync::Arc;
|
||
|
|
use tokio::sync::RwLock;
|
||
|
|
|
||
|
|
const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
|
||
|
|
const GOOGLE_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize)]
|
||
|
|
struct ServiceAccountKey {
|
||
|
|
client_email: String,
|
||
|
|
private_key: String,
|
||
|
|
token_uri: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize)]
|
||
|
|
struct TokenResponse {
|
||
|
|
access_token: String,
|
||
|
|
expires_in: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
struct CachedToken {
|
||
|
|
access_token: String,
|
||
|
|
expires_at: std::time::Instant,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Manages OAuth2 access tokens for Google Vertex AI
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct VertexTokenManager {
|
||
|
|
credentials_path: String,
|
||
|
|
cached_token: Arc<RwLock<Option<CachedToken>>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl VertexTokenManager {
|
||
|
|
pub fn new(credentials_path: &str) -> Self {
|
||
|
|
Self {
|
||
|
|
credentials_path: credentials_path.to_string(),
|
||
|
|
cached_token: Arc::new(RwLock::new(None)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Get a valid access token, refreshing if necessary
|
||
|
|
pub async fn get_access_token(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||
|
|
// Check cached token
|
||
|
|
{
|
||
|
|
let cache = self.cached_token.read().await;
|
||
|
|
if let Some(ref token) = *cache {
|
||
|
|
// Return cached token if it expires in more than 60 seconds
|
||
|
|
if token.expires_at > std::time::Instant::now() + std::time::Duration::from_secs(60) {
|
||
|
|
trace!("Using cached Vertex AI access token");
|
||
|
|
return Ok(token.access_token.clone());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate new token
|
||
|
|
info!("Generating new Vertex AI access token from service account");
|
||
|
|
let token = self.generate_token().await?;
|
||
|
|
|
||
|
|
// Cache it
|
||
|
|
{
|
||
|
|
let mut cache = self.cached_token.write().await;
|
||
|
|
*cache = Some(token.clone());
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(token.access_token)
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn generate_token(&self) -> Result<CachedToken, Box<dyn std::error::Error + Send + Sync>> {
|
||
|
|
let content = if self.credentials_path.trim().starts_with('{') {
|
||
|
|
self.credentials_path.clone()
|
||
|
|
} else {
|
||
|
|
// Expand ~ to home directory
|
||
|
|
let expanded_path = if self.credentials_path.starts_with('~') {
|
||
|
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||
|
|
self.credentials_path.replacen('~', &home, 1)
|
||
|
|
} else {
|
||
|
|
self.credentials_path.clone()
|
||
|
|
};
|
||
|
|
|
||
|
|
tokio::fs::read_to_string(&expanded_path)
|
||
|
|
.await
|
||
|
|
.map_err(|e| format!("Failed to read service account key from '{}': {}", expanded_path, e))?
|
||
|
|
};
|
||
|
|
|
||
|
|
let sa_key: ServiceAccountKey = serde_json::from_str(&content)
|
||
|
|
.map_err(|e| format!("Failed to parse service account JSON: {}", e))?;
|
||
|
|
|
||
|
|
let token_uri = sa_key.token_uri.as_deref().unwrap_or(GOOGLE_TOKEN_URL);
|
||
|
|
|
||
|
|
// Create JWT assertion
|
||
|
|
let now = std::time::SystemTime::now()
|
||
|
|
.duration_since(std::time::UNIX_EPOCH)
|
||
|
|
.map_err(|e| format!("System time error: {}", e))?
|
||
|
|
.as_secs();
|
||
|
|
|
||
|
|
let jwt_claims = JwtClaims {
|
||
|
|
iss: sa_key.client_email.clone(),
|
||
|
|
scope: GOOGLE_SCOPE.to_string(),
|
||
|
|
aud: token_uri.to_string(),
|
||
|
|
iat: now,
|
||
|
|
exp: now + 3600,
|
||
|
|
};
|
||
|
|
|
||
|
|
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
|
||
|
|
let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(sa_key.private_key.as_bytes())
|
||
|
|
.map_err(|e| format!("Failed to parse RSA private key: {}", e))?;
|
||
|
|
|
||
|
|
let jwt = jsonwebtoken::encode(&header, &jwt_claims, &encoding_key)
|
||
|
|
.map_err(|e| format!("Failed to encode JWT: {}", e))?;
|
||
|
|
|
||
|
|
// Exchange JWT for access token
|
||
|
|
let client = reqwest::Client::new();
|
||
|
|
let response = client
|
||
|
|
.post(token_uri)
|
||
|
|
.form(&[
|
||
|
|
("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
|
||
|
|
("assertion", &jwt),
|
||
|
|
])
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.map_err(|e| format!("Failed to request token: {}", e))?;
|
||
|
|
|
||
|
|
if !response.status().is_success() {
|
||
|
|
let status = response.status();
|
||
|
|
let body = response.text().await.unwrap_or_default();
|
||
|
|
error!("Google OAuth2 token request failed: {} - {}", status, body);
|
||
|
|
return Err(format!("Token request failed with status {}: {}", status, body).into());
|
||
|
|
}
|
||
|
|
|
||
|
|
let token_response: TokenResponse = response
|
||
|
|
.json()
|
||
|
|
.await
|
||
|
|
.map_err(|e| format!("Failed to parse token response: {}", e))?;
|
||
|
|
|
||
|
|
info!("Successfully obtained Vertex AI access token (expires in {}s)", token_response.expires_in);
|
||
|
|
|
||
|
|
Ok(CachedToken {
|
||
|
|
access_token: token_response.access_token,
|
||
|
|
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(token_response.expires_in),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Serialize)]
|
||
|
|
struct JwtClaims {
|
||
|
|
iss: String,
|
||
|
|
scope: String,
|
||
|
|
aud: String,
|
||
|
|
iat: u64,
|
||
|
|
exp: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Builds the Vertex AI OpenAI-compatible endpoint URL
|
||
|
|
pub fn build_vertex_url(project: &str, location: &str) -> String {
|
||
|
|
format!(
|
||
|
|
"https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi"
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Check if a URL is a Vertex AI endpoint
|
||
|
|
pub fn is_vertex_ai_url(url: &str) -> bool {
|
||
|
|
url.contains("aiplatform.googleapis.com") || url.contains("generativelanguage.googleapis.com")
|
||
|
|
}
|
||
|
|
|
||
|
|
use crate::llm::LLMProvider;
|
||
|
|
use serde_json::Value;
|
||
|
|
use tokio::sync::mpsc;
|
||
|
|
use async_trait::async_trait;
|
||
|
|
use futures::StreamExt;
|
||
|
|
|
||
|
|
#[derive(Debug)]
|
||
|
|
pub struct VertexClient {
|
||
|
|
client: reqwest::Client,
|
||
|
|
base_url: String,
|
||
|
|
endpoint_path: String,
|
||
|
|
token_manager: Arc<tokio::sync::RwLock<Option<Arc<VertexTokenManager>>>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl VertexClient {
|
||
|
|
pub fn new(base_url: String, endpoint_path: Option<String>) -> Self {
|
||
|
|
let endpoint = endpoint_path.unwrap_or_else(|| {
|
||
|
|
if base_url.contains("generativelanguage.googleapis.com") {
|
||
|
|
// Default to OpenAI compatibility if possible, but generate_stream will auto-detect
|
||
|
|
"/v1beta/openai/chat/completions".to_string()
|
||
|
|
} else if base_url.contains("aiplatform.googleapis.com") && !base_url.contains("openapi") {
|
||
|
|
// If it's a naked aiplatform URL, it might be missing the project/location path
|
||
|
|
// We'll leave it empty and let the caller provide a full URL if needed
|
||
|
|
"".to_string()
|
||
|
|
} else {
|
||
|
|
"".to_string()
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
Self {
|
||
|
|
client: reqwest::Client::new(),
|
||
|
|
base_url,
|
||
|
|
endpoint_path: endpoint,
|
||
|
|
token_manager: Arc::new(tokio::sync::RwLock::new(None)),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn get_auth_header(&self, key: &str) -> (&'static str, String) {
|
||
|
|
// If the key is a path or JSON (starts with {), it's a Service Account
|
||
|
|
if key.starts_with('{') || key.starts_with('~') || key.starts_with('/') || key.ends_with(".json") {
|
||
|
|
let mut manager_opt = self.token_manager.write().await;
|
||
|
|
if manager_opt.is_none() {
|
||
|
|
*manager_opt = Some(Arc::new(VertexTokenManager::new(key)));
|
||
|
|
}
|
||
|
|
if let Some(manager) = manager_opt.as_ref() {
|
||
|
|
match manager.get_access_token().await {
|
||
|
|
Ok(token) => return ("Authorization", format!("Bearer {}", token)),
|
||
|
|
Err(e) => error!("Failed to get Vertex OAuth token: {}", e),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Default to Google API Key (Google AI Studio / Gemini Developer APIs)
|
||
|
|
("x-goog-api-key", key.to_string())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn convert_messages_to_gemini(&self, messages: &Value) -> Value {
|
||
|
|
let mut contents = Vec::new();
|
||
|
|
if let Some(msg_array) = messages.as_array() {
|
||
|
|
for msg in msg_array {
|
||
|
|
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
|
||
|
|
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
|
||
|
|
|
||
|
|
let gemini_role = match role {
|
||
|
|
"assistant" => "model",
|
||
|
|
"system" => "user", // Gemini doesn't have system role in 'contents' by default, often wrapped in systemInstruction
|
||
|
|
_ => "user",
|
||
|
|
};
|
||
|
|
|
||
|
|
contents.push(serde_json::json!({
|
||
|
|
"role": gemini_role,
|
||
|
|
"parts": [{"text": content}]
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
serde_json::json!(contents)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[async_trait]
|
||
|
|
impl LLMProvider for VertexClient {
|
||
|
|
async fn generate(
|
||
|
|
&self,
|
||
|
|
prompt: &str,
|
||
|
|
messages: &Value,
|
||
|
|
model: &str,
|
||
|
|
key: &str,
|
||
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||
|
|
let default_messages = serde_json::json!([{"role": "user", "content": prompt}]);
|
||
|
|
let raw_messages = if messages.is_array() && !messages.as_array().unwrap_or(&vec![]).is_empty() {
|
||
|
|
messages
|
||
|
|
} else {
|
||
|
|
&default_messages
|
||
|
|
};
|
||
|
|
|
||
|
|
let (header_name, auth_value) = self.get_auth_header(key).await;
|
||
|
|
|
||
|
|
// Auto-detect if we should use native Gemini API
|
||
|
|
let is_native = self.endpoint_path.contains(":generateContent") ||
|
||
|
|
self.base_url.contains("generativelanguage") && !self.endpoint_path.contains("openai");
|
||
|
|
|
||
|
|
let full_url = if is_native && !self.endpoint_path.contains(":generateContent") {
|
||
|
|
// Build native URL for generativelanguage
|
||
|
|
format!("{}/v1beta/models/{}:generateContent", self.base_url.trim_end_matches('/'), model)
|
||
|
|
} else {
|
||
|
|
format!("{}{}", self.base_url, self.endpoint_path)
|
||
|
|
}.trim_end_matches('/').to_string();
|
||
|
|
|
||
|
|
let request_body = if is_native {
|
||
|
|
serde_json::json!({
|
||
|
|
"contents": self.convert_messages_to_gemini(raw_messages),
|
||
|
|
"generationConfig": {
|
||
|
|
"temperature": 0.7,
|
||
|
|
}
|
||
|
|
})
|
||
|
|
} else {
|
||
|
|
serde_json::json!({
|
||
|
|
"model": model,
|
||
|
|
"messages": raw_messages,
|
||
|
|
"stream": false
|
||
|
|
})
|
||
|
|
};
|
||
|
|
|
||
|
|
info!("Sending request to Vertex/Gemini endpoint: {}", full_url);
|
||
|
|
|
||
|
|
let response = self.client
|
||
|
|
.post(full_url)
|
||
|
|
.header(header_name, &auth_value)
|
||
|
|
.header("Content-Type", "application/json")
|
||
|
|
.json(&request_body)
|
||
|
|
.send()
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
let status = response.status();
|
||
|
|
if !status.is_success() {
|
||
|
|
let error_text = response.text().await.unwrap_or_default();
|
||
|
|
error!("Vertex/Gemini generate error: {}", error_text);
|
||
|
|
return Err(format!("Google API error ({}): {}", status, error_text).into());
|
||
|
|
}
|
||
|
|
|
||
|
|
let json: Value = response.json().await?;
|
||
|
|
|
||
|
|
// Try parsing OpenAI format
|
||
|
|
if let Some(choices) = json.get("choices") {
|
||
|
|
if let Some(first_choice) = choices.get(0) {
|
||
|
|
if let Some(message) = first_choice.get("message") {
|
||
|
|
if let Some(content) = message.get("content") {
|
||
|
|
if let Some(content_str) = content.as_str() {
|
||
|
|
return Ok(content_str.to_string());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try parsing Native Gemini format
|
||
|
|
if let Some(candidates) = json.get("candidates") {
|
||
|
|
if let Some(first_candidate) = candidates.get(0) {
|
||
|
|
if let Some(content) = first_candidate.get("content") {
|
||
|
|
if let Some(parts) = content.get("parts") {
|
||
|
|
if let Some(first_part) = parts.get(0) {
|
||
|
|
if let Some(text) = first_part.get("text") {
|
||
|
|
if let Some(text_str) = text.as_str() {
|
||
|
|
return Ok(text_str.to_string());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Err("Failed to parse response from Vertex/Gemini (unknown format)".into())
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn generate_stream(
|
||
|
|
&self,
|
||
|
|
prompt: &str,
|
||
|
|
messages: &Value,
|
||
|
|
tx: mpsc::Sender<String>,
|
||
|
|
model: &str,
|
||
|
|
key: &str,
|
||
|
|
tools: Option<&Vec<Value>>,
|
||
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||
|
|
let default_messages = serde_json::json!([{"role": "user", "content": prompt}]);
|
||
|
|
let raw_messages = if messages.is_array() && !messages.as_array().unwrap_or(&vec![]).is_empty() {
|
||
|
|
messages
|
||
|
|
} else {
|
||
|
|
&default_messages
|
||
|
|
};
|
||
|
|
|
||
|
|
let (header_name, auth_value) = self.get_auth_header(key).await;
|
||
|
|
|
||
|
|
// Auto-detect if we should use native Gemini API
|
||
|
|
let is_native = self.endpoint_path.contains("GenerateContent") ||
|
||
|
|
(self.base_url.contains("googleapis.com") &&
|
||
|
|
!self.endpoint_path.contains("openai") &&
|
||
|
|
!self.endpoint_path.contains("completions"));
|
||
|
|
|
||
|
|
let full_url = if is_native && !self.endpoint_path.contains("GenerateContent") {
|
||
|
|
// Build native URL for Gemini if it looks like we need it
|
||
|
|
if self.base_url.contains("aiplatform") {
|
||
|
|
// Global Vertex endpoint format
|
||
|
|
format!("{}/v1/publishers/google/models/{}:streamGenerateContent", self.base_url.trim_end_matches('/'), model)
|
||
|
|
} else {
|
||
|
|
// Google AI Studio format
|
||
|
|
format!("{}/v1beta/models/{}:streamGenerateContent", self.base_url.trim_end_matches('/'), model)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
format!("{}{}", self.base_url, self.endpoint_path)
|
||
|
|
}.trim_end_matches('/').to_string();
|
||
|
|
|
||
|
|
let mut request_body = if is_native {
|
||
|
|
let mut body = serde_json::json!({
|
||
|
|
"contents": self.convert_messages_to_gemini(raw_messages),
|
||
|
|
"generationConfig": {
|
||
|
|
"temperature": 0.7,
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// Handle thinking models if requested
|
||
|
|
if model.contains("preview") || model.contains("3.1-flash") {
|
||
|
|
body["generationConfig"]["thinkingConfig"] = serde_json::json!({
|
||
|
|
"thinkingLevel": "LOW"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
body
|
||
|
|
} else {
|
||
|
|
serde_json::json!({
|
||
|
|
"model": model,
|
||
|
|
"messages": raw_messages,
|
||
|
|
"stream": true
|
||
|
|
})
|
||
|
|
};
|
||
|
|
|
||
|
|
if let Some(tools_value) = tools {
|
||
|
|
if !tools_value.is_empty() {
|
||
|
|
request_body["tools"] = serde_json::json!(tools_value);
|
||
|
|
info!("Added {} tools to Vertex request", tools_value.len());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
info!("Sending streaming request to Vertex/Gemini endpoint: {}", full_url);
|
||
|
|
|
||
|
|
let response = self.client
|
||
|
|
.post(full_url)
|
||
|
|
.header(header_name, &auth_value)
|
||
|
|
.header("Content-Type", "application/json")
|
||
|
|
.json(&request_body)
|
||
|
|
.send()
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
let status = response.status();
|
||
|
|
if !status.is_success() {
|
||
|
|
let error_text = response.text().await.unwrap_or_default();
|
||
|
|
error!("Vertex/Gemini generate_stream error: {}", error_text);
|
||
|
|
return Err(format!("Google API error ({}): {}", status, error_text).into());
|
||
|
|
}
|
||
|
|
|
||
|
|
let mut stream = response.bytes_stream();
|
||
|
|
let mut tool_call_buffer = String::new();
|
||
|
|
|
||
|
|
while let Some(chunk_result) = stream.next().await {
|
||
|
|
match chunk_result {
|
||
|
|
Ok(chunk) => {
|
||
|
|
if let Ok(text) = std::str::from_utf8(&chunk) {
|
||
|
|
for line in text.split('\n') {
|
||
|
|
let line = line.trim();
|
||
|
|
if line.is_empty() { continue; }
|
||
|
|
|
||
|
|
if let Some(data) = line.strip_prefix("data: ") {
|
||
|
|
// --- OpenAI SSE Format ---
|
||
|
|
if data == "[DONE]" { continue; }
|
||
|
|
if let Ok(json) = serde_json::from_str::<Value>(data) {
|
||
|
|
if let Some(choices) = json.get("choices") {
|
||
|
|
if let Some(first_choice) = choices.get(0) {
|
||
|
|
if let Some(delta) = first_choice.get("delta") {
|
||
|
|
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||
|
|
if !content.is_empty() {
|
||
|
|
let _ = tx.send(content.to_string()).await;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle tool calls...
|
||
|
|
if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) {
|
||
|
|
if let Some(first_call) = tool_calls.first() {
|
||
|
|
if let Some(function) = first_call.get("function") {
|
||
|
|
if let Some(name) = function.get("name").and_then(|n| n.as_str()) {
|
||
|
|
tool_call_buffer = format!("{{\"name\": \"{}\", \"arguments\": \"", name);
|
||
|
|
}
|
||
|
|
if let Some(args) = function.get("arguments").and_then(|a| a.as_str()) {
|
||
|
|
tool_call_buffer.push_str(args);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// --- Native Gemini JSON format ---
|
||
|
|
// It usually arrives as raw JSON objects, sometimes with leading commas or brackets in a stream array
|
||
|
|
let trimmed = line.trim_start_matches(|c| c == ',' || c == '[' || c == ']').trim_end_matches(']');
|
||
|
|
if trimmed.is_empty() { continue; }
|
||
|
|
|
||
|
|
if let Ok(json) = serde_json::from_str::<Value>(trimmed) {
|
||
|
|
if let Some(candidates) = json.get("candidates").and_then(|c| c.as_array()) {
|
||
|
|
if let Some(candidate) = candidates.first() {
|
||
|
|
if let Some(content) = candidate.get("content") {
|
||
|
|
if let Some(parts) = content.get("parts").and_then(|p| p.as_array()) {
|
||
|
|
for part in parts {
|
||
|
|
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
|
||
|
|
if !text.is_empty() {
|
||
|
|
let _ = tx.send(text.to_string()).await;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Err(e) => {
|
||
|
|
error!("Vertex stream reading error: {}", e);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if !tool_call_buffer.is_empty() {
|
||
|
|
tool_call_buffer.push_str("\"}");
|
||
|
|
let _ = tx.send(format!("`tool_call`: {}", tool_call_buffer)).await;
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn cancel_job(&self, _session_id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
}
|