mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Add POST /v1/responses and GET /v1/responses/{id} to the web gateway,
implementing the OpenAI Responses API. Unlike the existing Chat
Completions proxy which passes through to the raw LLM, the Responses
API routes requests through the full agent loop — giving external
clients access to tools, memory, safety, and server-side conversation
state via a standard OpenAI-compatible interface.
Key design decisions:
- Response IDs encode thread UUIDs statelessly (resp_{uuid_simple})
- previous_response_id enables multi-turn conversations
- Streaming maps AppEvent variants to Responses API SSE events
- Tool approval returns response.failed (no interactive approval flow)
- GET endpoint reconstructs ResponseObject from conversation_messages
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
537 lines
18 KiB
Rust
537 lines
18 KiB
Rust
//! Web gateway channel for browser-based access to IronClaw.
|
|
//!
|
|
//! Provides a single-page web UI with:
|
|
//! - Chat with the agent (via REST + SSE)
|
|
//! - Workspace/memory browsing
|
|
//! - Job management
|
|
//!
|
|
//! ```text
|
|
//! Browser ─── POST /api/chat/send ──► Agent Loop
|
|
//! ◄── GET /api/chat/events ── SSE stream
|
|
//! ─── GET /api/chat/ws ─────► WebSocket (bidirectional)
|
|
//! ─── GET /api/memory/* ────► Workspace
|
|
//! ─── GET /api/jobs/* ──────► Database
|
|
//! ◄── GET / ───────────────── Static HTML/CSS/JS
|
|
//! ```
|
|
|
|
pub mod auth;
|
|
pub(crate) mod handlers;
|
|
pub mod log_layer;
|
|
pub mod openai_compat;
|
|
pub mod responses_api;
|
|
pub mod server;
|
|
pub mod sse;
|
|
pub mod types;
|
|
pub(crate) mod util;
|
|
pub mod ws;
|
|
|
|
/// Test helpers for gateway integration tests.
|
|
///
|
|
/// Always compiled (not behind `#[cfg(test)]`) so that integration tests in
|
|
/// `tests/` -- which import this crate as a regular dependency -- can use
|
|
/// [`TestGatewayBuilder`](test_helpers::TestGatewayBuilder).
|
|
pub mod test_helpers;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use tokio::sync::mpsc;
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
|
|
use crate::agent::SessionManager;
|
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
|
use crate::config::GatewayConfig;
|
|
use crate::db::Database;
|
|
use crate::error::ChannelError;
|
|
use crate::extensions::ExtensionManager;
|
|
use crate::orchestrator::job_manager::ContainerJobManager;
|
|
use crate::skills::catalog::SkillCatalog;
|
|
use crate::skills::registry::SkillRegistry;
|
|
use crate::tools::ToolRegistry;
|
|
use crate::workspace::Workspace;
|
|
|
|
use self::log_layer::{LogBroadcaster, LogLevelHandle};
|
|
|
|
use self::auth::{CombinedAuthState, DbAuthenticator, MultiAuthState};
|
|
use self::server::GatewayState;
|
|
use self::sse::SseManager;
|
|
use self::types::AppEvent;
|
|
|
|
/// Web gateway channel implementing the Channel trait.
|
|
pub struct GatewayChannel {
|
|
config: GatewayConfig,
|
|
state: Arc<GatewayState>,
|
|
/// Combined auth state: env-var tokens + optional DB-backed tokens.
|
|
auth: CombinedAuthState,
|
|
}
|
|
|
|
impl GatewayChannel {
|
|
/// Create a new gateway channel.
|
|
///
|
|
/// If no auth token is configured, generates a random one and prints it.
|
|
/// Builds a single-user `MultiAuthState` from the config.
|
|
pub fn new(config: GatewayConfig, owner_id: String) -> Self {
|
|
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
|
|
use rand::RngCore;
|
|
use rand::rngs::OsRng;
|
|
let mut bytes = [0u8; 32];
|
|
OsRng.fill_bytes(&mut bytes);
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
});
|
|
|
|
let auth = CombinedAuthState {
|
|
env_auth: MultiAuthState::single(auth_token, owner_id.clone()),
|
|
db_auth: None,
|
|
};
|
|
|
|
let state = Arc::new(GatewayState {
|
|
msg_tx: tokio::sync::RwLock::new(None),
|
|
sse: Arc::new(SseManager::new()),
|
|
workspace: None,
|
|
workspace_pool: None,
|
|
session_manager: None,
|
|
log_broadcaster: None,
|
|
log_level_handle: None,
|
|
extension_manager: None,
|
|
tool_registry: None,
|
|
store: None,
|
|
job_manager: None,
|
|
prompt_queue: None,
|
|
scheduler: None,
|
|
owner_id,
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
|
llm_provider: None,
|
|
skill_registry: None,
|
|
skill_catalog: None,
|
|
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
|
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
|
registry_entries: Vec::new(),
|
|
cost_guard: None,
|
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
|
startup_time: std::time::Instant::now(),
|
|
active_config: server::ActiveConfigSnapshot::default(),
|
|
secrets_store: None,
|
|
});
|
|
|
|
Self {
|
|
config,
|
|
state,
|
|
auth,
|
|
}
|
|
}
|
|
|
|
/// Helper to rebuild state, copying existing fields and applying a mutation.
|
|
fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) {
|
|
let mut new_state = GatewayState {
|
|
msg_tx: tokio::sync::RwLock::new(None),
|
|
// Preserve the existing broadcast channel so sender handles remain valid.
|
|
sse: Arc::new(SseManager::from_sender(self.state.sse.sender())),
|
|
workspace: self.state.workspace.clone(),
|
|
workspace_pool: self.state.workspace_pool.clone(),
|
|
session_manager: self.state.session_manager.clone(),
|
|
log_broadcaster: self.state.log_broadcaster.clone(),
|
|
log_level_handle: self.state.log_level_handle.clone(),
|
|
extension_manager: self.state.extension_manager.clone(),
|
|
tool_registry: self.state.tool_registry.clone(),
|
|
store: self.state.store.clone(),
|
|
job_manager: self.state.job_manager.clone(),
|
|
prompt_queue: self.state.prompt_queue.clone(),
|
|
scheduler: self.state.scheduler.clone(),
|
|
owner_id: self.state.owner_id.clone(),
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: self.state.ws_tracker.clone(),
|
|
llm_provider: self.state.llm_provider.clone(),
|
|
skill_registry: self.state.skill_registry.clone(),
|
|
skill_catalog: self.state.skill_catalog.clone(),
|
|
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
|
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
|
registry_entries: self.state.registry_entries.clone(),
|
|
cost_guard: self.state.cost_guard.clone(),
|
|
routine_engine: Arc::clone(&self.state.routine_engine),
|
|
startup_time: self.state.startup_time,
|
|
active_config: self.state.active_config.clone(),
|
|
secrets_store: self.state.secrets_store.clone(),
|
|
};
|
|
mutate(&mut new_state);
|
|
self.state = Arc::new(new_state);
|
|
}
|
|
|
|
/// Inject the workspace reference for the memory API.
|
|
pub fn with_workspace(mut self, workspace: Arc<Workspace>) -> Self {
|
|
self.rebuild_state(|s| s.workspace = Some(workspace));
|
|
self
|
|
}
|
|
|
|
/// Inject the session manager for thread/session info.
|
|
pub fn with_session_manager(mut self, sm: Arc<SessionManager>) -> Self {
|
|
self.rebuild_state(|s| s.session_manager = Some(sm));
|
|
self
|
|
}
|
|
|
|
/// Inject the log broadcaster for the logs SSE endpoint.
|
|
pub fn with_log_broadcaster(mut self, lb: Arc<LogBroadcaster>) -> Self {
|
|
self.rebuild_state(|s| s.log_broadcaster = Some(lb));
|
|
self
|
|
}
|
|
|
|
/// Inject the log level handle for runtime log level control.
|
|
pub fn with_log_level_handle(mut self, h: Arc<LogLevelHandle>) -> Self {
|
|
self.rebuild_state(|s| s.log_level_handle = Some(h));
|
|
self
|
|
}
|
|
|
|
/// Inject the extension manager for the extensions API.
|
|
pub fn with_extension_manager(mut self, em: Arc<ExtensionManager>) -> Self {
|
|
self.rebuild_state(|s| s.extension_manager = Some(em));
|
|
self
|
|
}
|
|
|
|
/// Inject the tool registry for the extensions API.
|
|
pub fn with_tool_registry(mut self, tr: Arc<ToolRegistry>) -> Self {
|
|
self.rebuild_state(|s| s.tool_registry = Some(tr));
|
|
self
|
|
}
|
|
|
|
/// Inject the database store for sandbox job persistence.
|
|
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
|
self.rebuild_state(|s| s.store = Some(store));
|
|
self
|
|
}
|
|
|
|
/// Enable DB-backed token authentication alongside env-var tokens.
|
|
pub fn with_db_auth(mut self, store: Arc<dyn Database>) -> Self {
|
|
self.auth.db_auth = Some(DbAuthenticator::new(store));
|
|
self
|
|
}
|
|
|
|
/// Inject the container job manager for sandbox operations.
|
|
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
|
|
self.rebuild_state(|s| s.job_manager = Some(jm));
|
|
self
|
|
}
|
|
|
|
/// Inject the prompt queue for Claude Code follow-up prompts.
|
|
pub fn with_prompt_queue(
|
|
mut self,
|
|
pq: Arc<
|
|
tokio::sync::Mutex<
|
|
std::collections::HashMap<
|
|
uuid::Uuid,
|
|
std::collections::VecDeque<crate::orchestrator::api::PendingPrompt>,
|
|
>,
|
|
>,
|
|
>,
|
|
) -> Self {
|
|
self.rebuild_state(|s| s.prompt_queue = Some(pq));
|
|
self
|
|
}
|
|
|
|
/// Inject the scheduler for sending follow-up messages to agent jobs.
|
|
pub fn with_scheduler(mut self, slot: crate::tools::builtin::SchedulerSlot) -> Self {
|
|
self.rebuild_state(|s| s.scheduler = Some(slot));
|
|
self
|
|
}
|
|
|
|
/// Inject the skill registry for skill management API.
|
|
pub fn with_skill_registry(mut self, sr: Arc<std::sync::RwLock<SkillRegistry>>) -> Self {
|
|
self.rebuild_state(|s| s.skill_registry = Some(sr));
|
|
self
|
|
}
|
|
|
|
/// Inject the skill catalog for skill search API.
|
|
pub fn with_skill_catalog(mut self, sc: Arc<SkillCatalog>) -> Self {
|
|
self.rebuild_state(|s| s.skill_catalog = Some(sc));
|
|
self
|
|
}
|
|
|
|
/// Inject the LLM provider for OpenAI-compatible API proxy.
|
|
pub fn with_llm_provider(mut self, llm: Arc<dyn crate::llm::LlmProvider>) -> Self {
|
|
self.rebuild_state(|s| s.llm_provider = Some(llm));
|
|
self
|
|
}
|
|
|
|
/// Inject registry catalog entries for the available extensions API.
|
|
pub fn with_registry_entries(mut self, entries: Vec<crate::extensions::RegistryEntry>) -> Self {
|
|
self.rebuild_state(|s| s.registry_entries = entries);
|
|
self
|
|
}
|
|
|
|
/// Inject the cost guard for token/cost tracking in the status popover.
|
|
pub fn with_cost_guard(mut self, cg: Arc<crate::agent::cost_guard::CostGuard>) -> Self {
|
|
self.rebuild_state(|s| s.cost_guard = Some(cg));
|
|
self
|
|
}
|
|
|
|
/// Inject a shared routine engine slot used by other HTTP ingress paths.
|
|
pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self {
|
|
self.rebuild_state(|s| s.routine_engine = slot);
|
|
self
|
|
}
|
|
|
|
/// Inject the active (resolved) configuration snapshot for the status endpoint.
|
|
pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self {
|
|
self.rebuild_state(|s| s.active_config = config);
|
|
self
|
|
}
|
|
|
|
/// Inject the secrets store for admin secret provisioning.
|
|
pub fn with_secrets_store(
|
|
mut self,
|
|
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
|
) -> Self {
|
|
self.rebuild_state(|s| s.secrets_store = Some(store));
|
|
self
|
|
}
|
|
|
|
/// Inject the per-user workspace pool for multi-user mode.
|
|
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
|
|
self.rebuild_state(|s| s.workspace_pool = Some(pool));
|
|
self
|
|
}
|
|
|
|
/// Get the first auth token (for printing to console on startup).
|
|
pub fn auth_token(&self) -> &str {
|
|
self.auth.env_auth.first_token().unwrap_or("")
|
|
}
|
|
|
|
/// Get a reference to the shared gateway state (for the agent to push SSE events).
|
|
pub fn state(&self) -> &Arc<GatewayState> {
|
|
&self.state
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for GatewayChannel {
|
|
fn name(&self) -> &str {
|
|
"gateway"
|
|
}
|
|
|
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
|
let (tx, rx) = mpsc::channel(256);
|
|
*self.state.msg_tx.write().await = Some(tx);
|
|
|
|
let addr: SocketAddr = format!("{}:{}", self.config.host, self.config.port)
|
|
.parse()
|
|
.map_err(|e| ChannelError::StartupFailed {
|
|
name: "gateway".to_string(),
|
|
reason: format!(
|
|
"Invalid address '{}:{}': {}",
|
|
self.config.host, self.config.port, e
|
|
),
|
|
})?;
|
|
|
|
server::start_server(addr, self.state.clone(), self.auth.clone()).await?;
|
|
|
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
|
}
|
|
|
|
async fn respond(
|
|
&self,
|
|
msg: &IncomingMessage,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
let thread_id = match &msg.thread_id {
|
|
Some(tid) => tid.clone(),
|
|
None => {
|
|
tracing::warn!(
|
|
"Gateway respond with no thread_id — skipping (clients would drop it)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
self.state.sse.broadcast_for_user(
|
|
&msg.user_id,
|
|
AppEvent::Response {
|
|
content: response.content,
|
|
thread_id,
|
|
},
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_status(
|
|
&self,
|
|
status: StatusUpdate,
|
|
metadata: &serde_json::Value,
|
|
) -> Result<(), ChannelError> {
|
|
let thread_id = metadata
|
|
.get("thread_id")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from);
|
|
let event = match status {
|
|
StatusUpdate::Thinking(msg) => AppEvent::Thinking {
|
|
message: msg,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted {
|
|
name,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ToolCompleted {
|
|
name,
|
|
success,
|
|
error,
|
|
parameters,
|
|
} => AppEvent::ToolCompleted {
|
|
name,
|
|
success,
|
|
error,
|
|
parameters,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ToolResult { name, preview } => AppEvent::ToolResult {
|
|
name,
|
|
preview,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::StreamChunk(content) => AppEvent::StreamChunk {
|
|
content,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::Status(msg) => AppEvent::Status {
|
|
message: msg,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::JobStarted {
|
|
job_id,
|
|
title,
|
|
browse_url,
|
|
} => AppEvent::JobStarted {
|
|
job_id,
|
|
title,
|
|
browse_url,
|
|
},
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters,
|
|
allow_always,
|
|
} => AppEvent::ApprovalNeeded {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters: serde_json::to_string_pretty(¶meters)
|
|
.unwrap_or_else(|_| parameters.to_string()),
|
|
thread_id,
|
|
allow_always,
|
|
},
|
|
StatusUpdate::AuthRequired {
|
|
extension_name,
|
|
instructions,
|
|
auth_url,
|
|
setup_url,
|
|
} => AppEvent::AuthRequired {
|
|
extension_name,
|
|
instructions,
|
|
auth_url,
|
|
setup_url,
|
|
},
|
|
StatusUpdate::AuthCompleted {
|
|
extension_name,
|
|
success,
|
|
message,
|
|
} => AppEvent::AuthCompleted {
|
|
extension_name,
|
|
success,
|
|
message,
|
|
},
|
|
StatusUpdate::ImageGenerated { data_url, path } => AppEvent::ImageGenerated {
|
|
data_url,
|
|
path,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::Suggestions { suggestions } => AppEvent::Suggestions {
|
|
suggestions,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ReasoningUpdate {
|
|
narrative,
|
|
decisions,
|
|
} => AppEvent::ReasoningUpdate {
|
|
narrative,
|
|
decisions: decisions
|
|
.into_iter()
|
|
.map(|d| crate::channels::web::types::ToolDecisionDto {
|
|
tool_name: d.tool_name,
|
|
rationale: d.rationale,
|
|
})
|
|
.collect(),
|
|
thread_id,
|
|
},
|
|
StatusUpdate::TurnCost {
|
|
input_tokens,
|
|
output_tokens,
|
|
cost_usd,
|
|
} => AppEvent::TurnCost {
|
|
input_tokens,
|
|
output_tokens,
|
|
cost_usd,
|
|
thread_id,
|
|
},
|
|
};
|
|
|
|
// Scope events to the user when user_id is available in metadata.
|
|
// When user_id is missing (heartbeat, routines), events go to all
|
|
// subscribers. In multi-tenant mode this leaks status across users.
|
|
if let Some(uid) = metadata.get("user_id").and_then(|v| v.as_str()) {
|
|
self.state.sse.broadcast_for_user(uid, event);
|
|
} else {
|
|
tracing::debug!("Status event missing user_id in metadata; broadcasting globally");
|
|
self.state.sse.broadcast(event);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn broadcast(
|
|
&self,
|
|
user_id: &str,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
let thread_id = match response.thread_id {
|
|
Some(tid) => tid,
|
|
None => {
|
|
tracing::warn!(
|
|
"Gateway broadcast with no thread_id — skipping (clients would drop it)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
};
|
|
self.state.sse.broadcast_for_user(
|
|
user_id,
|
|
AppEvent::Response {
|
|
content: response.content,
|
|
thread_id,
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
|
if self.state.msg_tx.read().await.is_some() {
|
|
Ok(())
|
|
} else {
|
|
Err(ChannelError::HealthCheckFailed {
|
|
name: "gateway".to_string(),
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
|
if let Some(tx) = self.state.shutdown_tx.write().await.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
*self.state.msg_tx.write().await = None;
|
|
Ok(())
|
|
}
|
|
}
|