use crate::server::app::AppState;
use crate::server::sse;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use nomoreide_core::agent_info::detected_agent_name;
use nomoreide_core::agent_runtime::{
self, is_agent_available, permission_mode, provider_by_id, public_provider_info,
resolve_chat_provider, AgentChatProvider, AgentStreamEvent, Approval, RunOptions,
};
use nomoreide_core::approval_broker::{ApprovalDecision, Decision};
use serde::Serialize;
use serde_json::{json, Value};
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route("/api/agent/chat", post(chat))
.route("/api/agent/chat/status", get(status))
.route("/api/agent/chat/model", post(set_model))
.route("/api/agent/chat/provider", post(set_provider))
.route("/api/agent/chat/approval", post(approval))
.route("/api/agent/chat/approve", post(approve))
}
#[allow(clippy::result_large_err)]
fn read_or_refuse(raw: &Bytes) -> Result<Value, Response> {
let text = String::from_utf8_lossy(raw);
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(Value::Object(Default::default()));
}
serde_json::from_str::<Value>(trimmed)
.map_err(|_| refuse(StatusCode::BAD_REQUEST, PARSE_FAILURE))
}
const PARSE_FAILURE: &str = "Request body is not valid JSON.";
fn refuse(status: StatusCode, message: &str) -> Response {
(
status,
Json(Failure {
ok: false,
error: message.to_string(),
}),
)
.into_response()
}
fn named_provider(body: &Value) -> Option<AgentChatProvider> {
provider_by_id(string_field(body, "provider"))
}
async fn selected_provider(
state: &AppState,
override_id: Option<&str>,
) -> (AgentChatProvider, Value) {
let config = state.config_store.load().await.unwrap_or_default();
let preferred = override_id
.map(str::to_string)
.or_else(|| config.chat_provider.clone());
let provider = resolve_chat_provider(&detected_agent_name().await, preferred.as_deref());
let models =
serde_json::to_value(config.chat_models.unwrap_or_default()).unwrap_or_else(|_| json!({}));
(provider, models)
}
async fn status(State(state): State<AppState>) -> Response {
let (provider, models) = selected_provider(&state, None).await;
let mode = permission_mode();
let mut providers = Vec::new();
for candidate in agent_runtime::chat_providers() {
let mut info = public_provider_info(&candidate);
if let Some(object) = info.as_object_mut() {
object.insert(
"configured".into(),
json!(is_agent_available(&candidate).await),
);
}
providers.push(info);
}
Json(json!({
"ok": true,
"configured": is_agent_available(&provider).await,
"approvals": agent_runtime::approvals_enabled(&provider, &mode),
"provider": public_provider_info(&provider),
"providers": providers,
"models": models,
}))
.into_response()
}
async fn set_model(State(state): State<AppState>, raw: Bytes) -> Response {
let body = match read_or_refuse(&raw) {
Ok(body) => body,
Err(response) => return response,
};
let Some(provider) = named_provider(&body) else {
return refuse(StatusCode::BAD_REQUEST, "Unknown chat provider.");
};
let model = match body.get("model") {
None | Some(Value::Null) => None,
Some(Value::String(model)) => Some(model.clone()),
Some(_) => return refuse(StatusCode::BAD_REQUEST, "Model must be a string."),
};
let requested = model.unwrap_or_default();
let trimmed = requested.trim();
if trimmed.chars().count() > 64 {
return refuse(StatusCode::BAD_REQUEST, "Model name is too long.");
}
match state
.config_store
.set_chat_model(provider.id.as_str(), Some(trimmed))
.await
{
Ok(config) => Json(json!({
"ok": true,
"models": serde_json::to_value(config.chat_models.unwrap_or_default())
.unwrap_or_else(|_| json!({})),
}))
.into_response(),
Err(reason) => refuse(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
}
}
async fn set_provider(State(state): State<AppState>, raw: Bytes) -> Response {
let body = match read_or_refuse(&raw) {
Ok(body) => body,
Err(response) => return response,
};
let Some(provider) = named_provider(&body) else {
return refuse(StatusCode::BAD_REQUEST, "Unknown chat provider.");
};
match state
.config_store
.set_chat_provider(provider.id.as_str().to_string())
.await
{
Ok(_) => {
Json(json!({ "ok": true, "provider": public_provider_info(&provider) })).into_response()
}
Err(reason) => refuse(StatusCode::INTERNAL_SERVER_ERROR, &reason.to_string()),
}
}
async fn chat(State(state): State<AppState>, headers: HeaderMap, raw: Bytes) -> Response {
let body = match read_or_refuse(&raw) {
Ok(body) => body,
Err(response) => return response,
};
let message = match string_field(&body, "message") {
Some(message) if !message.trim().is_empty() => message.to_string(),
_ => {
return refuse(
StatusCode::BAD_REQUEST,
"Request must include a non-empty `message` string.",
)
}
};
let resume = string_field(&body, "resumeSessionId").map(str::to_string);
let auto_approve = body.get("autoApprove") == Some(&Value::Bool(true));
let (provider, _) = selected_provider(&state, string_field(&body, "provider")).await;
if !is_agent_available(&provider).await {
return refuse(
StatusCode::SERVICE_UNAVAILABLE,
&format!(
"{} (`{}`) is not installed or not on PATH.",
provider.label, provider.command_name
),
);
}
let host = headers
.get(axum::http::header::HOST)
.and_then(|value| value.to_str().ok())
.unwrap_or("127.0.0.1:4317")
.to_string();
let cwd = state.workspace_cwd().await;
let approvals = state.approvals.clone();
sse::driven(sse::CHAT_TURN, move |sink| async move {
let (events, mut received) = tokio::sync::mpsc::unbounded_channel::<AgentStreamEvent>();
let run = tokio::spawn(async move {
agent_runtime::run(
&cwd,
&provider,
RunOptions {
message: &message,
resume_session_id: resume.as_deref(),
permission_mode: &permission_mode(),
codex_approval_policy: &agent_runtime::codex_approval_policy(),
approval: Some(Approval {
broker: approvals,
url: format!("http://{host}/api/agent/chat/approval"),
auto_approve,
}),
},
events,
)
.await;
});
while let Some(event) = received.recv().await {
if !sink.send(sse::unnamed(event)).await {
break;
}
}
let _ = run.await;
})
}
enum Body {
Value(Value),
Null,
Malformed,
}
fn read_body(raw: &Bytes) -> Body {
let text = String::from_utf8_lossy(raw);
let trimmed = text.trim();
if trimmed.is_empty() {
return Body::Value(Value::Object(Default::default()));
}
match serde_json::from_str::<Value>(trimmed) {
Ok(Value::Null) => Body::Null,
Ok(value) => Body::Value(value),
Err(_) => Body::Malformed,
}
}
fn string_field<'a>(body: &'a Value, key: &str) -> Option<&'a str> {
body.get(key).and_then(Value::as_str)
}
fn tool_name_of(body: &Value) -> &str {
string_field(body, "toolName").unwrap_or("tool")
}
fn decision_of(body: &Value) -> Decision {
if string_field(body, "decision") == Some("allow") {
Decision::Allow
} else {
Decision::Deny
}
}
#[derive(Serialize)]
struct Failure {
ok: bool,
error: String,
}
fn null_body_failure(field: &str) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(Failure {
ok: false,
error: format!("Cannot read properties of null (reading '{field}')"),
}),
)
.into_response()
}
async fn approval(State(state): State<AppState>, raw: Bytes) -> Response {
let body = match read_body(&raw) {
Body::Value(value) => value,
Body::Null => return null_body_failure("requestId"),
Body::Malformed => {
return Json(ApprovalDecision::deny("Malformed approval request.")).into_response()
}
};
let Some(request_id) = string_field(&body, "requestId") else {
return Json(ApprovalDecision::deny("Missing request id.")).into_response();
};
let name = tool_name_of(&body);
let input = body.get("toolInput").cloned().unwrap_or(Value::Null);
let decision = state
.approvals
.request_approval(string_field(&body, "sessionId"), request_id, name, input)
.await;
Json(decision).into_response()
}
async fn approve(State(state): State<AppState>, raw: Bytes) -> Response {
let body = match read_body(&raw) {
Body::Value(value) => value,
Body::Null => return null_body_failure("sessionId"),
Body::Malformed => {
return (
StatusCode::BAD_REQUEST,
Json(Failure {
ok: false,
error: "Unexpected token in JSON".to_string(),
}),
)
.into_response();
}
};
let (Some(session_id), Some(request_id)) = (
string_field(&body, "sessionId"),
string_field(&body, "requestId"),
) else {
return (
StatusCode::BAD_REQUEST,
Json(Failure {
ok: false,
error: "sessionId and requestId are required.".to_string(),
}),
)
.into_response();
};
let decision = decision_of(&body);
let resolved = state.approvals.resolve(
session_id,
request_id,
ApprovalDecision {
decision,
reason: string_field(&body, "reason").map(str::to_string),
},
);
Json(Resolved { ok: resolved }).into_response()
}
#[derive(Serialize)]
struct Resolved {
ok: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_empty_body_reads_as_an_empty_object() {
assert!(
matches!(read_body(&Bytes::from_static(b"")), Body::Value(Value::Object(map)) if map.is_empty())
);
assert!(
matches!(read_body(&Bytes::from_static(b" \n ")), Body::Value(Value::Object(map)) if map.is_empty())
);
}
#[test]
fn a_null_body_is_its_own_case() {
assert!(matches!(
read_body(&Bytes::from_static(b"null")),
Body::Null
));
}
#[test]
fn a_non_object_body_is_read_rather_than_refused() {
assert!(matches!(
read_body(&Bytes::from_static(b"[1,2,3]")),
Body::Value(_)
));
assert!(matches!(
read_body(&Bytes::from_static(b"\"hello\"")),
Body::Value(_)
));
assert_eq!(
string_field(&serde_json::json!([1, 2, 3]), "requestId"),
None
);
assert_eq!(string_field(&serde_json::json!("hello"), "requestId"), None);
}
#[test]
fn a_non_string_field_counts_as_absent() {
let body = serde_json::json!({ "requestId": 7, "sessionId": null, "other": "x" });
assert_eq!(string_field(&body, "requestId"), None);
assert_eq!(string_field(&body, "sessionId"), None);
assert_eq!(string_field(&body, "other"), Some("x"));
}
#[test]
fn only_the_exact_string_allow_allows() {
assert_eq!(
decision_of(&serde_json::json!({ "decision": "allow" })),
Decision::Allow
);
for denied in [
serde_json::json!({ "decision": "deny" }),
serde_json::json!({ "decision": "Allow" }),
serde_json::json!({ "decision": "ALLOW" }),
serde_json::json!({ "decision": " allow" }),
serde_json::json!({ "decision": "allowed" }),
serde_json::json!({ "decision": "maybe" }),
serde_json::json!({ "decision": true }),
serde_json::json!({ "decision": null }),
serde_json::json!({}),
] {
assert_eq!(decision_of(&denied), Decision::Deny, "{denied} must deny");
}
}
#[test]
fn an_unnamed_tool_gets_a_placeholder_label() {
assert_eq!(
tool_name_of(&serde_json::json!({ "toolName": "Bash" })),
"Bash"
);
assert_eq!(tool_name_of(&serde_json::json!({})), "tool");
assert_eq!(tool_name_of(&serde_json::json!({ "toolName": 7 })), "tool");
assert_eq!(
tool_name_of(&serde_json::json!({ "toolName": null })),
"tool"
);
}
#[test]
fn malformed_json_is_distinguished_from_an_empty_body() {
assert!(matches!(
read_body(&Bytes::from_static(b"{not json")),
Body::Malformed
));
}
}