1use std::collections::{HashMap, HashSet};
32use std::env;
33use std::fs;
34use std::path::Path as StdPath;
35use std::sync::{Arc, RwLock};
36use std::time::{SystemTime, UNIX_EPOCH};
37
38use axum::extract::{Path, State};
39use axum::response::{IntoResponse, Response as AxumResponse};
40use axum::Json;
41use embacle::config::{CliRunnerType, RunnerConfig};
42use embacle::discovery::resolve_binary;
43use embacle::factory::create_runner_with_config;
44use embacle::types::{ChatMessage, ChatRequest, LlmProvider, RunnerError};
45use serde::{Deserialize, Serialize};
46use serde_json::{Map, Value};
47use uuid::Uuid;
48
49use super::error::{ErrorType, UhpFailure};
50use super::files;
51use super::harnesses;
52use super::state::UhpState;
53
54const RESERVED: [&str; 2] = ["tools", "include"];
56
57#[derive(Debug, Clone, Deserialize)]
59pub struct CreateResponse {
60 pub input: Value,
62 #[serde(default)]
64 pub model: Option<String>,
65 #[serde(default)]
67 pub metadata: Option<Map<String, Value>>,
68 #[serde(default)]
70 pub stream: bool,
71 #[serde(default)]
73 pub background: bool,
74 #[serde(default)]
76 pub previous_response_id: Option<String>,
77 #[serde(default)]
79 pub instructions: Option<String>,
80 #[serde(flatten)]
83 pub rest: Map<String, Value>,
84}
85
86impl CreateResponse {
87 pub fn prompt(&self) -> String {
89 match &self.input {
90 Value::String(s) => s.clone(),
91 Value::Array(items) => items
92 .iter()
93 .filter_map(|item| {
94 item.get("content").map(|c| match c {
95 Value::String(s) => s.clone(),
96 other => other
97 .as_array()
98 .map(|parts| {
99 parts
100 .iter()
101 .filter_map(|p| p.get("text").and_then(Value::as_str))
102 .collect::<Vec<_>>()
103 .join("")
104 })
105 .unwrap_or_default(),
106 })
107 })
108 .collect::<Vec<_>>()
109 .join("\n"),
110 other => other.as_str().unwrap_or_default().to_owned(),
111 }
112 }
113
114 pub fn ignored_fields(&self) -> Vec<String> {
120 RESERVED
121 .iter()
122 .filter(|f| self.rest.contains_key(**f))
123 .map(|f| (*f).to_owned())
124 .collect()
125 }
126
127 pub fn harness_id(&self) -> Option<&str> {
129 self.metadata
130 .as_ref()?
131 .get("harness_id")
132 .and_then(Value::as_str)
133 }
134}
135
136#[derive(Debug, Clone, Serialize)]
138pub struct OutputItem {
139 #[serde(rename = "type")]
141 pub kind: &'static str,
142 pub id: String,
144 pub status: &'static str,
146 pub role: &'static str,
148 pub content: Vec<ContentPart>,
150}
151
152#[derive(Debug, Clone, Serialize)]
154pub struct ContentPart {
155 #[serde(rename = "type")]
157 pub kind: &'static str,
158 pub text: String,
160}
161
162#[derive(Debug, Clone, Copy, Serialize)]
164pub struct Usage {
165 pub input_tokens: u32,
167 pub output_tokens: u32,
169 pub total_tokens: u32,
171}
172
173#[derive(Debug, Clone, Serialize)]
175pub struct Response {
176 pub id: String,
178 pub object: &'static str,
180 pub created_at: u64,
182 pub status: &'static str,
184 pub error: Option<super::error::UhpError>,
186 pub previous_response_id: Option<String>,
188 pub model: String,
190 pub output: Vec<OutputItem>,
192 pub store: bool,
194 pub usage: Option<Usage>,
196 pub metadata: Map<String, Value>,
198}
199
200#[derive(Debug, Default)]
205pub struct ResponseStore {
206 responses: RwLock<HashMap<String, Response>>,
207 sessions: RwLock<HashMap<String, Vec<String>>>,
208 cancelled: RwLock<HashSet<String>>,
209}
210
211impl ResponseStore {
212 pub fn put(&self, session_id: &str, response: Response) {
214 if let Ok(mut sessions) = self.sessions.write() {
215 sessions
216 .entry(session_id.to_owned())
217 .or_default()
218 .push(response.id.clone());
219 }
220 if let Ok(mut responses) = self.responses.write() {
221 responses.insert(response.id.clone(), response);
222 }
223 }
224
225 pub fn get(&self, id: &str) -> Option<Response> {
227 self.responses.read().ok()?.get(id).cloned()
228 }
229
230 pub fn session_ids(&self) -> Vec<String> {
232 self.sessions
233 .read()
234 .map(|s| s.keys().cloned().collect())
235 .unwrap_or_default()
236 }
237
238 pub fn session_turns(&self, session_id: &str) -> Option<Vec<Response>> {
240 let ids = self.sessions.read().ok()?.get(session_id)?.clone();
241 let responses = self.responses.read().ok()?;
242 Some(
243 ids.iter()
244 .filter_map(|i| responses.get(i).cloned())
245 .collect(),
246 )
247 }
248
249 pub fn cancel(&self, id: &str) -> bool {
256 let running = self.get(id).is_some_and(|r| r.status == "in_progress");
257 if running {
258 if let Ok(mut cancelled) = self.cancelled.write() {
259 cancelled.insert(id.to_owned());
260 }
261 if let Ok(mut responses) = self.responses.write() {
262 if let Some(r) = responses.get_mut(id) {
263 r.status = "cancelled";
264 }
265 }
266 }
267 running
268 }
269
270 pub fn is_cancelled(&self, id: &str) -> bool {
272 self.cancelled.read().is_ok_and(|c| c.contains(id))
273 }
274
275 pub fn settle(&self, response: Response) {
280 if self.is_cancelled(&response.id) {
281 return;
282 }
283 if let Ok(mut responses) = self.responses.write() {
284 responses.insert(response.id.clone(), response);
285 }
286 }
287
288 pub fn running_in(&self, session_id: &str) -> Vec<String> {
290 self.session_turns(session_id)
291 .unwrap_or_default()
292 .into_iter()
293 .filter(|r| r.status == "in_progress")
294 .map(|r| r.id)
295 .collect()
296 }
297
298 pub fn delete_session(&self, session_id: &str) -> bool {
303 let ids = self
304 .sessions
305 .write()
306 .map_or(None, |mut sessions| sessions.remove(session_id));
307 let Some(ids) = ids else { return false };
308 if let Ok(mut responses) = self.responses.write() {
309 for id in &ids {
310 responses.remove(id);
311 }
312 }
313 true
314 }
315
316 pub fn session_of(&self, response_id: &str) -> Option<String> {
318 self.get(response_id)?
319 .metadata
320 .get("session_id")
321 .and_then(Value::as_str)
322 .map(str::to_owned)
323 }
324}
325
326fn now_secs() -> u64 {
328 SystemTime::now()
329 .duration_since(UNIX_EPOCH)
330 .map(|d| d.as_secs())
331 .unwrap_or_default()
332}
333
334fn id_with(prefix: &str) -> String {
336 format!("{prefix}{}", Uuid::new_v4().simple())
337}
338
339pub async fn create(
346 State(state): State<UhpState>,
347 Json(request): Json<CreateResponse>,
348) -> Result<AxumResponse, UhpFailure> {
349 if request.background {
350 return Ok(Json(start_background(state, request)).into_response());
351 }
352 if request.stream {
353 let prepared = prepare(&state, &request).await?;
354 let previous = request.previous_response_id.clone();
355 return Ok(super::streaming::sse(state, prepared, previous).into_response());
356 }
357 let outcome = run(&state, &request).await?;
358 Ok(Json(outcome.response).into_response())
359}
360
361fn start_background(state: UhpState, request: CreateResponse) -> Response {
372 let id = id_with("resp_");
373 let session_id = request
374 .previous_response_id
375 .as_ref()
376 .and_then(|prev| state.store.session_of(prev))
377 .unwrap_or_else(|| id_with("sess_"));
378
379 let mut metadata = request.metadata.clone().unwrap_or_default();
380 metadata.insert("session_id".to_owned(), Value::String(session_id.clone()));
381
382 let pending = Response {
383 id: id.clone(),
384 object: "response",
385 created_at: now_secs(),
386 status: "in_progress",
387 error: None,
388 previous_response_id: request.previous_response_id.clone(),
389 model: request.model.clone().unwrap_or_default(),
390 output: Vec::new(),
391 store: true,
392 usage: None,
393 metadata,
394 };
395 state.store.put(&session_id, pending.clone());
396
397 let spawned = pending.clone();
398 tokio::spawn(async move {
399 let finished = match run(&state, &request).await {
400 Ok(outcome) => {
401 let mut r = outcome.response;
404 r.id = id;
405 r
406 }
407 Err(failure) => {
408 let mut r = spawned;
409 r.status = "failed";
410 r.error = Some(failure.into_error());
411 r
412 }
413 };
414 state.store.settle(finished);
415 });
416
417 pending
418}
419
420pub async fn cancel(
426 State(state): State<UhpState>,
427 Path(id): Path<String>,
428) -> Result<Json<Response>, UhpFailure> {
429 if state.store.get(&id).is_none() {
430 return Err(UhpFailure::not_found(
431 "response_not_found",
432 "no response with that id exists",
433 ));
434 }
435 state.store.cancel(&id);
436 state.store.get(&id).map(Json).ok_or_else(|| {
437 UhpFailure::not_found("response_not_found", "no response with that id exists")
438 })
439}
440
441pub struct Outcome {
443 pub response: Response,
445}
446
447pub struct Prepared {
449 pub runner: Arc<dyn LlmProvider>,
451 pub chat: ChatRequest,
453 pub model: String,
455 pub session_id: String,
457 pub id: String,
459 pub metadata: Map<String, Value>,
461 pub requested_model: Option<String>,
463}
464
465async fn runner_in(
471 provider: CliRunnerType,
472 workdir: &StdPath,
473) -> Result<Arc<dyn LlmProvider>, RunnerError> {
474 fs::create_dir_all(workdir)
475 .map_err(|e| RunnerError::internal(format!("session working folder: {e}")))?;
476
477 let env_override = env::var(provider.env_override_key()).ok();
478 let binary = resolve_binary(provider.binary_name(), env_override.as_deref())?;
479 let mut config = RunnerConfig::new(binary).with_working_directory(workdir.to_path_buf());
480 config.extra_args = write_permission_args(provider);
481
482 Ok(Arc::from(
483 create_runner_with_config(provider, config).await?,
484 ))
485}
486
487fn write_permission_args(provider: CliRunnerType) -> Vec<String> {
508 match provider {
509 CliRunnerType::ClaudeCode => vec![
510 "--permission-mode".to_owned(),
511 "acceptEdits".to_owned(),
512 "--allowed-tools".to_owned(),
513 "Write Edit".to_owned(),
514 ],
515 _ => Vec::new(),
516 }
517}
518
519pub async fn prepare(state: &UhpState, request: &CreateResponse) -> Result<Prepared, UhpFailure> {
528 let harnesses = harnesses::discover(&state.shared).await;
529 let chosen = match request.harness_id() {
530 Some(id) => harnesses
531 .iter()
532 .find(|d| d.harness.id == id)
533 .ok_or_else(|| {
534 UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
535 })?,
536 None => harnesses.first().ok_or_else(|| {
537 UhpFailure::new(
538 ErrorType::InvalidRequestError,
539 "harness_not_found",
540 "this server has no installed harness to run",
541 )
542 })?,
543 };
544
545 let session_id = request
551 .previous_response_id
552 .as_ref()
553 .and_then(|prev| state.store.session_of(prev))
554 .unwrap_or_else(|| id_with("sess_"));
555 let workdir = files::session_workdir(&session_id);
556 let runner = runner_in(chosen.provider, &workdir).await.map_err(|_| {
557 UhpFailure::new(
558 ErrorType::HarnessError,
559 "harness_unavailable",
560 "the harness could not be started",
561 )
562 })?;
563
564 let requested_model = request.model.clone();
565 let model = requested_model
566 .clone()
567 .or_else(|| chosen.harness.default_model.clone())
568 .unwrap_or_else(|| runner.default_model().to_owned());
569
570 let mut chat = ChatRequest::new(vec![ChatMessage::user(request.prompt())]);
571 chat.model = Some(model.clone());
572
573 let mut metadata = request.metadata.clone().unwrap_or_default();
574 metadata.insert("session_id".to_owned(), Value::String(session_id.clone()));
575 let ignored = request.ignored_fields();
576 if !ignored.is_empty() {
577 metadata.insert(
578 "ignored_fields".to_owned(),
579 Value::Array(ignored.into_iter().map(Value::String).collect()),
580 );
581 }
582
583 Ok(Prepared {
584 runner,
585 chat,
586 model,
587 session_id,
588 id: id_with("resp_"),
589 metadata,
590 requested_model,
591 })
592}
593
594#[must_use]
596pub fn pending_response(p: &Prepared, previous: Option<String>) -> Response {
597 Response {
598 id: p.id.clone(),
599 object: "response",
600 created_at: now_secs(),
601 status: "in_progress",
602 error: None,
603 previous_response_id: previous,
604 model: p.model.clone(),
605 output: Vec::new(),
606 store: true,
607 usage: None,
608 metadata: p.metadata.clone(),
609 }
610}
611
612#[must_use]
614pub fn completed_response(
615 p: Prepared,
616 previous: Option<String>,
617 text: String,
618 usage: Option<Usage>,
619 ran_model: String,
620) -> Response {
621 let mut metadata = p.metadata;
622 if p.requested_model.as_ref().is_some_and(|m| *m != ran_model) {
623 metadata.insert(
624 "requested_model".to_owned(),
625 Value::String(p.requested_model.unwrap_or_default()),
626 );
627 metadata.insert("model_fallback".to_owned(), Value::Bool(true));
628 }
629 Response {
630 id: p.id,
631 object: "response",
632 created_at: now_secs(),
633 status: "completed",
634 error: None,
635 previous_response_id: previous,
636 model: ran_model,
637 output: vec![OutputItem {
638 kind: "message",
639 id: id_with("msg_"),
640 status: "completed",
641 role: "assistant",
642 content: vec![ContentPart {
643 kind: "output_text",
644 text,
645 }],
646 }],
647 store: true,
648 usage,
649 metadata,
650 }
651}
652
653pub async fn run(state: &UhpState, request: &CreateResponse) -> Result<Outcome, UhpFailure> {
659 let prepared = prepare(state, request).await?;
660 let session_id = prepared.session_id.clone();
661 let previous = request.previous_response_id.clone();
662 let model = prepared.model.clone();
663
664 let response = match prepared.runner.complete(&prepared.chat).await {
665 Ok(reply) => {
666 let ran = if reply.model.is_empty() {
667 model
668 } else {
669 reply.model
670 };
671 let usage = reply.usage.map(|u| Usage {
674 input_tokens: u.prompt_tokens,
675 output_tokens: u.completion_tokens,
676 total_tokens: u.total_tokens,
677 });
678 completed_response(prepared, previous, reply.content, usage, ran)
679 }
680 Err(e) => {
681 let mut failed = pending_response(&prepared, previous);
682 failed.status = "failed";
683 failed.error = Some(super::error::UhpError {
684 kind: ErrorType::HarnessError,
685 code: "harness_error".to_owned(),
686 message: e.message,
687 detail: None,
688 });
689 failed
690 }
691 };
692
693 state.store.put(&session_id, response.clone());
694 Ok(Outcome { response })
695}
696
697pub async fn get(
703 State(state): State<UhpState>,
704 Path(id): Path<String>,
705) -> Result<Json<Response>, UhpFailure> {
706 state.store.get(&id).map(Json).ok_or_else(|| {
707 UhpFailure::not_found("response_not_found", "no response with that id exists")
708 })
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use serde_json::json;
715
716 fn parse(body: Value) -> CreateResponse {
717 serde_json::from_value(body).expect("request parses") }
719
720 #[test]
721 fn a_bare_string_input_is_one_user_message() {
722 assert_eq!(parse(json!({"input": "hello"})).prompt(), "hello");
723 }
724
725 #[test]
726 fn an_item_array_input_is_flattened_to_its_text() {
727 let r = parse(json!({"input": [
728 {"role": "user", "content": [{"type": "input_text", "text": "first"}]},
729 {"role": "user", "content": "second"}
730 ]}));
731 assert_eq!(r.prompt(), "first\nsecond");
732 }
733
734 #[test]
735 fn ignored_fields_reports_only_what_the_request_carried() {
736 let both = parse(json!({"input": "x", "tools": [], "include": []}));
739 assert_eq!(both.ignored_fields(), vec!["tools", "include"]);
740
741 let one = parse(json!({"input": "x", "include": []}));
742 assert_eq!(one.ignored_fields(), vec!["include"]);
743
744 let neither = parse(json!({"input": "x"}));
745 assert!(
746 neither.ignored_fields().is_empty(),
747 "a request that sent no reserved field must not be told one was ignored"
748 );
749 }
750
751 #[test]
752 fn a_reserved_field_does_not_make_the_request_unparseable() {
753 let r = parse(json!({
756 "input": "x",
757 "tools": [{"type": "function", "name": "probe"}],
758 "metadata": {"harness_id": "chrn_copilot"}
759 }));
760 assert_eq!(r.harness_id(), Some("chrn_copilot"));
761 assert_eq!(r.prompt(), "x");
762 }
763
764 #[test]
765 fn ids_carry_the_prefix_the_object_model_requires() {
766 assert!(id_with("resp_").starts_with("resp_"));
767 assert!(id_with("sess_").starts_with("sess_"));
768 }
769
770 #[test]
771 fn a_terminal_response_reads_back_unchanged() {
772 let store = ResponseStore::default();
773 let resp = Response {
774 id: "resp_abc".to_owned(),
775 object: "response",
776 created_at: 1,
777 status: "completed",
778 error: None,
779 previous_response_id: None,
780 model: "m".to_owned(),
781 output: Vec::new(),
782 store: true,
783 usage: None,
784 metadata: Map::new(),
785 };
786 store.put("sess_1", resp);
787
788 let got = store.get("resp_abc").expect("stored"); assert_eq!(got.status, "completed", "a terminal state must not change");
790 assert_eq!(store.session_turns("sess_1").map(|t| t.len()), Some(1));
791 assert!(store.get("resp_missing").is_none());
792 }
793}