use crate::Session;
use serde::{Deserialize, Serialize};
fn url_encode_path(path: &str) -> String {
let mut result = String::with_capacity(path.len());
for byte in path.bytes() {
match byte {
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'~'
| b'/'
| b':'
| b'\\' => {
result.push(byte as char);
}
b' ' => result.push_str("%20"),
_ => {
result.push_str(&format!("%{:02X}", byte));
}
}
}
result
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ThinkingMode {
Disabled,
Low,
Medium,
High,
XHigh,
Max,
}
impl ThinkingMode {
pub fn as_str(&self) -> &'static str {
match self {
ThinkingMode::Disabled => "disabled",
ThinkingMode::Low => "low",
ThinkingMode::Medium => "medium",
ThinkingMode::High => "high",
ThinkingMode::XHigh => "xhigh",
ThinkingMode::Max => "max",
}
}
}
impl std::fmt::Display for ThinkingMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum MessageRole {
System,
User,
Assistant,
}
impl MessageRole {
pub fn as_str(&self) -> &'static str {
match self {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
}
}
}
impl std::fmt::Display for MessageRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: MessageRole,
pub content: String,
}
impl ChatMessage {
pub fn new(role: MessageRole, content: impl Into<String>) -> Self {
Self {
role,
content: content.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum LlmProvider {
OpenAI,
Anthropic,
Ollama,
OpenCode,
Codex,
}
#[derive(Clone, Debug)]
pub struct OpenCodeSession {
pub session_id: Option<String>,
pub parent_id: Option<String>,
}
impl OpenCodeSession {
pub fn new() -> Self {
Self {
session_id: None,
parent_id: None,
}
}
}
pub struct CodexSession {
pub ws: Option<crate::Websocket>,
pub thread_id: Option<String>,
pub request_id: i64,
}
impl CodexSession {
pub fn new() -> Self {
Self {
ws: None,
thread_id: None,
request_id: 0,
}
}
pub fn next_request_id(&mut self) -> i64 {
self.request_id += 1;
self.request_id
}
}
pub enum ProviderSession {
OpenCode(OpenCodeSession),
Codex(CodexSession),
Other,
}
impl ProviderSession {
pub fn from_provider(provider: &LlmProvider) -> Self {
match provider {
LlmProvider::OpenCode => ProviderSession::OpenCode(OpenCodeSession::new()),
LlmProvider::Codex => ProviderSession::Codex(CodexSession::new()),
_ => ProviderSession::Other,
}
}
pub fn as_opencode_mut(&mut self) -> Option<&mut OpenCodeSession> {
match self {
ProviderSession::OpenCode(ref mut s) => Some(s),
_ => None,
}
}
pub fn as_codex_mut(&mut self) -> Option<&mut CodexSession> {
match self {
ProviderSession::Codex(ref mut s) => Some(s),
_ => None,
}
}
pub fn as_opencode(&self) -> Option<&OpenCodeSession> {
match self {
ProviderSession::OpenCode(ref s) => Some(s),
_ => None,
}
}
pub fn as_codex(&self) -> Option<&CodexSession> {
match self {
ProviderSession::Codex(ref s) => Some(s),
_ => None,
}
}
}
#[derive(Clone, Debug)]
pub enum StreamChunk {
Content(String),
Done,
}
#[derive(Clone, Debug)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub provider_id: String,
}
pub struct AgentClientSession {
provider: LlmProvider,
base_url: String,
api_key: Option<String>,
model: Option<String>,
session: Session,
messages: Vec<ChatMessage>,
thinking_mode: ThinkingMode,
working_directory: Option<String>,
provider_session: ProviderSession,
}
impl AgentClientSession {
pub fn new(
provider: LlmProvider,
base_url: impl Into<String>,
api_key: Option<String>,
) -> Self {
let provider_session = ProviderSession::from_provider(&provider);
Self {
provider,
base_url: base_url.into(),
api_key,
model: None,
session: Session::new(),
messages: Vec::new(),
thinking_mode: ThinkingMode::Medium,
working_directory: None,
provider_session,
}
}
pub fn set_system_prompt(&mut self, prompt: impl Into<String>) {
self.messages
.push(ChatMessage::new(MessageRole::System, prompt));
}
pub async fn set_model(&mut self, model: impl Into<String>) -> anyhow::Result<()> {
let model = model.into();
if self.provider != LlmProvider::Anthropic {
let available_models = self.list_models().await?;
let exists = available_models.iter().any(|m| m.id == model);
if !exists {
return Err(anyhow::anyhow!(
"Model '{model}' is invalid. Use list_models() to get valid models"
));
}
}
self.model = Some(model);
Ok(())
}
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
pub fn set_thinking_mode(&mut self, effort: ThinkingMode) {
self.thinking_mode = effort;
}
pub fn thinking_mode(&self) -> &ThinkingMode {
&self.thinking_mode
}
pub fn set_working_directory(&mut self, path: Option<String>) {
self.working_directory = path;
}
pub fn working_directory(&self) -> Option<&str> {
self.working_directory.as_deref()
}
pub async fn list_reasoning_efforts(&mut self) -> anyhow::Result<Vec<String>> {
let model = self.ensure_model()?.to_string();
match self.provider {
LlmProvider::OpenAI => self.list_reasoning_efforts_openai(&model).await,
_ => Ok(vec![]),
}
}
async fn list_reasoning_efforts_openai(&mut self, model: &str) -> anyhow::Result<Vec<String>> {
let url = format!("{}/v1/models/{}", self.base_url, model);
let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())];
if let Some(ref key) = self.api_key {
headers.push(("Authorization".to_string(), format!("Bearer {key}")));
}
let mut args = Vec::new();
for (k, v) in headers {
args.push(crate::Headers::Custom((k, v)));
}
let mut res = self.session.get(&url, args).await?;
let body_data = res.body.data().await;
let response_text = String::from_utf8_lossy(body_data).to_string();
if res.http_code != 200 {
return Err(anyhow::anyhow!(
"Failed to get model info: HTTP {}",
res.http_code
));
}
let json: serde_json::Value = serde_json::from_str(&response_text)?;
let mut efforts = Vec::new();
if let Some(capabilities) = json.get("capabilities") {
if let Some(reasoning) = capabilities.get("reasoning") {
if let Some(effort_levels) =
reasoning.get("effort_levels").and_then(|v| v.as_array())
{
for level in effort_levels {
if let Some(s) = level.as_str() {
efforts.push(s.to_string());
}
}
}
}
}
Ok(efforts)
}
pub async fn list_models(&mut self) -> anyhow::Result<Vec<ModelInfo>> {
match self.provider {
LlmProvider::OpenAI => {
Self::list_models_openai(&self.base_url, &self.api_key, &mut self.session).await
}
LlmProvider::Anthropic => Ok(vec![]), LlmProvider::Ollama => {
Self::list_models_ollama(&self.base_url, &mut self.session).await
}
LlmProvider::OpenCode => {
Self::list_models_opencode(&self.base_url, &mut self.session).await
}
LlmProvider::Codex => self.list_models_codex().await,
}
}
async fn list_models_opencode(
base_url: &str,
session: &mut Session,
) -> anyhow::Result<Vec<ModelInfo>> {
let url = format!("{}/config/providers", base_url);
let mut res = session.get(&url, vec![]).await?;
let body_data = res.body.data().await;
let response_text = String::from_utf8_lossy(body_data).to_string();
if res.http_code != 200 {
return Err(anyhow::anyhow!(
"Failed to list OpenCode models: HTTP {}",
res.http_code
));
}
let json: serde_json::Value = serde_json::from_str(&response_text)?;
let mut models = Vec::new();
if let Some(providers) = json["providers"].as_array() {
for provider in providers {
let provider_id = provider["id"].as_str().unwrap_or("unknown").to_string();
if let Some(provider_models) = provider["models"].as_object() {
for (model_id, model_info) in provider_models {
let name = model_info["name"].as_str().unwrap_or(model_id).to_string();
models.push(ModelInfo {
id: format!("{}:{}", provider_id, model_id),
name,
provider_id: provider_id.clone(),
});
}
}
}
}
Ok(models)
}
async fn list_models_openai(
base_url: &str,
api_key: &Option<String>,
session: &mut Session,
) -> anyhow::Result<Vec<ModelInfo>> {
let url = format!("{}/v1/models", base_url);
let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())];
if let Some(ref key) = api_key {
headers.push(("Authorization".to_string(), format!("Bearer {key}")));
}
let mut args = Vec::new();
for (k, v) in headers {
args.push(crate::Headers::Custom((k, v)));
}
let mut res = session.get(&url, args).await?;
let body_data = res.body.data().await;
let response_text = String::from_utf8_lossy(body_data).to_string();
if res.http_code != 200 {
return Err(anyhow::anyhow!(
"Failed to list OpenAI models: HTTP {}",
res.http_code
));
}
let json: serde_json::Value = serde_json::from_str(&response_text)?;
let mut models = Vec::new();
if let Some(data) = json["data"].as_array() {
for item in data {
let id = item["id"].as_str().unwrap_or("").to_string();
if !id.is_empty() {
models.push(ModelInfo {
id: id.clone(),
name: id,
provider_id: "openai".to_string(),
});
}
}
}
Ok(models)
}
async fn list_models_ollama(
base_url: &str,
session: &mut Session,
) -> anyhow::Result<Vec<ModelInfo>> {
let url = format!("{}/api/tags", base_url);
let mut res = session.get(&url, vec![]).await?;
let body_data = res.body.data().await;
let response_text = String::from_utf8_lossy(body_data).to_string();
if res.http_code != 200 {
return Err(anyhow::anyhow!(
"Failed to list Ollama models: HTTP {}",
res.http_code
));
}
let json: serde_json::Value = serde_json::from_str(&response_text)?;
let mut models = Vec::new();
if let Some(data) = json["models"].as_array() {
for item in data {
let id = item["name"].as_str().unwrap_or("").to_string();
if !id.is_empty() {
models.push(ModelInfo {
id: id.clone(),
name: id,
provider_id: "ollama".to_string(),
});
}
}
}
Ok(models)
}
fn ensure_model(&self) -> anyhow::Result<&str> {
self.model
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Model not set. Call set_model() first."))
}
pub fn messages(&self) -> &[ChatMessage] {
&self.messages
}
pub fn clear_messages(&mut self) {
let system_msgs: Vec<ChatMessage> = self
.messages
.drain(..)
.filter(|m| m.role == MessageRole::System)
.collect();
self.messages = system_msgs;
}
pub async fn chat(&mut self, message: impl Into<String>) -> anyhow::Result<String> {
let user_msg = ChatMessage::new(MessageRole::User, message);
self.messages.push(user_msg);
if self.provider == LlmProvider::OpenCode {
return self.chat_opencode(false).await;
}
if self.provider == LlmProvider::Codex {
return self.chat_codex().await;
}
let (url, body, headers) = self.build_request(false)?;
let mut args = Vec::new();
for (k, v) in headers {
args.push(crate::Headers::Custom((k, v)));
}
let mut res = self.session.post_json(&url, body, args).await?;
let body_data = res.body.data().await;
let response_text = String::from_utf8_lossy(body_data).to_string();
if res.http_code != 200 {
return Err(anyhow::anyhow!(
"HTTP error {}: {}",
res.http_code,
response_text
));
}
let content = self.parse_response(&response_text)?;
self.messages
.push(ChatMessage::new(MessageRole::Assistant, content.clone()));
Ok(content)
}
pub fn session_mut(&mut self) -> &mut Session {
&mut self.session
}
async fn chat_opencode(&mut self, _stream: bool) -> anyhow::Result<String> {
{
let opencode = self
.provider_session
.as_opencode_mut()
.ok_or_else(|| anyhow::anyhow!("Expected OpenCode provider session"))?;
if opencode.session_id.is_none() {
let mut create_url = format!("{}/session", self.base_url);
if let Some(ref dir) = self.working_directory {
create_url = format!("{}?directory={}", create_url, url_encode_path(dir));
}
let create_body = serde_json::json!({"title": "potato-agent-session"});
let mut create_res = self
.session
.post_json(&create_url, create_body, vec![])
.await?;
let create_data = create_res.body.data().await;
let create_text = String::from_utf8_lossy(create_data).to_string();
if create_res.http_code != 200 {
return Err(anyhow::anyhow!(
"OpenCode create session failed {}: {}",
create_res.http_code,
create_text
));
}
let create_json: serde_json::Value = serde_json::from_str(&create_text)?;
opencode.session_id = Some(
create_json["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("OpenCode session response missing id"))?
.to_string(),
);
}
}
let session_id = {
let opencode = self.provider_session.as_opencode().unwrap();
opencode.session_id.as_ref().unwrap().clone()
};
let url = format!("{}/session/{}/message", self.base_url, session_id);
let last_msg_content = {
let last_msg = self
.messages
.last()
.ok_or_else(|| anyhow::anyhow!("No message to send"))?;
last_msg.content.clone()
};
let parts = serde_json::json!([{"type": "text", "text": last_msg_content}]);
let (provider_id, model_id) = self.parse_opencode_model()?;
let parent_id = {
let opencode = self.provider_session.as_opencode().unwrap();
opencode.parent_id.clone()
};
let mut body = serde_json::json!({
"parts": parts,
"model": {
"providerID": provider_id,
"modelID": model_id,
},
});
if let Some(ref parent_id) = parent_id {
body["parentID"] = serde_json::Value::String(parent_id.clone());
}
let mut response_text = String::new();
for attempt in 0..3 {
let mut res = self.session.post_json(&url, body.clone(), vec![]).await?;
let body_data = res.body.data().await;
response_text = String::from_utf8_lossy(body_data).to_string();
if res.http_code != 200 {
return Err(anyhow::anyhow!(
"OpenCode message failed {}: {}",
res.http_code,
response_text
));
}
if !response_text.trim().is_empty() {
break;
}
if attempt < 2 {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
self.session.force_reconnect();
}
}
let content = self.parse_opencode_response(&response_text)?;
{
let opencode = self
.provider_session
.as_opencode_mut()
.ok_or_else(|| anyhow::anyhow!("Expected OpenCode provider session"))?;
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&response_text) {
if let Some(parent_id) = json["info"]["parentID"].as_str() {
opencode.parent_id = Some(parent_id.to_string());
}
}
}
self.messages
.push(ChatMessage::new(MessageRole::Assistant, content.clone()));
Ok(content)
}
fn parse_opencode_model(&self) -> anyhow::Result<(String, String)> {
let model = self.ensure_model()?;
if let Some(pos) = model.find(':') {
let provider_id = model[..pos].to_string();
let model_id = model[pos + 1..].to_string();
Ok((provider_id, model_id))
} else {
Ok(("opencode".to_string(), model.to_string()))
}
}
fn parse_opencode_response(&self, text: &str) -> anyhow::Result<String> {
if text.trim().is_empty() {
return Err(anyhow::anyhow!("OpenCode response is empty"));
}
let json: serde_json::Value = serde_json::from_str(text)?;
let parts = json["parts"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("OpenCode response missing parts"))?;
let mut result = String::new();
for part in parts {
if let Some(text) = part["text"].as_str() {
result.push_str(text);
}
}
Ok(result)
}
fn codex_session_mut(&mut self) -> anyhow::Result<&mut CodexSession> {
self.provider_session
.as_codex_mut()
.ok_or_else(|| anyhow::anyhow!("Expected Codex provider session"))
}
fn next_codex_request_id(&mut self) -> i64 {
self.codex_session_mut().unwrap().next_request_id()
}
async fn ensure_codex_connected(&mut self) -> anyhow::Result<()> {
let codex = self.codex_session_mut()?;
if codex.ws.is_some() {
return Ok(());
}
let ws_url = self
.base_url
.replacen("http://", "ws://", 1)
.replacen("https://", "wss://", 1);
let mut ws = crate::Websocket::connect(&ws_url, vec![]).await?;
let init_id = self.next_codex_request_id();
let init_req = serde_json::json!({
"method": "initialize",
"id": init_id,
"params": {
"clientInfo": {
"name": "potato_agent",
"title": "Potato Agent Client",
"version": "0.1.0"
},
"capabilities": {
"experimentalApi": true
}
}
});
ws.send_text(&init_req.to_string()).await?;
let init_res = Self::recv_codex_jsonrpc_response(&mut ws).await?;
if init_res.get("error").is_some() {
return Err(anyhow::anyhow!(
"Codex initialize failed: {}",
init_res["error"]
));
}
let init_notify = serde_json::json!({
"method": "initialized",
"params": {}
});
ws.send_text(&init_notify.to_string()).await?;
let codex = self.codex_session_mut()?;
codex.ws = Some(ws);
if let Some(thread_id) = codex.thread_id.clone() {
let req_id = self.next_codex_request_id();
{
let codex = self.codex_session_mut()?;
let ws = codex.ws.as_mut().unwrap();
let req = serde_json::json!({
"method": "thread/resume",
"id": req_id,
"params": {
"threadId": thread_id
}
});
if let Err(e) = ws.send_text(&req.to_string()).await {
eprintln!("WARN: Failed to send thread/resume: {}", e);
codex.thread_id = None;
}
}
let codex = self.codex_session_mut()?;
if codex.thread_id.is_some() {
let res = {
let codex = self.codex_session_mut()?;
let ws = codex.ws.as_mut().unwrap();
match Self::recv_codex_jsonrpc_response(ws).await {
Ok(res) => res,
Err(e) => {
eprintln!("WARN: Failed to receive thread/resume response: {}", e);
codex.thread_id = None;
return Ok(());
}
}
};
let codex = self.codex_session_mut()?;
if res.get("error").is_some() {
eprintln!(
"WARN: Codex thread/resume failed: {}, will create new thread",
res["error"]
);
codex.thread_id = None;
}
}
}
Ok(())
}
async fn recv_codex_jsonrpc_response(
ws: &mut crate::Websocket,
) -> anyhow::Result<serde_json::Value> {
loop {
match ws.recv().await? {
crate::WsFrame::Text(text) => {
let val: serde_json::Value = serde_json::from_str(&text)?;
if val.get("id").is_some() {
return Ok(val);
}
}
crate::WsFrame::Binary(_) => {}
}
}
}
async fn recv_codex_notification(
ws: &mut crate::Websocket,
) -> anyhow::Result<serde_json::Value> {
loop {
match ws.recv().await? {
crate::WsFrame::Text(text) => {
let val: serde_json::Value = serde_json::from_str(&text)?;
if val.get("method").is_some() {
return Ok(val);
}
}
crate::WsFrame::Binary(_) => {}
}
}
}
async fn list_models_codex(&mut self) -> anyhow::Result<Vec<ModelInfo>> {
self.ensure_codex_connected().await?;
let req_id = self.next_codex_request_id();
{
let codex = self.codex_session_mut()?;
let ws = codex.ws.as_mut().unwrap();
let req = serde_json::json!({
"method": "model/list",
"id": req_id,
"params": {}
});
ws.send_text(&req.to_string()).await?;
}
let res = {
let codex = self.codex_session_mut()?;
let ws = codex.ws.as_mut().unwrap();
Self::recv_codex_jsonrpc_response(ws).await?
};
if let Some(error) = res.get("error") {
return Err(anyhow::anyhow!("Codex model/list failed: {}", error));
}
let mut models = Vec::new();
if let Some(data) = res["result"]["data"].as_array() {
for item in data {
let id = item["id"].as_str().unwrap_or("").to_string();
let display_name = item["displayName"].as_str().unwrap_or(&id).to_string();
if !id.is_empty() {
models.push(ModelInfo {
id: id.clone(),
name: display_name,
provider_id: "codex".to_string(),
});
}
}
}
Ok(models)
}
async fn chat_codex(&mut self) -> anyhow::Result<String> {
self.ensure_codex_connected().await?;
let codex = self.codex_session_mut()?;
if codex.thread_id.is_none() {
let (model, model_provider) = self.parse_codex_model();
let req_id = self.next_codex_request_id();
let mut thread_params = serde_json::json!({
"ephemeral": true
});
if let Some(model) = model {
thread_params["model"] = serde_json::Value::String(model);
}
if let Some(model_provider) = model_provider {
thread_params["modelProvider"] = serde_json::Value::String(model_provider);
}
if let Some(ref cwd) = self.working_directory {
thread_params["cwd"] = serde_json::Value::String(cwd.clone());
}
{
let codex = self.codex_session_mut()?;
let ws = codex.ws.as_mut().unwrap();
let req = serde_json::json!({
"method": "thread/start",
"id": req_id,
"params": thread_params
});
ws.send_text(&req.to_string()).await?;
}
let res = {
let codex = self.codex_session_mut()?;
let ws = codex.ws.as_mut().unwrap();
Self::recv_codex_jsonrpc_response(ws).await?
};
if let Some(error) = res.get("error") {
return Err(anyhow::anyhow!("Codex thread/start failed: {}", error));
}
let codex = self.codex_session_mut()?;
codex.thread_id = Some(
res["result"]["thread"]["id"]
.as_str()
.ok_or_else(|| {
anyhow::anyhow!("Codex thread/start response missing thread.id")
})?
.to_string(),
);
}
let thread_id = {
let codex = self.codex_session_mut()?;
codex.thread_id.as_ref().unwrap().clone()
};
let input_text = if self.messages.len() > 1 {
let mut context = String::new();
for msg in &self.messages[..self.messages.len() - 1] {
match msg.role {
MessageRole::System => {
context.push_str(&format!("[系统提示]\n{}\n\n", msg.content));
}
MessageRole::User => {
context.push_str(&format!("[用户]\n{}\n\n", msg.content));
}
MessageRole::Assistant => {
context.push_str(&format!("[助手]\n{}\n\n", msg.content));
}
}
}
let last_msg = self.messages.last().unwrap();
context.push_str(&format!("[用户]\n{}\n", last_msg.content));
context
} else {
self.messages.last().unwrap().content.clone()
};
let req_id = self.next_codex_request_id();
let cwd = self.working_directory.clone();
{
let codex = self.codex_session_mut()?;
let ws = codex.ws.as_mut().unwrap();
let mut turn_params = serde_json::json!({
"threadId": thread_id,
"input": [
{
"type": "text",
"text": input_text
}
]
});
if let Some(ref cwd) = cwd {
turn_params["cwd"] = serde_json::Value::String(cwd.clone());
}
let req = serde_json::json!({
"method": "turn/start",
"id": req_id,
"params": turn_params
});
ws.send_text(&req.to_string()).await?;
}
let turn_res = {
let codex = self.codex_session_mut()?;
let ws = codex.ws.as_mut().unwrap();
Self::recv_codex_jsonrpc_response(ws).await?
};
if let Some(error) = turn_res.get("error") {
return Err(anyhow::anyhow!("Codex turn/start failed: {}", error));
}
let mut agent_text = String::new();
let mut turn_completed = false;
while !turn_completed {
let notification = {
let codex = self.codex_session_mut()?;
let ws = codex.ws.as_mut().unwrap();
Self::recv_codex_notification(ws).await?
};
let method = notification["method"].as_str().unwrap_or("");
match method {
"item/agentMessage/delta" => {
if let Some(delta) = notification["params"]["delta"].as_str() {
agent_text.push_str(delta);
}
}
"turn/completed" => {
turn_completed = true;
}
"item/completed" => {
}
"turn/started" | "item/started" | "thread/started" => {
}
"error" => {
return Err(anyhow::anyhow!(
"Codex error notification: {}",
notification["params"]
));
}
_ => {
}
}
}
self.messages
.push(ChatMessage::new(MessageRole::Assistant, agent_text.clone()));
Ok(agent_text)
}
fn parse_codex_model(&self) -> (Option<String>, Option<String>) {
let model = match self.model.as_deref() {
Some(m) => m,
None => return (None, None),
};
if let Some(pos) = model.find(':') {
let provider_id = model[..pos].to_string();
let model_id = model[pos + 1..].to_string();
(Some(model_id), Some(provider_id))
} else {
(Some(model.to_string()), None)
}
}
pub async fn chat_stream(
&mut self,
message: impl Into<String>,
) -> anyhow::Result<tokio::sync::mpsc::Receiver<StreamChunk>> {
let user_msg = ChatMessage::new(MessageRole::User, message);
self.messages.push(user_msg);
if self.provider == LlmProvider::OpenCode {
let content = self.chat_opencode(false).await?;
let (tx, rx) = tokio::sync::mpsc::channel::<StreamChunk>(64);
tokio::spawn(async move {
for line in content.lines() {
if tx
.send(StreamChunk::Content(line.to_string()))
.await
.is_err()
{
return;
}
}
let _ = tx.send(StreamChunk::Done).await;
});
return Ok(rx);
}
let (url, body, headers) = self.build_request(true)?;
let mut args = Vec::new();
for (k, v) in headers {
args.push(crate::Headers::Custom((k, v)));
}
let mut res = self.session.post_json(&url, body, args).await?;
if res.http_code != 200 {
let body_data = res.body.data().await;
return Err(anyhow::anyhow!(
"HTTP error {}: {}",
res.http_code,
String::from_utf8_lossy(body_data)
));
}
let (tx, rx) = tokio::sync::mpsc::channel::<StreamChunk>(64);
let provider = self.provider.clone();
tokio::spawn(async move {
let mut stream = res.body.stream_data();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
let text = String::from_utf8_lossy(&chunk);
buffer.push_str(&text);
match provider {
LlmProvider::OpenAI => {
while let Some(pos) = buffer.find("\n\n") {
let event = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
if let Some(content) = Self::parse_openai_sse_chunk(&event) {
if content.is_empty() {
continue;
}
if tx.send(StreamChunk::Content(content)).await.is_err() {
return;
}
}
}
}
LlmProvider::Anthropic => {
while let Some(pos) = buffer.find("\n\n") {
let event = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
if let Some(content) = Self::parse_anthropic_sse_chunk(&event) {
if content.is_empty() {
continue;
}
if tx.send(StreamChunk::Content(content)).await.is_err() {
return;
}
}
}
}
LlmProvider::Ollama => {
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].to_string();
buffer = buffer[pos + 1..].to_string();
if let Some(content) = Self::parse_ollama_ndjson_chunk(&line) {
if content.is_empty() {
continue;
}
if tx.send(StreamChunk::Content(content)).await.is_err() {
return;
}
}
}
}
LlmProvider::OpenCode => {
}
LlmProvider::Codex => {
}
}
}
let _ = tx.send(StreamChunk::Done).await;
});
Ok(rx)
}
pub fn append_assistant_message(&mut self, content: impl Into<String>) {
self.messages
.push(ChatMessage::new(MessageRole::Assistant, content));
}
pub fn serialize(&self) -> anyhow::Result<String> {
let opencode = self.provider_session.as_opencode();
let codex = self.provider_session.as_codex();
let state = serde_json::json!({
"provider": self.provider,
"base_url": self.base_url,
"api_key": self.api_key,
"model": self.model,
"messages": self.messages,
"thinking_mode": self.thinking_mode,
"working_directory": self.working_directory,
"opencode_session_id": opencode.and_then(|s| s.session_id.as_ref()),
"opencode_parent_id": opencode.and_then(|s| s.parent_id.as_ref()),
"codex_thread_id": codex.and_then(|s| s.thread_id.as_ref()),
});
Ok(state.to_string())
}
pub fn deserialize(json: &str) -> anyhow::Result<Self> {
let state: serde_json::Value = serde_json::from_str(json)?;
let provider: LlmProvider = serde_json::from_value(
state
.get("provider")
.ok_or_else(|| anyhow::anyhow!("missing provider field"))?
.clone(),
)?;
let base_url = state
.get("base_url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing base_url field"))?
.to_string();
let api_key = state
.get("api_key")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let model = state
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let messages: Vec<ChatMessage> = serde_json::from_value(
state
.get("messages")
.ok_or_else(|| anyhow::anyhow!("missing messages field"))?
.clone(),
)?;
let thinking_mode = state
.get("thinking_mode")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or(ThinkingMode::Medium);
let working_directory = state
.get("working_directory")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let opencode_session_id = state
.get("opencode_session_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let opencode_parent_id = state
.get("opencode_parent_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let codex_thread_id = state
.get("codex_thread_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mut provider_session = ProviderSession::from_provider(&provider);
if let ProviderSession::OpenCode(ref mut opencode) = provider_session {
opencode.session_id = opencode_session_id;
opencode.parent_id = opencode_parent_id;
}
if let ProviderSession::Codex(ref mut codex) = provider_session {
codex.thread_id = codex_thread_id;
}
Ok(Self {
provider,
base_url,
api_key,
model,
session: Session::new(),
messages,
thinking_mode,
working_directory,
provider_session,
})
}
fn build_request(
&self,
stream: bool,
) -> anyhow::Result<(String, serde_json::Value, Vec<(String, String)>)> {
let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())];
if let Some(ref key) = self.api_key {
headers.push(("Authorization".to_string(), format!("Bearer {key}")));
}
match self.provider {
LlmProvider::OpenAI => {
let url = format!("{}/v1/chat/completions", self.base_url);
let messages: Vec<serde_json::Value> = self
.messages
.iter()
.map(|m| {
serde_json::json!({
"role": m.role.as_str(),
"content": m.content,
})
})
.collect();
let mut body = serde_json::json!({
"model": self.model,
"messages": messages,
"stream": stream,
});
if self.thinking_mode == ThinkingMode::Disabled {
body["thinking"] = serde_json::json!({"type": "disabled"});
} else {
body["thinking"] = serde_json::json!({"type": "enabled"});
body["reasoning_effort"] = self.thinking_mode.as_str().into();
}
Ok((url, body, headers))
}
LlmProvider::Anthropic => {
let url = format!("{}/v1/messages", self.base_url);
let system_msg = self
.messages
.iter()
.find(|m| m.role == MessageRole::System)
.map(|m| m.content.clone());
let messages: Vec<serde_json::Value> = self
.messages
.iter()
.filter(|m| m.role != MessageRole::System)
.map(|m| {
serde_json::json!({
"role": m.role.as_str(),
"content": m.content,
})
})
.collect();
let mut body = serde_json::json!({
"model": self.model,
"messages": messages,
"max_tokens": 4096,
"stream": stream,
});
if let Some(system) = system_msg {
body["system"] = serde_json::Value::String(system);
}
headers.push((
"x-api-key".to_string(),
self.api_key.clone().unwrap_or_default(),
));
headers.push(("anthropic-version".to_string(), "2023-06-01".to_string()));
if self.thinking_mode == ThinkingMode::Disabled {
body["thinking"] = serde_json::json!({"type": "disabled"});
} else {
body["thinking"] = serde_json::json!({"type": "enabled"});
body["output_config"] =
serde_json::json!({ "effort": self.thinking_mode.as_str() })
}
Ok((url, body, headers))
}
LlmProvider::Ollama => {
let url = format!("{}/api/chat", self.base_url);
let messages: Vec<serde_json::Value> = self
.messages
.iter()
.map(|m| {
serde_json::json!({
"role": m.role.as_str(),
"content": m.content,
})
})
.collect();
let body = serde_json::json!({
"model": self.model,
"messages": messages,
"stream": stream,
});
Ok((url, body, headers))
}
LlmProvider::OpenCode => {
let url = format!("{}/session/message", self.base_url);
let body = serde_json::json!({});
Ok((url, body, headers))
}
LlmProvider::Codex => {
let url = self.base_url.clone();
let body = serde_json::json!({});
Ok((url, body, headers))
}
}
}
fn parse_response(&self, text: &str) -> anyhow::Result<String> {
match self.provider {
LlmProvider::OpenAI => {
let json: serde_json::Value = serde_json::from_str(text)?;
let content = json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("");
Ok(content.to_string())
}
LlmProvider::OpenCode => self.parse_opencode_response(text),
LlmProvider::Anthropic => {
let json: serde_json::Value = serde_json::from_str(text)?;
let mut result = String::new();
if let Some(contents) = json["content"].as_array() {
for item in contents {
if item["type"].as_str() == Some("text") {
if let Some(text) = item["text"].as_str() {
result.push_str(text);
}
}
}
}
Ok(result)
}
LlmProvider::Ollama => {
let json: serde_json::Value = serde_json::from_str(text)?;
let content = json["message"]["content"].as_str().unwrap_or("");
Ok(content.to_string())
}
LlmProvider::Codex => {
Ok(String::new())
}
}
}
fn parse_openai_sse_chunk(event: &str) -> Option<String> {
for line in event.lines() {
if line.starts_with("data: ") {
let data = &line[6..];
if data == "[DONE]" {
return Some(String::new());
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(content) = json["choices"][0]["delta"]["content"].as_str() {
return Some(content.to_string());
}
}
}
}
None
}
fn parse_anthropic_sse_chunk(event: &str) -> Option<String> {
for line in event.lines() {
if line.starts_with("data: ") {
let data = &line[6..];
if let Ok(json) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(text) = json["delta"]["text"].as_str() {
return Some(text.to_string());
}
}
}
}
None
}
fn parse_ollama_ndjson_chunk(line: &str) -> Option<String> {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
if json["done"].as_bool().unwrap_or(false) {
return Some(String::new());
}
if let Some(content) = json["message"]["content"].as_str() {
return Some(content.to_string());
}
}
None
}
}