Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use console::Style;
use mofa_sdk::{llm::LLMAgentBuilder, skills::SkillsManager};
use mofaclaw_core::{
AgentLoop, ChannelManager, Config, ContextBuilder, DingTalkChannel, FeishuChannel,
HeartbeatService, MessageBus, SessionManager, SubagentManager, TelegramChannel,
GatewayServer, HeartbeatService, MessageBus, SessionManager, SubagentManager, TelegramChannel,
channels::DiscordChannel,
load_config,
provider::{OpenAIConfig, OpenAIProvider},
Expand Down Expand Up @@ -422,19 +422,27 @@ async fn command_gateway(port: u16, verbose: bool) -> Result<()> {
}));

println!("Heartbeat: every 30m");

// Build HTTP gateway server (binds on the configured port)
let gateway_host = config.gateway.host.clone();
let http_server = GatewayServer::new(bus.clone(), gateway_host, port, model.clone());

println!("Ready!");

// Start heartbeat
heartbeat.start().await?;

// Start everything
// Start everything: agent loop + channel manager + HTTP gateway
tokio::select! {
result = agent.run() => {
result?;
}
result = channel_manager.start_all() => {
result?;
}
result = http_server.run() => {
result?;
}
_ = tokio::signal::ctrl_c() => {
println!("\nShutting down...");
heartbeat.stop().await;
Expand Down
5 changes: 5 additions & 0 deletions core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ infer = "0.15"
base64 = "0.22"
glob = "0.3"

# http server for REST API gateway
axum = { workspace = true }
tower = { workspace = true }

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tower crate is added as a dependency here but is never directly imported or used anywhere in core/src/. Only tower_http is used (for CorsLayer). The tower dependency can be removed to keep the dependency list clean.

Suggested change
tower = { workspace = true }

Copilot uses AI. Check for mistakes.
tower-http = { workspace = true }

# telegram
teloxide = { workspace = true }

Expand Down
304 changes: 304 additions & 0 deletions core/src/gateway/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
//! HTTP REST API Gateway for mofaclaw
//!
//! Exposes an OpenAI-compatible API on port 18790 (default) so any tool that
//! speaks the OpenAI chat-completions protocol can talk to the mofaclaw agent.
//!
//! # Endpoints
//!
//! | Method | Path | Description |
//! |--------|---------------------------|---------------------------------|
//! | GET | `/` | Welcome / info |
//! | GET | `/health` | Health check (returns 200 OK) |
//! | GET | `/v1/models` | List available models |
//! | POST | `/v1/chat/completions` | Chat (streaming and non-stream) |
//!
//! # Quick start
//! ```bash
//! curl http://localhost:18790/v1/chat/completions \
//! -H "Content-Type: application/json" \
//! -d '{"model":"mofaclaw","messages":[{"role":"user","content":"Hello!"}]}'
//! ```

pub mod types;

use std::{
collections::HashMap,
convert::Infallible,
net::SocketAddr,
sync::Arc,
time::Duration,
};

use axum::{
Json, Router,
extract::State,
http::StatusCode,
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
};
use futures_util::stream;
use tokio::sync::{Mutex, oneshot};
use tower_http::cors::CorsLayer;
use tracing::{debug, error, info, warn};
use uuid::Uuid;

use crate::{
MessageBus,
messages::InboundMessage,
};

use types::{
ApiError, ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse, ModelList,
};

// ─────────────────────────── Pending-request map ──────────────────────────

/// In-flight HTTP requests waiting for an agent response.
///
/// Key: `chat_id` used for the InboundMessage (unique per request unless the
/// caller supplies a `conversation_id`).
/// Value: oneshot sender – fires when the agent publishes an outbound reply.
type PendingMap = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;

// ─────────────────────────── Shared state ─────────────────────────────────

#[derive(Clone)]
pub struct GatewayState {
bus: MessageBus,
pending: PendingMap,
model: String,
}

// ─────────────────────────── Server ───────────────────────────────────────

/// The HTTP gateway server.
pub struct GatewayServer {
bus: MessageBus,
host: String,
port: u16,
model: String,
}

impl GatewayServer {
pub fn new(bus: MessageBus, host: impl Into<String>, port: u16, model: impl Into<String>) -> Self {
Self {
bus,
host: host.into(),
port,
model: model.into(),
}
}

/// Start the HTTP server. Runs until the process is stopped.
pub async fn run(self) -> anyhow::Result<()> {
let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));

// Spawn the outbound-dispatch background task.
// It subscribes to the message bus and resolves pending HTTP requests.
let pending_for_dispatch = pending.clone();
let mut outbound_rx = self.bus.subscribe_outbound();
tokio::spawn(async move {
loop {
match outbound_rx.recv().await {
Ok(msg) => {
if msg.channel != "api" {
continue;
}
debug!("API gateway received outbound for chat_id={}", msg.chat_id);
let mut map = pending_for_dispatch.lock().await;
if let Some(tx) = map.remove(&msg.chat_id) {
let _ = tx.send(msg.content);
} else {
warn!("No pending request for chat_id={}", msg.chat_id);
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!("API gateway outbound receiver lagged by {} messages", n);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
info!("API gateway outbound bus closed – stopping dispatch task");
break;
}
}
}
});

let state = GatewayState {
bus: self.bus,
pending,
model: self.model,
};

let app = Router::new()
.route("/", get(handle_root))
.route("/health", get(handle_health))
.route("/v1/models", get(handle_models))
.route("/v1/chat/completions", post(handle_chat_completions))
.layer(CorsLayer::permissive())

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CorsLayer::permissive() allows all origins, methods, and headers with no restrictions. While this is convenient for development and local usage, it should be documented as a known security trade-off. If this gateway is ever exposed to non-local networks, unrestricted CORS could allow any website to make requests to the API. Consider at least logging a warning when the server starts if it's binding to a non-loopback address, or making the CORS policy configurable.

Copilot uses AI. Check for mistakes.
.with_state(state);

let addr: SocketAddr = format!("{}:{}", self.host, self.port).parse()?;
info!("HTTP API gateway listening on http://{}", addr);
println!("HTTP API: http://{} (OpenAI-compatible)", addr);

let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
}

// ─────────────────────────── Handlers ─────────────────────────────────────

async fn handle_root() -> impl IntoResponse {
Json(serde_json::json!({
"name": "mofaclaw",
"version": env!("CARGO_PKG_VERSION"),
"description": "Mofaclaw AI assistant – OpenAI-compatible HTTP API",
"endpoints": {
"GET /health": "Health check",
"GET /v1/models": "List models",
"POST /v1/chat/completions": "Chat (stream=false|true)"
},
"docs": "https://github.com/mofa-org/mofaclaw"
}))
}

async fn handle_health() -> impl IntoResponse {
Json(serde_json::json!({"status": "ok"}))
}

async fn handle_models(State(state): State<GatewayState>) -> impl IntoResponse {
Json(ModelList::mofaclaw_models(&state.model))
}

async fn handle_chat_completions(
State(state): State<GatewayState>,
Json(req): Json<ChatCompletionRequest>,
) -> Response {
// Determine session / chat_id
let chat_id = req
.conversation_id
.clone()
.unwrap_or_else(|| Uuid::new_v4().to_string());

// Extract the latest user message (the session carries prior history)
let user_content = req
.messages
.iter()
.rev()
.find(|m| m.role == "user")
.map(|m| m.content.clone())
.unwrap_or_default();

if user_content.is_empty() {
let status = StatusCode::BAD_REQUEST;
let body = Json(ApiError::internal("No user message found in request"));
return (status, body).into_response();
Comment on lines +196 to +199

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApiError::internal(...) is used here for a 400 BAD_REQUEST response, but internal sets the error type to "internal_error". For OpenAI API compatibility, a missing user message is a client error and should use "invalid_request_error" as the type. Consider adding a bad_request constructor to ApiError (similar to the internal and timeout constructors) that sets r#type: "invalid_request_error".

Copilot uses AI. Check for mistakes.
}

// Register pending request BEFORE publishing so the reply isn't missed
let (tx, rx) = oneshot::channel::<String>();
{
let mut map = state.pending.lock().await;

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a conversation_id is provided, chat_id is set to that value. If two concurrent requests share the same conversation_id, the second map.insert(chat_id.clone(), tx) will silently overwrite the first sender in the PendingMap. This causes the first request's oneshot sender to be dropped, and the first request's receiver will get a RecvError, resulting in a 500 "Agent response channel closed unexpectedly" error.

To prevent this, either reject the second request if a pending entry already exists for the same chat_id, or append a unique suffix (e.g., format!("{}-{}", conversation_id, Uuid::new_v4())) to disambiguate the map key while still passing the conversation_id as the session key to the agent.

Suggested change
let mut map = state.pending.lock().await;
let mut map = state.pending.lock().await;
if map.contains_key(&chat_id) {
// There is already a pending request for this conversation; reject to avoid
// overwriting the existing sender and breaking the in‑flight request.
return (
StatusCode::CONFLICT,
Json(ApiError::internal(
"A request for this conversation is already in progress",
)),
)
.into_response();
}

Copilot uses AI. Check for mistakes.
map.insert(chat_id.clone(), tx);
}

// Publish to the agent via the message bus
let inbound = InboundMessage::new("api", "api-user", &chat_id, &user_content);
if let Err(e) = state.bus.publish_inbound(inbound).await {
error!("Failed to publish inbound message: {}", e);
let mut map = state.pending.lock().await;
map.remove(&chat_id);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::internal(format!("Bus error: {e}"))),
)
.into_response();
}

// Wait for the agent response (2-minute hard timeout)
let response_text = match tokio::time::timeout(Duration::from_secs(120), rx).await {
Ok(Ok(text)) => text,
Ok(Err(_)) => {
// sender dropped without sending
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::internal("Agent response channel closed unexpectedly")),
)
.into_response();
}
Err(_) => {
// Timed out – clean up the pending entry
let mut map = state.pending.lock().await;
map.remove(&chat_id);
return (StatusCode::GATEWAY_TIMEOUT, Json(ApiError::timeout())).into_response();
}
};

let completion_id = format!("chatcmpl-{}", Uuid::new_v4().simple());

if req.stream {
// ── Streaming path: SSE with OpenAI-format chunks ──────────────────
// The agent processes in one shot, so we stream the response
// word-by-word to give clients the streaming experience.
let model = state.model.clone();
let id_for_stream = completion_id.clone();

let words: Vec<String> = response_text
.split_inclusive(' ')
.map(|s| s.to_string())
.collect();

let event_stream = {
let model_role = model.clone();
let id_role = id_for_stream.clone();
let model_words = model.clone();
let id_words = id_for_stream.clone();
let model_stop = model.clone();
let id_stop = id_for_stream.clone();

// Build a Vec of SSE events: role → words → stop
let mut events: Vec<Result<Event, Infallible>> = Vec::new();

// Role event
let role_chunk = ChatCompletionChunk::role_chunk(&id_role, &model_role);
if let Ok(data) = serde_json::to_string(&role_chunk) {
events.push(Ok(Event::default().data(data)));
}

// Word events
for word in words {
let chunk = ChatCompletionChunk::content_chunk(&id_words, &model_words, word);
if let Ok(data) = serde_json::to_string(&chunk) {
events.push(Ok(Event::default().data(data)));
}
}

// Stop event
let stop_chunk = ChatCompletionChunk::stop_chunk(&id_stop, &model_stop);
Comment on lines +256 to +281

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are six unnecessary clones here. Since model and id_for_stream are not moved into a closure or spawned task within this block, you can use &model and &id_for_stream (or just &completion_id) directly in the role_chunk, content_chunk, and stop_chunk calls below—these methods already accept impl Into<String>. All of model_role, id_role, model_words, id_words, model_stop, and id_stop can be removed.

Suggested change
let model_role = model.clone();
let id_role = id_for_stream.clone();
let model_words = model.clone();
let id_words = id_for_stream.clone();
let model_stop = model.clone();
let id_stop = id_for_stream.clone();
// Build a Vec of SSE events: role → words → stop
let mut events: Vec<Result<Event, Infallible>> = Vec::new();
// Role event
let role_chunk = ChatCompletionChunk::role_chunk(&id_role, &model_role);
if let Ok(data) = serde_json::to_string(&role_chunk) {
events.push(Ok(Event::default().data(data)));
}
// Word events
for word in words {
let chunk = ChatCompletionChunk::content_chunk(&id_words, &model_words, word);
if let Ok(data) = serde_json::to_string(&chunk) {
events.push(Ok(Event::default().data(data)));
}
}
// Stop event
let stop_chunk = ChatCompletionChunk::stop_chunk(&id_stop, &model_stop);
// Build a Vec of SSE events: role → words → stop
let mut events: Vec<Result<Event, Infallible>> = Vec::new();
// Role event
let role_chunk = ChatCompletionChunk::role_chunk(&id_for_stream, &model);
if let Ok(data) = serde_json::to_string(&role_chunk) {
events.push(Ok(Event::default().data(data)));
}
// Word events
for word in words {
let chunk =
ChatCompletionChunk::content_chunk(&id_for_stream, &model, word);
if let Ok(data) = serde_json::to_string(&chunk) {
events.push(Ok(Event::default().data(data)));
}
}
// Stop event
let stop_chunk = ChatCompletionChunk::stop_chunk(&id_for_stream, &model);

Copilot uses AI. Check for mistakes.
if let Ok(data) = serde_json::to_string(&stop_chunk) {
events.push(Ok(Event::default().data(data)));
}

// Terminator
events.push(Ok(Event::default().data("[DONE]")));

stream::iter(events)
};

Sse::new(event_stream)
.keep_alive(KeepAlive::default())
.into_response()
} else {
// ── Non-streaming path ──────────────────────────────────────────────
Json(ChatCompletionResponse::new(
completion_id,
&state.model,
response_text,
))
.into_response()
}
}

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gateway module has no tests, while nearly every other module in core/src/ has a #[cfg(test)] section (e.g., messages.rs, bus/queue.rs, channels/manager.rs, config.rs). At minimum, the types in types.rs should have unit tests for serialization/deserialization (e.g., verifying ChatCompletionResponse::new produces the expected JSON shape), and mod.rs should have tests verifying the pending-map dispatch logic (register a sender, simulate an outbound message, assert the receiver gets the content).

Suggested change
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::response::IntoResponse;
use hyper::body::to_bytes;
#[tokio::test]
async fn handle_health_returns_ok_status() {
let response = handle_health().into_response();
let body_bytes = to_bytes(response.into_body()).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(value, serde_json::json!({ "status": "ok" }));
}
}

Copilot uses AI. Check for mistakes.
Loading