1use std::{convert::Infallible, sync::Arc, time::Duration};
4
5use async_trait::async_trait;
6use axum::{
7 Json, Router,
8 extract::{
9 DefaultBodyLimit, Path, Query, Request, State,
10 rejection::JsonRejection,
11 ws::{Message, WebSocket, WebSocketUpgrade},
12 },
13 http::{HeaderMap, StatusCode, header},
14 middleware::{self, Next},
15 response::{
16 IntoResponse, Response, Sse,
17 sse::{Event, KeepAlive},
18 },
19 routing::{get, post},
20};
21use futures_util::{SinkExt, Stream, StreamExt};
22use runtime_api_contract::{
23 AgentTask, AgentTaskEvent, AgentTaskEventPage, AgentTaskWebSocketClientMessage,
24 AgentTaskWebSocketServerMessage, ApiErrorBody, ClaudeContentBlock, ClaudeError,
25 ClaudeErrorEnvelope, ClaudeMessage, ClaudeMessageRequest, ClaudeUsage, CreateAgentTaskRequest,
26 CreateExecutionRequest, ExecutionOptions, ExecutionState, ModelGenerationOptions,
27 ModelToolChoice, OpenAiError, OpenAiErrorEnvelope, OpenAiInputTokensDetails,
28 OpenAiOutputContent, OpenAiOutputItem, OpenAiOutputTokensDetails, OpenAiResponse,
29 OpenAiResponseError, OpenAiResponseRequest, OpenAiUsage, RuntimeInput, RuntimeMessage,
30 RuntimeMessageRole, SubmitInputRequest, openapi_document,
31};
32use runtime_link_api::{LinkError, RuntimeLink};
33use runtime_types::{
34 CallerScope, CredentialHandle, ExecutionId, RequestAuthority, RuntimeInstanceId,
35};
36use serde::Deserialize;
37use serde_json::json;
38use sha2::{Digest, Sha256};
39
40#[derive(Debug)]
41pub struct AuthError {
42 pub message: String,
43}
44
45#[async_trait]
46pub trait IngressAuthenticator: Send + Sync + 'static {
47 async fn authenticate(&self, headers: &HeaderMap) -> Result<RequestAuthority, AuthError>;
48}
49
50pub struct StaticTokenAuthenticator {
53 expected_token: String,
54 caller: CallerScope,
55}
56
57impl StaticTokenAuthenticator {
58 pub fn new(expected_token: impl Into<String>, caller: CallerScope) -> Result<Self, AuthError> {
59 let expected_token = expected_token.into();
60 if expected_token.is_empty() {
61 return Err(AuthError {
62 message: "static bearer token must not be empty".into(),
63 });
64 }
65 caller.validate().map_err(|error| AuthError {
66 message: error.to_string(),
67 })?;
68 Ok(Self {
69 expected_token,
70 caller,
71 })
72 }
73}
74
75#[async_trait]
76impl IngressAuthenticator for StaticTokenAuthenticator {
77 async fn authenticate(&self, headers: &HeaderMap) -> Result<RequestAuthority, AuthError> {
78 let bearer = headers
79 .get(header::AUTHORIZATION)
80 .and_then(|value| value.to_str().ok())
81 .and_then(|value| value.strip_prefix("Bearer "));
82 let api_key = headers
83 .get("x-api-key")
84 .and_then(|value| value.to_str().ok());
85 if bearer.is_some() && api_key.is_some() && bearer != api_key {
86 return Err(AuthError {
87 message: "Authorization and x-api-key credentials disagree".into(),
88 });
89 }
90 let value = bearer
91 .or(api_key)
92 .filter(|value| !value.is_empty())
93 .ok_or_else(|| AuthError {
94 message: "missing Authorization bearer or x-api-key credential".into(),
95 })?;
96 if value != self.expected_token {
97 return Err(AuthError {
98 message: "invalid token credential".into(),
99 });
100 }
101 Ok(RequestAuthority {
102 caller: self.caller.clone(),
103 credential: CredentialHandle::new(value).map_err(|error| AuthError {
104 message: error.to_string(),
105 })?,
106 })
107 }
108}
109
110pub struct TrustedGatewayAuthenticator {
115 shared_secret_digest: [u8; 32],
116}
117
118impl TrustedGatewayAuthenticator {
119 pub fn new(shared_secret: &str) -> Result<Self, AuthError> {
120 if shared_secret.len() < 32 || shared_secret.len() > 4096 {
121 return Err(AuthError {
122 message: "trusted gateway secret must contain 32..=4096 bytes".into(),
123 });
124 }
125 Ok(Self {
126 shared_secret_digest: Sha256::digest(shared_secret.as_bytes()).into(),
127 })
128 }
129}
130
131#[async_trait]
132impl IngressAuthenticator for TrustedGatewayAuthenticator {
133 async fn authenticate(&self, headers: &HeaderMap) -> Result<RequestAuthority, AuthError> {
134 fn required(headers: &HeaderMap, name: &'static str) -> Result<String, AuthError> {
135 headers
136 .get(name)
137 .and_then(|value| value.to_str().ok())
138 .filter(|value| !value.is_empty())
139 .map(ToOwned::to_owned)
140 .ok_or_else(|| AuthError {
141 message: format!("trusted gateway header {name} is missing"),
142 })
143 }
144 let supplied_secret = required(headers, "x-runtime-gateway-secret")?;
145 let supplied_digest: [u8; 32] = Sha256::digest(supplied_secret.as_bytes()).into();
146 let matches = self
147 .shared_secret_digest
148 .iter()
149 .zip(supplied_digest)
150 .fold(0_u8, |difference, (expected, actual)| {
151 difference | (*expected ^ actual)
152 })
153 == 0;
154 if !matches {
155 return Err(AuthError {
156 message: "trusted gateway authentication failed".into(),
157 });
158 }
159 let token = headers
160 .get(header::AUTHORIZATION)
161 .and_then(|value| value.to_str().ok())
162 .and_then(|value| value.strip_prefix("Bearer "))
163 .ok_or_else(|| AuthError {
164 message: "trusted gateway bearer credential is missing".into(),
165 })?;
166 let caller = CallerScope {
167 subject: required(headers, "x-runtime-subject")?,
168 tenant_id: required(headers, "x-runtime-tenant-id")?,
169 project_id: required(headers, "x-runtime-project-id")?,
170 capabilities: headers
171 .get("x-runtime-capabilities")
172 .and_then(|value| value.to_str().ok())
173 .unwrap_or_default()
174 .split(',')
175 .map(str::trim)
176 .filter(|value| !value.is_empty())
177 .map(ToOwned::to_owned)
178 .collect(),
179 };
180 caller.validate().map_err(|error| AuthError {
181 message: error.to_string(),
182 })?;
183 Ok(RequestAuthority {
184 caller,
185 credential: CredentialHandle::new(token).map_err(|error| AuthError {
186 message: error.to_string(),
187 })?,
188 })
189 }
190}
191
192#[async_trait]
193pub trait ReadinessProbe: Send + Sync + 'static {
194 async fn ready(&self) -> Result<(), String>;
195}
196
197pub struct AlwaysReady;
198#[async_trait]
199impl ReadinessProbe for AlwaysReady {
200 async fn ready(&self) -> Result<(), String> {
201 Ok(())
202 }
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub struct RuntimeTransportConfig {
212 pub agent_tasks: bool,
213 pub openai_responses: bool,
214 pub claude_messages: bool,
215 pub sse: bool,
216 pub websocket: bool,
217 pub websocket_max_message_bytes: usize,
218}
219
220#[derive(Clone)]
221struct AppState {
222 link: Arc<dyn RuntimeLink>,
223 authenticator: Arc<dyn IngressAuthenticator>,
224 readiness: Arc<dyn ReadinessProbe>,
225 transport: RuntimeTransportConfig,
226}
227
228pub fn router(
236 link: Arc<dyn RuntimeLink>,
237 authenticator: Arc<dyn IngressAuthenticator>,
238 readiness: Arc<dyn ReadinessProbe>,
239 transport: RuntimeTransportConfig,
240) -> Router {
241 let state = AppState {
242 link,
243 authenticator,
244 readiness,
245 transport,
246 };
247 let mut router = Router::new()
248 .route("/livez", get(live))
249 .route("/readyz", get(ready))
250 .route("/openapi.json", get(openapi));
251 if transport.agent_tasks {
252 router = router
253 .route("/v1/agent/tasks", post(create_agent_task))
256 .route("/v1/agent/tasks/{task_id}", get(get_agent_task))
257 .route(
258 "/v1/agent/tasks/{task_id}/events",
259 get(get_agent_task_events),
260 )
261 .route(
262 "/v1/agent/tasks/{task_id}/inputs",
263 post(submit_agent_task_input),
264 )
265 .route("/v1/agent/tasks/{task_id}/cancel", post(cancel_agent_task));
266 }
267 if transport.openai_responses {
268 router = router
269 .route("/v1/responses", post(create_openai_response))
270 .route("/v1/responses/{response_id}", get(get_openai_response))
271 .route(
272 "/v1/responses/{response_id}/cancel",
273 post(cancel_openai_response),
274 );
275 }
276 if transport.claude_messages {
277 router = router.route("/v1/messages", post(create_claude_message));
278 }
279 if transport.agent_tasks && transport.sse {
280 router = router.route(
281 "/v1/agent/tasks/{task_id}/stream",
282 get(stream_agent_task_events),
283 );
284 }
285 if transport.agent_tasks && transport.websocket {
286 let max_message_bytes = transport.websocket_max_message_bytes;
287 router = router.route(
288 "/v1/agent/tasks/{task_id}/ws",
289 get(
290 move |upgrade: WebSocketUpgrade,
291 state: State<AppState>,
292 headers: HeaderMap,
293 path: Path<String>,
294 query: Query<EventQuery>| {
295 websocket_agent_task(
296 upgrade.max_message_size(max_message_bytes),
297 state,
298 headers,
299 path,
300 query,
301 )
302 },
303 ),
304 );
305 }
306 router
307 .layer(DefaultBodyLimit::max(1024 * 1024))
308 .layer(middleware::from_fn(request_id_middleware))
309 .with_state(state)
310}
311
312async fn request_id_middleware(mut request: Request, next: Next) -> Response {
315 let request_id = request
316 .headers()
317 .get("x-request-id")
318 .and_then(|value| value.to_str().ok())
319 .filter(|value| {
320 !value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control)
321 })
322 .map(ToOwned::to_owned)
323 .unwrap_or_else(|| format!("req_{}", uuid::Uuid::new_v4()));
324 if let Ok(value) = request_id.parse() {
325 request.headers_mut().insert("x-request-id", value);
326 }
327 let mut response = next.run(request).await;
328 if let Ok(value) = request_id.parse() {
329 response.headers_mut().insert("x-request-id", value);
330 }
331 if let Ok(value) = request_id.parse() {
332 response.headers_mut().insert("request-id", value);
333 }
334 response
335}
336
337async fn live() -> Json<serde_json::Value> {
338 Json(json!({"status":"ok","service":"agent-runtime"}))
339}
340
341async fn openapi(State(state): State<AppState>) -> Json<serde_json::Value> {
342 let mut document = openapi_document();
343 let paths = document["paths"]
344 .as_object_mut()
345 .expect("embedded OpenAPI paths must be an object");
346 if !state.transport.agent_tasks {
347 for path in [
348 "/v1/agent/tasks",
349 "/v1/agent/tasks/{taskId}",
350 "/v1/agent/tasks/{taskId}/events",
351 "/v1/agent/tasks/{taskId}/inputs",
352 "/v1/agent/tasks/{taskId}/cancel",
353 "/v1/agent/tasks/{taskId}/stream",
354 "/v1/agent/tasks/{taskId}/ws",
355 ] {
356 paths.remove(path);
357 }
358 }
359 if !state.transport.openai_responses {
360 for path in [
361 "/v1/responses",
362 "/v1/responses/{responseId}",
363 "/v1/responses/{responseId}/cancel",
364 ] {
365 paths.remove(path);
366 }
367 }
368 if !state.transport.claude_messages {
369 paths.remove("/v1/messages");
370 }
371 if !state.transport.sse {
372 paths.remove("/v1/agent/tasks/{taskId}/stream");
373 }
374 if !state.transport.websocket {
375 paths.remove("/v1/agent/tasks/{taskId}/ws");
376 }
377 Json(document)
378}
379
380async fn ready(State(state): State<AppState>) -> Response {
381 match state.readiness.ready().await {
382 Ok(()) => Json(json!({"status":"ready"})).into_response(),
383 Err(message) => (
384 StatusCode::SERVICE_UNAVAILABLE,
385 Json(json!({"status":"not_ready","message":message})),
386 )
387 .into_response(),
388 }
389}
390
391async fn create_agent_task(
392 State(state): State<AppState>,
393 headers: HeaderMap,
394 payload: Result<Json<CreateAgentTaskRequest>, JsonRejection>,
395) -> Response {
396 let authority = match authenticate(&state, &headers).await {
397 Ok(value) => value,
398 Err(response) => return response,
399 };
400 let Json(mut request) = match payload {
401 Ok(value) => value,
402 Err(error) => {
403 return api_error(
404 StatusCode::BAD_REQUEST,
405 "TASK_REQUEST_INVALID",
406 &error.body_text(),
407 );
408 }
409 };
410 attach_request_id_metadata(&headers, &mut request.metadata);
411 let key = idempotency_key_or_random(&headers, "task");
412 match state
413 .link
414 .create_execution(&authority, &key, request.into_execution())
415 .await
416 {
417 Ok(value) => {
418 let status = if value.replayed {
419 StatusCode::OK
420 } else {
421 StatusCode::ACCEPTED
422 };
423 let task = AgentTask::from(value.execution);
424 let mut response = (status, Json(task.clone())).into_response();
425 if let Ok(location) = task.links.self_url.parse() {
426 response.headers_mut().insert(header::LOCATION, location);
427 }
428 response
429 .headers_mut()
430 .insert(header::RETRY_AFTER, "1".parse().expect("static header"));
431 response
432 }
433 Err(error) => link_error(error),
434 }
435}
436
437async fn get_agent_task(
438 State(state): State<AppState>,
439 headers: HeaderMap,
440 Path(id): Path<String>,
441) -> Response {
442 let authority = match authenticate(&state, &headers).await {
443 Ok(value) => value,
444 Err(response) => return response,
445 };
446 let id = match parse_id(id) {
447 Ok(value) => value,
448 Err(error) => return invalid_task_id(error),
449 };
450 match state.link.execution(&authority, &id).await {
451 Ok(value) => Json(AgentTask::from(value)).into_response(),
452 Err(error) => link_error(error),
453 }
454}
455
456async fn get_agent_task_events(
457 State(state): State<AppState>,
458 headers: HeaderMap,
459 Path(id): Path<String>,
460 Query(query): Query<EventQuery>,
461) -> Response {
462 let authority = match authenticate(&state, &headers).await {
463 Ok(value) => value,
464 Err(response) => return response,
465 };
466 let id = match parse_id(id) {
467 Ok(value) => value,
468 Err(error) => return invalid_task_id(error),
469 };
470 let limit = query.limit.unwrap_or(100);
471 if !(1..=1000).contains(&limit) {
472 return api_error(
473 StatusCode::BAD_REQUEST,
474 "TASK_EVENT_LIMIT_INVALID",
475 "limit must be within 1..=1000",
476 );
477 }
478 match state.link.events(&authority, &id, query.after, limit).await {
479 Ok(page) => Json(AgentTaskEventPage {
480 items: page.items.into_iter().map(AgentTaskEvent::from).collect(),
481 next_after: page.next_after,
482 })
483 .into_response(),
484 Err(error) => link_error(error),
485 }
486}
487
488async fn stream_agent_task_events(
489 State(state): State<AppState>,
490 headers: HeaderMap,
491 Path(id): Path<String>,
492 Query(query): Query<EventQuery>,
493) -> Response {
494 let authority = match authenticate(&state, &headers).await {
495 Ok(value) => value,
496 Err(response) => return response,
497 };
498 let id = match parse_id(id) {
499 Ok(value) => value,
500 Err(error) => return invalid_task_id(error),
501 };
502 let after = match sse_resume_after(&headers, query.after) {
503 Ok(value) => value,
504 Err(message) => {
505 return api_error(StatusCode::BAD_REQUEST, "SSE_CURSOR_INVALID", &message);
506 }
507 };
508 if let Err(error) = state.link.execution(&authority, &id).await {
509 return link_error(error);
510 }
511 let stream = agent_task_event_stream(state.link, authority, id, after);
512 Sse::new(stream)
513 .keep_alive(
514 KeepAlive::new()
515 .interval(Duration::from_secs(15))
516 .text("heartbeat"),
517 )
518 .into_response()
519}
520
521async fn submit_agent_task_input(
522 State(state): State<AppState>,
523 headers: HeaderMap,
524 Path(id): Path<String>,
525 Json(request): Json<SubmitInputRequest>,
526) -> Response {
527 let authority = match authenticate(&state, &headers).await {
528 Ok(value) => value,
529 Err(response) => return response,
530 };
531 let id = match parse_id(id) {
532 Ok(value) => value,
533 Err(error) => return invalid_task_id(error),
534 };
535 match state.link.submit_input(&authority, &id, request).await {
536 Ok(value) => Json(AgentTask::from(value)).into_response(),
537 Err(error) => link_error(error),
538 }
539}
540
541async fn cancel_agent_task(
542 State(state): State<AppState>,
543 headers: HeaderMap,
544 Path(id): Path<String>,
545) -> Response {
546 let authority = match authenticate(&state, &headers).await {
547 Ok(value) => value,
548 Err(response) => return response,
549 };
550 let id = match parse_id(id) {
551 Ok(value) => value,
552 Err(error) => return invalid_task_id(error),
553 };
554 match state.link.cancel(&authority, &id).await {
555 Ok(value) => Json(AgentTask::from(value)).into_response(),
556 Err(error) => link_error(error),
557 }
558}
559
560async fn create_openai_response(
561 State(state): State<AppState>,
562 headers: HeaderMap,
563 payload: Result<Json<OpenAiResponseRequest>, JsonRejection>,
564) -> Response {
565 let authority = match authenticate(&state, &headers).await {
566 Ok(value) => value,
567 Err(_) => {
568 return openai_error(
569 StatusCode::UNAUTHORIZED,
570 "invalid_api_key",
571 "authentication failed",
572 None,
573 );
574 }
575 };
576 let Json(mut request) = match payload {
577 Ok(value) => value,
578 Err(error) => {
579 return openai_error(
580 StatusCode::BAD_REQUEST,
581 "invalid_json",
582 &error.body_text(),
583 None,
584 );
585 }
586 };
587 attach_request_id_metadata(&headers, &mut request.metadata);
588 if request.stream && !state.transport.sse {
589 return openai_error(
590 StatusCode::BAD_REQUEST,
591 "streaming_disabled",
592 "stream=true is disabled for this Runtime listener",
593 Some("stream"),
594 );
595 }
596 let execution_request = match openai_execution_request(&request) {
597 Ok(value) => value,
598 Err((param, message)) => {
599 return openai_error(
600 StatusCode::BAD_REQUEST,
601 "invalid_request_error",
602 &message,
603 param.as_deref(),
604 );
605 }
606 };
607 let key = idempotency_key_or_random(&headers, "openai-response");
608 let created = match state
609 .link
610 .create_execution(&authority, &key, execution_request)
611 .await
612 {
613 Ok(value) => value.execution,
614 Err(error) => return openai_link_error(error),
615 };
616 if request.stream {
617 let stream = openai_response_stream(
618 state.link,
619 authority,
620 created,
621 request.model,
622 request.instructions,
623 request.max_output_tokens,
624 request.metadata,
625 );
626 return Sse::new(stream)
627 .keep_alive(
628 KeepAlive::new()
629 .interval(Duration::from_secs(15))
630 .text("keepalive"),
631 )
632 .into_response();
633 }
634 if request.background {
635 return Json(openai_response_from_view(
636 created,
637 request.model,
638 request.instructions,
639 request.max_output_tokens,
640 request.metadata,
641 ))
642 .into_response();
643 }
644 match wait_for_provider_boundary(state.link, &authority, &created.id).await {
645 Ok(view) => Json(openai_response_from_view(
646 view,
647 request.model,
648 request.instructions,
649 request.max_output_tokens,
650 request.metadata,
651 ))
652 .into_response(),
653 Err(error) => openai_link_error(error),
654 }
655}
656
657async fn get_openai_response(
658 State(state): State<AppState>,
659 headers: HeaderMap,
660 Path(id): Path<String>,
661) -> Response {
662 let authority = match authenticate(&state, &headers).await {
663 Ok(value) => value,
664 Err(_) => {
665 return openai_error(
666 StatusCode::UNAUTHORIZED,
667 "invalid_api_key",
668 "authentication failed",
669 None,
670 );
671 }
672 };
673 let id = match parse_openai_response_id(id) {
674 Ok(value) => value,
675 Err(error) => {
676 return openai_error(
677 StatusCode::BAD_REQUEST,
678 "invalid_request_error",
679 &error.to_string(),
680 Some("response_id"),
681 );
682 }
683 };
684 match state.link.execution(&authority, &id).await {
685 Ok(view) => {
686 let model = view.runtime_instance_id.to_string();
687 Json(openai_response_from_view(
688 view,
689 model,
690 None,
691 None,
692 Default::default(),
693 ))
694 .into_response()
695 }
696 Err(error) => openai_link_error(error),
697 }
698}
699
700async fn cancel_openai_response(
701 State(state): State<AppState>,
702 headers: HeaderMap,
703 Path(id): Path<String>,
704) -> Response {
705 let authority = match authenticate(&state, &headers).await {
706 Ok(value) => value,
707 Err(_) => {
708 return openai_error(
709 StatusCode::UNAUTHORIZED,
710 "invalid_api_key",
711 "authentication failed",
712 None,
713 );
714 }
715 };
716 let id = match parse_openai_response_id(id) {
717 Ok(value) => value,
718 Err(error) => {
719 return openai_error(
720 StatusCode::BAD_REQUEST,
721 "invalid_request_error",
722 &error.to_string(),
723 Some("response_id"),
724 );
725 }
726 };
727 match state.link.cancel(&authority, &id).await {
728 Ok(view) => {
729 let model = view.runtime_instance_id.to_string();
730 Json(openai_response_from_view(
731 view,
732 model,
733 None,
734 None,
735 Default::default(),
736 ))
737 .into_response()
738 }
739 Err(error) => openai_link_error(error),
740 }
741}
742
743async fn create_claude_message(
744 State(state): State<AppState>,
745 headers: HeaderMap,
746 payload: Result<Json<ClaudeMessageRequest>, JsonRejection>,
747) -> Response {
748 let authority = match authenticate(&state, &headers).await {
749 Ok(value) => value,
750 Err(_) => {
751 return claude_error(
752 StatusCode::UNAUTHORIZED,
753 "authentication_error",
754 "authentication failed",
755 );
756 }
757 };
758 let anthropic_version = headers
759 .get("anthropic-version")
760 .and_then(|value| value.to_str().ok())
761 .filter(|value| !value.is_empty());
762 if anthropic_version.is_none() {
763 return claude_error(
764 StatusCode::BAD_REQUEST,
765 "invalid_request_error",
766 "anthropic-version header is required",
767 );
768 }
769 if anthropic_version != Some("2023-06-01") {
770 return claude_error(
771 StatusCode::BAD_REQUEST,
772 "invalid_request_error",
773 "unsupported anthropic-version; this listener supports 2023-06-01",
774 );
775 }
776 let Json(mut request) = match payload {
777 Ok(value) => value,
778 Err(error) => {
779 return claude_error(
780 StatusCode::BAD_REQUEST,
781 "invalid_request_error",
782 &error.body_text(),
783 );
784 }
785 };
786 if let Some(request_id) = request_id(&headers) {
787 request
788 .metadata
789 .entry("requestId".into())
790 .or_insert_with(|| serde_json::Value::String(request_id.to_string()));
791 }
792 if request.stream && !state.transport.sse {
793 return claude_error(
794 StatusCode::BAD_REQUEST,
795 "invalid_request_error",
796 "stream=true is disabled for this Runtime listener",
797 );
798 }
799 let execution_request = match claude_execution_request(&request) {
800 Ok(value) => value,
801 Err(message) => {
802 return claude_error(StatusCode::BAD_REQUEST, "invalid_request_error", &message);
803 }
804 };
805 let key = idempotency_key_or_random(&headers, "claude-message");
806 let created = match state
807 .link
808 .create_execution(&authority, &key, execution_request)
809 .await
810 {
811 Ok(value) => value.execution,
812 Err(error) => return claude_link_error(error),
813 };
814 if request.stream {
815 let stream = claude_message_stream(state.link, authority, created, request.model);
816 return Sse::new(stream)
817 .keep_alive(
818 KeepAlive::new()
819 .interval(Duration::from_secs(15))
820 .text("ping"),
821 )
822 .into_response();
823 }
824 match wait_for_provider_boundary(state.link, &authority, &created.id).await {
825 Ok(view) if view.state == ExecutionState::Completed => {
826 Json(claude_message_from_view(view, request.model)).into_response()
827 }
828 Ok(view) => claude_terminal_error(view),
829 Err(error) => claude_link_error(error),
830 }
831}
832
833fn openai_execution_request(
834 request: &OpenAiResponseRequest,
835) -> Result<CreateExecutionRequest, (Option<String>, String)> {
836 if let Some(field) = request.extra.keys().next() {
837 return Err((
838 Some(field.clone()),
839 format!("{field} is not supported by this Agent endpoint"),
840 ));
841 }
842 if !request.tools.is_empty() {
843 return Err((Some("tools".into()), "client-defined tools are not supported by the Agent endpoint; configure tools on the Agent Definition".into()));
844 }
845 for (present, field) in [
846 (
847 request.previous_response_id.is_some(),
848 "previous_response_id",
849 ),
850 (request.conversation.is_some(), "conversation"),
851 (request.reasoning.is_some(), "reasoning"),
852 (request.top_p.is_some(), "top_p"),
853 (!request.include.is_empty(), "include"),
854 (request.safety_identifier.is_some(), "safety_identifier"),
855 (request.prompt_cache_key.is_some(), "prompt_cache_key"),
856 ] {
857 if present {
858 return Err((
859 Some(field.into()),
860 format!("{field} cannot be preserved by this Agent endpoint"),
861 ));
862 }
863 }
864 if request.store == Some(true) {
865 return Err((
866 Some("store".into()),
867 "store=true requires a durable provider response store and is not supported".into(),
868 ));
869 }
870 if request
871 .truncation
872 .as_deref()
873 .is_some_and(|value| value != "disabled")
874 {
875 return Err((
876 Some("truncation".into()),
877 "only truncation=disabled is supported".into(),
878 ));
879 }
880 let messages =
881 extract_openai_input(&request.input).map_err(|message| (Some("input".into()), message))?;
882 let instructions = request
883 .instructions
884 .as_ref()
885 .map(extract_text_value)
886 .transpose()
887 .map_err(|message| (Some("instructions".into()), message))?;
888 let tool_choice = parse_openai_tool_choice(request.tool_choice.as_ref())
889 .map_err(|message| (Some("tool_choice".into()), message))?;
890 let response_format = request
891 .text
892 .as_ref()
893 .map(openai_response_format)
894 .transpose()
895 .map_err(|message| (Some("text".into()), message))?;
896 let agent = request.agent.as_ref();
897 if agent
898 .and_then(|value| value.conversation_id.as_ref())
899 .is_some()
900 && messages.len() > 1
901 {
902 return Err((
903 Some("agent.conversationId".into()),
904 "agent.conversationId cannot be combined with a multi-message stateless input history"
905 .into(),
906 ));
907 }
908 let runtime_instance_id = match agent.and_then(|value| value.runtime_id.clone()) {
909 Some(value) => value,
910 None => RuntimeInstanceId::new(request.model.clone())
911 .map_err(|error| (Some("model".into()), error.to_string()))?,
912 };
913 Ok(CreateExecutionRequest {
914 runtime_instance_id,
915 conversation_id: agent.and_then(|value| value.conversation_id.clone()),
916 input: RuntimeInput::Messages { messages },
917 workspace_id: agent.and_then(|value| value.workspace_id.clone()),
918 model: agent.and_then(|value| value.downstream_model.clone()),
919 instructions,
920 metadata: request.metadata.clone(),
921 generation: ModelGenerationOptions {
922 max_output_tokens: request.max_output_tokens,
923 temperature: request.temperature.map(|value| value as f32),
924 stop_sequences: Vec::new(),
925 response_format,
926 tool_choice,
927 },
928 options: ExecutionOptions {
929 deadline_seconds: agent
930 .and_then(|value| value.deadline_seconds)
931 .unwrap_or(900),
932 max_model_turns: agent.and_then(|value| value.max_model_turns).unwrap_or(64),
933 max_tool_calls: agent.and_then(|value| value.max_tool_calls).unwrap_or(256),
934 },
935 })
936}
937
938fn claude_execution_request(
939 request: &ClaudeMessageRequest,
940) -> Result<CreateExecutionRequest, String> {
941 if let Some(field) = request.extra.keys().next() {
942 return Err(format!("{field} is not supported by this Agent endpoint"));
943 }
944 if request.max_tokens == 0 {
945 return Err("max_tokens must be greater than zero".into());
946 }
947 if request
948 .temperature
949 .is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
950 {
951 return Err("temperature must be within 0..=1 for Claude Messages".into());
952 }
953 if request.messages.is_empty() {
954 return Err("messages must not be empty".into());
955 }
956 if !request.tools.is_empty() {
957 return Err("client-defined tools are not supported by the Agent endpoint; configure tools on the Agent Definition".into());
958 }
959 if request.top_p.is_some() || request.top_k.is_some() {
960 return Err("top_p and top_k cannot be preserved by this Agent endpoint".into());
961 }
962 if request.thinking.is_some()
963 || request.output_config.is_some()
964 || request.service_tier.is_some()
965 {
966 return Err(
967 "thinking, output_config, and service_tier cannot be preserved by this Agent endpoint"
968 .into(),
969 );
970 }
971 let messages = extract_claude_messages(&request.messages)?;
972 let instructions = request
973 .system
974 .as_ref()
975 .map(extract_text_value)
976 .transpose()?;
977 let tool_choice = parse_claude_tool_choice(request.tool_choice.as_ref())?;
978 let metadata = request
979 .metadata
980 .iter()
981 .map(|(key, value)| (key.clone(), scalar_metadata(value)))
982 .collect();
983 let agent = request.agent.as_ref();
984 if agent
985 .and_then(|value| value.conversation_id.as_ref())
986 .is_some()
987 && messages.len() > 1
988 {
989 return Err(
990 "agent.conversationId cannot be combined with a multi-message stateless history".into(),
991 );
992 }
993 let runtime_instance_id = match agent.and_then(|value| value.runtime_id.clone()) {
994 Some(value) => value,
995 None => RuntimeInstanceId::new(request.model.clone()).map_err(|error| error.to_string())?,
996 };
997 Ok(CreateExecutionRequest {
998 runtime_instance_id,
999 conversation_id: agent.and_then(|value| value.conversation_id.clone()),
1000 input: RuntimeInput::Messages { messages },
1001 workspace_id: agent.and_then(|value| value.workspace_id.clone()),
1002 model: agent.and_then(|value| value.downstream_model.clone()),
1003 instructions,
1004 metadata,
1005 generation: ModelGenerationOptions {
1006 max_output_tokens: Some(request.max_tokens),
1007 temperature: request.temperature.map(|value| value as f32),
1008 stop_sequences: request.stop_sequences.clone(),
1009 response_format: None,
1010 tool_choice,
1011 },
1012 options: ExecutionOptions {
1013 deadline_seconds: agent
1014 .and_then(|value| value.deadline_seconds)
1015 .unwrap_or(900),
1016 max_model_turns: agent.and_then(|value| value.max_model_turns).unwrap_or(64),
1017 max_tool_calls: agent.and_then(|value| value.max_tool_calls).unwrap_or(256),
1018 },
1019 })
1020}
1021
1022fn parse_openai_tool_choice(value: Option<&serde_json::Value>) -> Result<ModelToolChoice, String> {
1023 let Some(value) = value else {
1024 return Ok(ModelToolChoice::Auto);
1025 };
1026 match value.as_str() {
1027 Some("auto") => Ok(ModelToolChoice::Auto),
1028 Some("none") => Ok(ModelToolChoice::None),
1029 _ => Err(
1030 "only tool_choice=auto or none can be preserved across Agent Infra providers".into(),
1031 ),
1032 }
1033}
1034
1035fn parse_claude_tool_choice(value: Option<&serde_json::Value>) -> Result<ModelToolChoice, String> {
1036 let Some(value) = value else {
1037 return Ok(ModelToolChoice::Auto);
1038 };
1039 let object = value
1040 .as_object()
1041 .ok_or_else(|| "tool_choice must be an object".to_string())?;
1042 if object.len() != 1 {
1043 return Err("tool_choice contains fields this Agent endpoint cannot preserve".into());
1044 }
1045 match value.get("type").and_then(serde_json::Value::as_str) {
1046 Some("auto") => Ok(ModelToolChoice::Auto),
1047 Some("none") => Ok(ModelToolChoice::None),
1048 _ => Err(
1049 "only tool_choice.type auto or none can be preserved across Agent Infra providers"
1050 .into(),
1051 ),
1052 }
1053}
1054
1055fn openai_response_format(text: &serde_json::Value) -> Result<serde_json::Value, String> {
1056 let format = text
1057 .get("format")
1058 .ok_or_else(|| "text.format is required when text is supplied".to_string())?;
1059 if !format.is_object() {
1060 return Err("text.format must be an object".into());
1061 }
1062 match format.get("type").and_then(serde_json::Value::as_str) {
1063 Some("json_schema") => {
1064 let object = format.as_object().expect("format was checked as an object");
1065 let name = object
1066 .get("name")
1067 .and_then(serde_json::Value::as_str)
1068 .filter(|name| !name.trim().is_empty())
1069 .ok_or_else(|| "text.format json_schema requires name".to_string())?;
1070 let schema = object
1071 .get("schema")
1072 .filter(|schema| schema.is_object())
1073 .ok_or_else(|| "text.format json_schema requires an object schema".to_string())?;
1074 let mut json_schema = serde_json::Map::new();
1075 json_schema.insert("name".into(), json!(name));
1076 json_schema.insert("schema".into(), schema.clone());
1077 for field in ["description", "strict"] {
1078 if let Some(value) = object.get(field) {
1079 json_schema.insert(field.into(), value.clone());
1080 }
1081 }
1082 Ok(json!({"type":"json_schema","json_schema":json_schema}))
1083 }
1084 Some("text" | "json_object") => Ok(format.clone()),
1085 _ => Err("text.format.type must be text, json_object, or json_schema".into()),
1086 }
1087}
1088
1089fn extract_openai_input(input: &serde_json::Value) -> Result<Vec<RuntimeMessage>, String> {
1090 if let Some(text) = input.as_str() {
1091 return Ok(vec![RuntimeMessage {
1092 role: RuntimeMessageRole::User,
1093 text: non_empty_text(text)?,
1094 }]);
1095 }
1096 let items = input
1097 .as_array()
1098 .ok_or_else(|| "input must be a string or an array of message items".to_string())?;
1099 let mut messages = Vec::new();
1100 for item in items {
1101 let object = item
1102 .as_object()
1103 .ok_or_else(|| "input items must be objects".to_string())?;
1104 if let Some(field) = object.keys().find(|field| {
1105 !matches!(
1106 field.as_str(),
1107 "type" | "role" | "content" | "id" | "status"
1108 )
1109 }) {
1110 return Err(format!("input message field {field} is not supported"));
1111 }
1112 let item_type = object
1113 .get("type")
1114 .and_then(|value| value.as_str())
1115 .unwrap_or("message");
1116 if item_type != "message" {
1117 return Err(format!("input item type {item_type} is not supported"));
1118 }
1119 let role = object
1120 .get("role")
1121 .and_then(|value| value.as_str())
1122 .unwrap_or("user");
1123 let role = match role {
1124 "system" => RuntimeMessageRole::System,
1125 "developer" => RuntimeMessageRole::Developer,
1126 "user" => RuntimeMessageRole::User,
1127 "assistant" => RuntimeMessageRole::Assistant,
1128 role => return Err(format!("input message role {role} is not supported")),
1129 };
1130 let content = object
1131 .get("content")
1132 .ok_or_else(|| "message content is required".to_string())?;
1133 messages.push(RuntimeMessage {
1134 role,
1135 text: extract_text_value(content)?,
1136 });
1137 }
1138 if messages.is_empty() {
1139 Err("input message array must not be empty".into())
1140 } else {
1141 Ok(messages)
1142 }
1143}
1144
1145fn extract_claude_messages(
1146 messages: &[runtime_api_contract::ClaudeInputMessage],
1147) -> Result<Vec<RuntimeMessage>, String> {
1148 let mut transcript = Vec::with_capacity(messages.len());
1149 for message in messages {
1150 if message.role != "user" && message.role != "assistant" {
1151 return Err("Claude message role must be user or assistant".into());
1152 }
1153 transcript.push(RuntimeMessage {
1154 role: if message.role == "user" {
1155 RuntimeMessageRole::User
1156 } else {
1157 RuntimeMessageRole::Assistant
1158 },
1159 text: extract_text_value(&message.content)?,
1160 });
1161 }
1162 Ok(transcript)
1163}
1164
1165fn extract_text_value(value: &serde_json::Value) -> Result<String, String> {
1166 if let Some(text) = value.as_str() {
1167 return non_empty_text(text);
1168 }
1169 let blocks = value
1170 .as_array()
1171 .ok_or_else(|| "content must be text or an array of text blocks".to_string())?;
1172 let mut text = Vec::new();
1173 for block in blocks {
1174 let object = block
1175 .as_object()
1176 .ok_or_else(|| "content blocks must be objects".to_string())?;
1177 if let Some(field) = object
1178 .keys()
1179 .find(|field| !matches!(field.as_str(), "type" | "text"))
1180 {
1181 return Err(format!("content block field {field} is not supported"));
1182 }
1183 let kind = object
1184 .get("type")
1185 .and_then(|value| value.as_str())
1186 .unwrap_or("text");
1187 if kind != "text" && kind != "input_text" && kind != "output_text" {
1188 return Err(format!("content block type {kind} is not supported"));
1189 }
1190 let part = object
1191 .get("text")
1192 .and_then(|value| value.as_str())
1193 .ok_or_else(|| "text block requires text".to_string())?;
1194 text.push(part);
1195 }
1196 non_empty_text(&text.join("\n"))
1197}
1198
1199fn non_empty_text(text: &str) -> Result<String, String> {
1200 if text.trim().is_empty() {
1201 Err("text content must not be empty".into())
1202 } else {
1203 Ok(text.to_string())
1204 }
1205}
1206
1207fn scalar_metadata(value: &serde_json::Value) -> String {
1208 value
1209 .as_str()
1210 .map(ToOwned::to_owned)
1211 .unwrap_or_else(|| value.to_string())
1212}
1213
1214async fn wait_for_provider_boundary(
1218 link: Arc<dyn RuntimeLink>,
1219 authority: &RequestAuthority,
1220 id: &ExecutionId,
1221) -> Result<runtime_api_contract::ExecutionView, LinkError> {
1222 loop {
1223 let view = link.execution(authority, id).await?;
1224 if view.state.is_terminal() || view.state == ExecutionState::WaitingForInput {
1225 return Ok(view);
1226 }
1227 tokio::time::sleep(Duration::from_millis(100)).await;
1228 }
1229}
1230
1231fn openai_response_from_view(
1232 view: runtime_api_contract::ExecutionView,
1233 model: String,
1234 instructions: Option<serde_json::Value>,
1235 max_output_tokens: Option<u32>,
1236 metadata: std::collections::BTreeMap<String, String>,
1237) -> OpenAiResponse {
1238 let status = match view.state {
1239 ExecutionState::Queued | ExecutionState::Running | ExecutionState::Finalizing => {
1240 "in_progress"
1241 }
1242 ExecutionState::WaitingForInput => "incomplete",
1243 ExecutionState::Completed => "completed",
1244 ExecutionState::Failed => "failed",
1245 ExecutionState::Canceled => "cancelled",
1246 };
1247 let output = view
1248 .outcome
1249 .as_ref()
1250 .map(|outcome| {
1251 vec![OpenAiOutputItem {
1252 id: format!("msg_{}", view.id),
1253 item_type: "message".into(),
1254 status: "completed".into(),
1255 role: "assistant".into(),
1256 content: vec![OpenAiOutputContent {
1257 content_type: "output_text".into(),
1258 text: outcome.answer.clone(),
1259 annotations: Vec::new(),
1260 }],
1261 }]
1262 })
1263 .unwrap_or_default();
1264 let usage = view.outcome.as_ref().map(|outcome| {
1265 let input = outcome.input_tokens.unwrap_or_default();
1266 let output = outcome.output_tokens.unwrap_or_default();
1267 OpenAiUsage {
1268 input_tokens: input,
1269 input_tokens_details: OpenAiInputTokensDetails { cached_tokens: 0 },
1270 output_tokens: output,
1271 output_tokens_details: OpenAiOutputTokensDetails {
1272 reasoning_tokens: 0,
1273 },
1274 total_tokens: input.saturating_add(output),
1275 }
1276 });
1277 let error = view.failure.as_ref().map(|failure| OpenAiResponseError {
1278 code: failure.code.clone(),
1279 message: failure.message.clone(),
1280 });
1281 OpenAiResponse {
1282 id: format!("resp_{}", view.id),
1283 object: "response".into(),
1284 created_at: view.created_at_ms / 1000,
1285 status: status.into(),
1286 completed_at: view
1287 .state
1288 .is_terminal()
1289 .then_some(view.updated_at_ms / 1000),
1290 error,
1291 incomplete_details: (view.state == ExecutionState::WaitingForInput)
1292 .then(|| json!({"reason":"requires_input"})),
1293 instructions,
1294 max_output_tokens,
1295 model,
1296 output,
1297 parallel_tool_calls: false,
1298 previous_response_id: None,
1299 tool_choice: json!("none"),
1300 tools: Vec::new(),
1301 metadata,
1302 usage,
1303 }
1304}
1305
1306fn claude_message_from_view(
1307 view: runtime_api_contract::ExecutionView,
1308 model: String,
1309) -> ClaudeMessage {
1310 let outcome = view.outcome.expect("completed execution has an outcome");
1311 ClaudeMessage {
1312 id: format!("msg_{}", view.id),
1313 message_type: "message".into(),
1314 role: "assistant".into(),
1315 content: vec![ClaudeContentBlock::Text {
1316 text: outcome.answer,
1317 }],
1318 model,
1319 stop_reason: Some("end_turn".into()),
1320 stop_sequence: None,
1321 usage: ClaudeUsage {
1322 input_tokens: outcome.input_tokens.unwrap_or_default(),
1323 output_tokens: outcome.output_tokens.unwrap_or_default(),
1324 },
1325 }
1326}
1327
1328fn openai_response_stream(
1329 link: Arc<dyn RuntimeLink>,
1330 authority: RequestAuthority,
1331 initial: runtime_api_contract::ExecutionView,
1332 model: String,
1333 instructions: Option<serde_json::Value>,
1334 max_output_tokens: Option<u32>,
1335 metadata: std::collections::BTreeMap<String, String>,
1336) -> impl Stream<Item = Result<Event, Infallible>> {
1337 async_stream::stream! {
1338 let created = openai_response_from_view(initial.clone(), model.clone(), instructions.clone(), max_output_tokens, metadata.clone());
1339 yield Ok(sse_json("response.created", json!({"type":"response.created","response":created,"sequence_number":0})));
1340 let final_view = wait_for_provider_boundary(link, &authority, &initial.id).await;
1341 match final_view {
1342 Ok(view) if view.state == ExecutionState::Completed => {
1343 let response = openai_response_from_view(view, model, instructions, max_output_tokens, metadata);
1344 let item = response.output.first().cloned().expect("completed response has output");
1345 let text = item.content.first().map(|content| content.text.clone()).unwrap_or_default();
1346 yield Ok(sse_json("response.output_item.added", json!({"type":"response.output_item.added","output_index":0,"item":{"id":item.id,"type":"message","status":"in_progress","role":"assistant","content":[]},"sequence_number":1})));
1347 yield Ok(sse_json("response.content_part.added", json!({"type":"response.content_part.added","item_id":item.id,"output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"sequence_number":2})));
1348 yield Ok(sse_json("response.output_text.delta", json!({"type":"response.output_text.delta","item_id":item.id,"output_index":0,"content_index":0,"delta":text,"logprobs":[],"sequence_number":3})));
1349 yield Ok(sse_json("response.output_text.done", json!({"type":"response.output_text.done","item_id":item.id,"output_index":0,"content_index":0,"text":text,"logprobs":[],"sequence_number":4})));
1350 yield Ok(sse_json("response.content_part.done", json!({"type":"response.content_part.done","item_id":item.id,"output_index":0,"content_index":0,"part":item.content[0],"sequence_number":5})));
1351 yield Ok(sse_json("response.output_item.done", json!({"type":"response.output_item.done","output_index":0,"item":item,"sequence_number":6})));
1352 yield Ok(sse_json("response.completed", json!({"type":"response.completed","response":response,"sequence_number":7})));
1353 }
1354 Ok(view) => {
1355 let event_type = match view.state {
1356 ExecutionState::Canceled => "response.cancelled",
1357 ExecutionState::WaitingForInput => "response.incomplete",
1358 _ => "response.failed",
1359 };
1360 let response = openai_response_from_view(view, model, instructions, max_output_tokens, metadata);
1361 yield Ok(sse_json(event_type, json!({"type":event_type,"response":response,"sequence_number":1})));
1362 }
1363 Err(error) => {
1364 yield Ok(sse_json("error", json!({"type":"error","error":{"type":"api_error","code":"runtime_unavailable","message":error.to_string()}})));
1365 }
1366 }
1367 }
1368}
1369
1370fn claude_message_stream(
1371 link: Arc<dyn RuntimeLink>,
1372 authority: RequestAuthority,
1373 initial: runtime_api_contract::ExecutionView,
1374 model: String,
1375) -> impl Stream<Item = Result<Event, Infallible>> {
1376 async_stream::stream! {
1377 yield Ok(sse_json("message_start", json!({
1378 "type":"message_start",
1379 "message":{"id":format!("msg_{}", initial.id),"type":"message","role":"assistant","content":[],"model":model,"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}
1380 })));
1381 match wait_for_provider_boundary(link, &authority, &initial.id).await {
1382 Ok(view) if view.state == ExecutionState::Completed => {
1383 let message = claude_message_from_view(view, model);
1384 let text = match &message.content[0] { ClaudeContentBlock::Text { text } => text.clone() };
1385 yield Ok(sse_json("content_block_start", json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}})));
1386 yield Ok(sse_json("content_block_delta", json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":text}})));
1387 yield Ok(sse_json("content_block_stop", json!({"type":"content_block_stop","index":0})));
1388 yield Ok(sse_json("message_delta", json!({"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":message.usage.output_tokens}})));
1389 yield Ok(sse_json("message_stop", json!({"type":"message_stop"})));
1390 }
1391 Ok(view) => {
1392 let message = view.failure.map(|failure| failure.message).unwrap_or_else(|| "Agent Task was canceled".into());
1393 yield Ok(sse_json("error", json!({"type":"error","error":{"type":"api_error","message":message}})));
1394 }
1395 Err(error) => {
1396 yield Ok(sse_json("error", json!({"type":"error","error":{"type":"api_error","message":error.to_string()}})));
1397 }
1398 }
1399 }
1400}
1401
1402fn sse_json(event_type: &str, value: serde_json::Value) -> Event {
1403 Event::default()
1404 .event(event_type)
1405 .json_data(value)
1406 .unwrap_or_else(|_| {
1407 Event::default()
1408 .event("error")
1409 .data("serialization failure")
1410 })
1411}
1412
1413fn openai_error(status: StatusCode, code: &str, message: &str, param: Option<&str>) -> Response {
1414 (
1415 status,
1416 Json(OpenAiErrorEnvelope {
1417 error: OpenAiError {
1418 message: message.into(),
1419 error_type: if status == StatusCode::UNAUTHORIZED {
1420 "authentication_error".into()
1421 } else {
1422 "invalid_request_error".into()
1423 },
1424 param: param.map(ToOwned::to_owned),
1425 code: Some(code.into()),
1426 },
1427 }),
1428 )
1429 .into_response()
1430}
1431
1432fn openai_link_error(error: LinkError) -> Response {
1433 match error {
1434 LinkError::Invalid(message) => {
1435 openai_error(StatusCode::BAD_REQUEST, "invalid_request", &message, None)
1436 }
1437 LinkError::NotFound => openai_error(
1438 StatusCode::NOT_FOUND,
1439 "not_found",
1440 "response not found",
1441 None,
1442 ),
1443 LinkError::Forbidden => openai_error(
1444 StatusCode::FORBIDDEN,
1445 "permission_denied",
1446 "caller cannot access this response",
1447 None,
1448 ),
1449 LinkError::Conflict(message) => {
1450 openai_error(StatusCode::CONFLICT, "conflict", &message, None)
1451 }
1452 LinkError::Overloaded => openai_error(
1453 StatusCode::TOO_MANY_REQUESTS,
1454 "rate_limit_exceeded",
1455 "runtime is at capacity",
1456 None,
1457 ),
1458 LinkError::Unavailable(message) | LinkError::Internal(message) => openai_error(
1459 StatusCode::SERVICE_UNAVAILABLE,
1460 "server_error",
1461 &message,
1462 None,
1463 ),
1464 }
1465}
1466
1467fn claude_error(status: StatusCode, error_type: &str, message: &str) -> Response {
1468 (
1469 status,
1470 Json(ClaudeErrorEnvelope {
1471 envelope_type: "error".into(),
1472 error: ClaudeError {
1473 error_type: error_type.into(),
1474 message: message.into(),
1475 },
1476 request_id: None,
1477 }),
1478 )
1479 .into_response()
1480}
1481
1482fn claude_link_error(error: LinkError) -> Response {
1483 match error {
1484 LinkError::Invalid(message) => {
1485 claude_error(StatusCode::BAD_REQUEST, "invalid_request_error", &message)
1486 }
1487 LinkError::NotFound => claude_error(
1488 StatusCode::NOT_FOUND,
1489 "not_found_error",
1490 "message task not found",
1491 ),
1492 LinkError::Forbidden => claude_error(
1493 StatusCode::FORBIDDEN,
1494 "permission_error",
1495 "caller cannot access this message task",
1496 ),
1497 LinkError::Overloaded => claude_error(
1498 StatusCode::TOO_MANY_REQUESTS,
1499 "rate_limit_error",
1500 "runtime is at capacity",
1501 ),
1502 LinkError::Conflict(message) => {
1503 claude_error(StatusCode::CONFLICT, "invalid_request_error", &message)
1504 }
1505 LinkError::Unavailable(message) | LinkError::Internal(message) => {
1506 claude_error(StatusCode::SERVICE_UNAVAILABLE, "api_error", &message)
1507 }
1508 }
1509}
1510
1511fn claude_terminal_error(view: runtime_api_contract::ExecutionView) -> Response {
1512 let message = view
1513 .failure
1514 .map(|failure| failure.message)
1515 .unwrap_or_else(|| "Agent Task was canceled".into());
1516 claude_error(StatusCode::INTERNAL_SERVER_ERROR, "api_error", &message)
1517}
1518
1519#[derive(Debug, Deserialize)]
1520#[serde(rename_all = "camelCase", deny_unknown_fields)]
1521struct EventQuery {
1522 after: Option<u64>,
1523 limit: Option<usize>,
1524}
1525
1526async fn websocket_agent_task(
1527 upgrade: WebSocketUpgrade,
1528 State(state): State<AppState>,
1529 headers: HeaderMap,
1530 Path(id): Path<String>,
1531 Query(query): Query<EventQuery>,
1532) -> Response {
1533 let authority = match authenticate(&state, &headers).await {
1534 Ok(value) => value,
1535 Err(response) => return response,
1536 };
1537 let id = match parse_id(id) {
1538 Ok(value) => value,
1539 Err(error) => return invalid_task_id(error),
1540 };
1541 let task = match state.link.execution(&authority, &id).await {
1542 Ok(value) => AgentTask::from(value),
1543 Err(error) => return link_error(error),
1544 };
1545 upgrade
1546 .on_upgrade(move |socket| {
1547 websocket_agent_task_session(socket, state.link, authority, id, task, query.after)
1548 })
1549 .into_response()
1550}
1551
1552async fn websocket_agent_task_session(
1553 mut socket: WebSocket,
1554 link: Arc<dyn RuntimeLink>,
1555 authority: RequestAuthority,
1556 id: ExecutionId,
1557 task: AgentTask,
1558 mut after: Option<u64>,
1559) {
1560 if send_agent_task_websocket(
1561 &mut socket,
1562 &AgentTaskWebSocketServerMessage::Connected {
1563 task,
1564 next_after: after,
1565 heartbeat_seconds: 15,
1566 },
1567 )
1568 .await
1569 .is_err()
1570 {
1571 return;
1572 }
1573
1574 let mut poll = tokio::time::interval(Duration::from_millis(100));
1575 poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1576 let mut heartbeat = tokio::time::interval(Duration::from_secs(15));
1577 heartbeat.tick().await;
1578 loop {
1579 tokio::select! {
1580 incoming = socket.next() => {
1581 match incoming {
1582 Some(Ok(Message::Text(text))) => {
1583 let command = match serde_json::from_str::<AgentTaskWebSocketClientMessage>(&text) {
1584 Ok(command) => command,
1585 Err(_) => {
1586 let _ = send_agent_task_websocket_error(&mut socket, "WEBSOCKET_MESSAGE_INVALID", "Agent Task WebSocket command is invalid").await;
1587 continue;
1588 }
1589 };
1590 let result = match command {
1591 AgentTaskWebSocketClientMessage::SubmitInput { operation_id, input } => {
1592 Some(link.submit_input(&authority, &id, SubmitInputRequest { operation_id, input }).await)
1593 }
1594 AgentTaskWebSocketClientMessage::Cancel {} => {
1595 Some(link.cancel(&authority, &id).await)
1596 }
1597 AgentTaskWebSocketClientMessage::Ack { sequence } => {
1598 if after.is_none_or(|delivered| sequence > delivered) {
1601 let _ = send_agent_task_websocket_error(&mut socket, "ACK_SEQUENCE_INVALID", "cannot acknowledge an event that has not been delivered").await;
1602 } else if send_agent_task_websocket(
1603 &mut socket,
1604 &AgentTaskWebSocketServerMessage::Acknowledged { sequence },
1605 ).await.is_err() {
1606 return;
1607 }
1608 None
1609 }
1610 };
1611 if let Some(result) = result {
1612 match result {
1613 Ok(view) => {
1614 if send_agent_task_websocket(&mut socket, &AgentTaskWebSocketServerMessage::Task { task: AgentTask::from(view) }).await.is_err() {
1615 return;
1616 }
1617 }
1618 Err(_) => {
1619 if send_agent_task_websocket_error(&mut socket, "WEBSOCKET_COMMAND_FAILED", "Agent Task command could not be committed").await.is_err() {
1620 return;
1621 }
1622 }
1623 }
1624 }
1625 }
1626 Some(Ok(Message::Ping(value))) => {
1627 if socket.send(Message::Pong(value)).await.is_err() { return; }
1628 }
1629 Some(Ok(Message::Close(_))) | None | Some(Err(_)) => return,
1630 Some(Ok(Message::Binary(_))) | Some(Ok(Message::Pong(_))) => {}
1631 }
1632 }
1633 _ = poll.tick() => {
1634 let page = match link.events(&authority, &id, after, 100).await {
1635 Ok(page) => page,
1636 Err(_) => {
1637 let _ = send_agent_task_websocket_error(&mut socket, "WEBSOCKET_EVENTS_UNAVAILABLE", "Agent Task events are unavailable").await;
1638 return;
1639 }
1640 };
1641 let empty = page.items.is_empty();
1642 for item in page.items {
1643 after = Some(item.sequence);
1644 if send_agent_task_websocket(
1645 &mut socket,
1646 &AgentTaskWebSocketServerMessage::Event { event: AgentTaskEvent::from(item) },
1647 ).await.is_err() {
1648 return;
1649 }
1650 }
1651 if empty {
1652 match link.execution(&authority, &id).await {
1653 Ok(view) if view.state.is_terminal() => {
1654 let _ = send_agent_task_websocket(&mut socket, &AgentTaskWebSocketServerMessage::Task { task: AgentTask::from(view) }).await;
1655 let _ = socket.close().await;
1656 return;
1657 }
1658 Err(_) => return,
1659 _ => {}
1660 }
1661 }
1662 }
1663 _ = heartbeat.tick() => {
1664 if socket.send(Message::Ping(Vec::new().into())).await.is_err() {
1665 return;
1666 }
1667 }
1668 }
1669 }
1670}
1671
1672async fn send_agent_task_websocket(
1673 socket: &mut WebSocket,
1674 message: &AgentTaskWebSocketServerMessage,
1675) -> Result<(), axum::Error> {
1676 let json = serde_json::to_string(message).map_err(axum::Error::new)?;
1677 socket.send(Message::Text(json.into())).await
1678}
1679
1680async fn send_agent_task_websocket_error(
1681 socket: &mut WebSocket,
1682 code: &str,
1683 message: &str,
1684) -> Result<(), axum::Error> {
1685 send_agent_task_websocket(
1686 socket,
1687 &AgentTaskWebSocketServerMessage::Error {
1688 error: ApiErrorBody {
1689 code: code.into(),
1690 message: message.into(),
1691 request_id: None,
1692 details: None,
1693 },
1694 },
1695 )
1696 .await
1697}
1698
1699fn agent_task_event_stream(
1700 link: Arc<dyn RuntimeLink>,
1701 authority: RequestAuthority,
1702 id: ExecutionId,
1703 initial_after: Option<u64>,
1704) -> impl Stream<Item = Result<Event, Infallible>> {
1705 async_stream::stream! {
1706 let mut after = initial_after;
1707 loop {
1708 match link.events(&authority, &id, after, 100).await {
1709 Ok(page) => {
1710 let empty = page.items.is_empty();
1711 for item in page.items {
1712 after = Some(item.sequence);
1713 let event = AgentTaskEvent::from(item);
1714 let frame = Event::default()
1715 .id(event.sequence.to_string())
1716 .event(event.kind.clone())
1717 .json_data(&event)
1718 .unwrap_or_else(|_| Event::default().event("task.error").data("serialization failure"));
1719 yield Ok(frame);
1720 }
1721 if empty {
1722 match link.execution(&authority, &id).await {
1723 Ok(view) if view.state.is_terminal() => break,
1724 Err(_) => break,
1725 _ => {}
1726 }
1727 }
1728 }
1729 Err(error) => {
1730 yield Ok(Event::default().event("task.error").data(error.to_string()));
1731 break;
1732 }
1733 }
1734 tokio::time::sleep(Duration::from_millis(100)).await;
1735 }
1736 }
1737}
1738
1739fn idempotency_key_or_random(headers: &HeaderMap, prefix: &str) -> String {
1740 headers
1741 .get("idempotency-key")
1742 .and_then(|value| value.to_str().ok())
1743 .filter(|value| !value.is_empty())
1744 .map(ToOwned::to_owned)
1745 .unwrap_or_else(|| format!("{prefix}:{}", uuid::Uuid::new_v4()))
1746}
1747
1748fn request_id(headers: &HeaderMap) -> Option<&str> {
1749 headers
1750 .get("x-request-id")
1751 .and_then(|value| value.to_str().ok())
1752}
1753
1754fn sse_resume_after(headers: &HeaderMap, query_after: Option<u64>) -> Result<Option<u64>, String> {
1755 let header_after = headers
1756 .get("last-event-id")
1757 .map(|value| {
1758 value
1759 .to_str()
1760 .map_err(|_| "Last-Event-ID must be ASCII".to_string())?
1761 .parse::<u64>()
1762 .map_err(|_| "Last-Event-ID must be an unsigned integer".to_string())
1763 })
1764 .transpose()?;
1765 if query_after.is_some() && header_after.is_some() && query_after != header_after {
1766 return Err("after and Last-Event-ID must identify the same sequence".into());
1767 }
1768 Ok(query_after.or(header_after))
1769}
1770
1771fn attach_request_id_metadata(
1772 headers: &HeaderMap,
1773 metadata: &mut std::collections::BTreeMap<String, String>,
1774) {
1775 if let Some(request_id) = request_id(headers) {
1776 metadata
1777 .entry("requestId".into())
1778 .or_insert_with(|| request_id.to_string());
1779 }
1780}
1781
1782fn invalid_task_id(error: runtime_types::IdentifierError) -> Response {
1783 api_error(
1784 StatusCode::BAD_REQUEST,
1785 "TASK_ID_INVALID",
1786 &error.to_string(),
1787 )
1788}
1789
1790async fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<RequestAuthority, Response> {
1791 state
1792 .authenticator
1793 .authenticate(headers)
1794 .await
1795 .map_err(|_| {
1796 api_error(
1797 StatusCode::UNAUTHORIZED,
1798 "UNAUTHENTICATED",
1799 "request authentication failed",
1800 )
1801 })
1802}
1803
1804fn parse_id(value: String) -> Result<ExecutionId, runtime_types::IdentifierError> {
1805 ExecutionId::new(value)
1806}
1807
1808fn parse_openai_response_id(value: String) -> Result<ExecutionId, runtime_types::IdentifierError> {
1809 ExecutionId::new(value.strip_prefix("resp_").unwrap_or(&value).to_string())
1810}
1811
1812fn link_error(error: LinkError) -> Response {
1813 match error {
1814 LinkError::Invalid(message) => {
1815 api_error(StatusCode::BAD_REQUEST, "INVALID_REQUEST", &message)
1816 }
1817 LinkError::NotFound => api_error(
1818 StatusCode::NOT_FOUND,
1819 "EXECUTION_NOT_FOUND",
1820 "execution not found",
1821 ),
1822 LinkError::Forbidden => api_error(
1823 StatusCode::FORBIDDEN,
1824 "FORBIDDEN",
1825 "caller cannot access this execution",
1826 ),
1827 LinkError::Conflict(message) => {
1828 api_error(StatusCode::CONFLICT, "EXECUTION_CONFLICT", &message)
1829 }
1830 LinkError::Overloaded => api_error(
1831 StatusCode::TOO_MANY_REQUESTS,
1832 "RUNTIME_OVERLOADED",
1833 "runtime is at capacity",
1834 ),
1835 LinkError::Unavailable(message) => api_error(
1836 StatusCode::SERVICE_UNAVAILABLE,
1837 "DEPENDENCY_UNAVAILABLE",
1838 &message,
1839 ),
1840 LinkError::Internal(message) => {
1841 tracing::error!(%message, "runtime internal error");
1842 api_error(
1843 StatusCode::INTERNAL_SERVER_ERROR,
1844 "INTERNAL",
1845 "runtime internal failure",
1846 )
1847 }
1848 }
1849}
1850
1851fn api_error(status: StatusCode, code: &str, message: &str) -> Response {
1852 (
1853 status,
1854 Json(ApiErrorBody {
1855 code: code.into(),
1856 message: message.into(),
1857 request_id: None,
1858 details: None,
1859 }),
1860 )
1861 .into_response()
1862}
1863
1864#[cfg(test)]
1865mod tests {
1866 use std::sync::Arc;
1867
1868 use axum::{body::Body, http::Request};
1869 use http_body_util::BodyExt;
1870 use runtime_api_contract::{CreateExecutionResponse, EventPage, ExecutionState, ExecutionView};
1871 use runtime_types::RuntimeInstanceId;
1872 use tokio_tungstenite::{
1873 connect_async,
1874 tungstenite::{Message as ClientMessage, client::IntoClientRequest},
1875 };
1876 use tower::ServiceExt;
1877
1878 use super::*;
1879
1880 struct FakeLink {
1881 terminal: bool,
1882 }
1883
1884 fn view(terminal: bool) -> ExecutionView {
1885 ExecutionView {
1886 id: ExecutionId::new("execution-1").unwrap(),
1887 runtime_instance_id: RuntimeInstanceId::new("runtime-1").unwrap(),
1888 conversation_id: None,
1889 workspace_id: None,
1890 model: None,
1891 metadata: Default::default(),
1892 state: if terminal {
1893 ExecutionState::Completed
1894 } else {
1895 ExecutionState::Queued
1896 },
1897 outcome: terminal.then_some(runtime_api_contract::ExecutionOutcome {
1898 answer: "complete".into(),
1899 model_turns: 1,
1900 tool_calls: 0,
1901 input_tokens: Some(10),
1902 output_tokens: Some(2),
1903 }),
1904 failure: None,
1905 created_at_ms: 1,
1906 updated_at_ms: 1,
1907 }
1908 }
1909
1910 #[async_trait]
1911 impl RuntimeLink for FakeLink {
1912 async fn create_execution(
1913 &self,
1914 _: &RequestAuthority,
1915 _: &str,
1916 request: CreateExecutionRequest,
1917 ) -> Result<CreateExecutionResponse, LinkError> {
1918 let mut execution = view(self.terminal);
1919 execution.workspace_id = request.workspace_id;
1920 execution.model = request.model;
1921 execution.metadata = request.metadata;
1922 Ok(CreateExecutionResponse {
1923 execution,
1924 replayed: false,
1925 })
1926 }
1927 async fn execution(
1928 &self,
1929 _: &RequestAuthority,
1930 _: &ExecutionId,
1931 ) -> Result<ExecutionView, LinkError> {
1932 Ok(view(self.terminal))
1933 }
1934 async fn events(
1935 &self,
1936 _: &RequestAuthority,
1937 _: &ExecutionId,
1938 _: Option<u64>,
1939 _: usize,
1940 ) -> Result<EventPage, LinkError> {
1941 Ok(EventPage {
1942 items: Vec::new(),
1943 next_after: None,
1944 })
1945 }
1946 async fn submit_input(
1947 &self,
1948 _: &RequestAuthority,
1949 _: &ExecutionId,
1950 _: SubmitInputRequest,
1951 ) -> Result<ExecutionView, LinkError> {
1952 Ok(view(self.terminal))
1953 }
1954 async fn cancel(
1955 &self,
1956 _: &RequestAuthority,
1957 _: &ExecutionId,
1958 ) -> Result<ExecutionView, LinkError> {
1959 Ok(view(self.terminal))
1960 }
1961 }
1962
1963 fn app() -> Router {
1964 app_with_terminal(false)
1965 }
1966
1967 fn app_with_terminal(terminal: bool) -> Router {
1968 let auth = StaticTokenAuthenticator::new(
1969 "token",
1970 CallerScope {
1971 subject: "user".into(),
1972 tenant_id: "tenant".into(),
1973 project_id: "project".into(),
1974 capabilities: Vec::new(),
1975 },
1976 )
1977 .unwrap();
1978 router(
1979 Arc::new(FakeLink { terminal }),
1980 Arc::new(auth),
1981 Arc::new(AlwaysReady),
1982 RuntimeTransportConfig {
1983 agent_tasks: true,
1984 openai_responses: true,
1985 claude_messages: true,
1986 sse: true,
1987 websocket: true,
1988 websocket_max_message_bytes: 256 * 1024,
1989 },
1990 )
1991 }
1992
1993 fn create_request(authenticated: bool) -> Request<Body> {
1994 let mut request =
1995 Request::post("/v1/agent/tasks").header("content-type", "application/json");
1996 if authenticated {
1997 request = request.header("authorization", "Bearer token");
1998 }
1999 request.body(Body::from(r#"{"runtimeId":"runtime-1","input":{"text":"hello"},"limits":{"deadlineSeconds":60,"maxModelTurns":4,"maxToolCalls":8}}"#)).unwrap()
2000 }
2001
2002 #[tokio::test]
2003 async fn create_requires_authentication_and_returns_contract_body() {
2004 let unauthorized = app().oneshot(create_request(false)).await.unwrap();
2005 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
2006
2007 let response = app().oneshot(create_request(true)).await.unwrap();
2008 assert_eq!(response.status(), StatusCode::ACCEPTED);
2009 assert!(response.headers().contains_key("x-request-id"));
2010 let body = response.into_body().collect().await.unwrap().to_bytes();
2011 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2012 assert_eq!(body["id"], "execution-1");
2013 }
2014
2015 #[tokio::test]
2016 async fn native_task_creation_generates_idempotency_and_returns_task_links() {
2017 let response = app()
2018 .oneshot(
2019 Request::post("/v1/agent/tasks")
2020 .header("authorization", "Bearer token")
2021 .header("content-type", "application/json")
2022 .body(Body::from(
2023 r#"{"runtimeId":"runtime-1","input":{"text":"hello"},"workspace":{"id":"workspace-2"}}"#,
2024 ))
2025 .unwrap(),
2026 )
2027 .await
2028 .unwrap();
2029 assert_eq!(response.status(), StatusCode::ACCEPTED);
2030 assert_eq!(
2031 response.headers().get(header::LOCATION).unwrap(),
2032 "/v1/agent/tasks/execution-1"
2033 );
2034 assert_eq!(response.headers().get(header::RETRY_AFTER).unwrap(), "1");
2035 let body = response.into_body().collect().await.unwrap().to_bytes();
2036 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2037 assert_eq!(body["object"], "agent.task");
2038 assert_eq!(body["workspace"]["id"], "workspace-2");
2039 assert!(body["metadata"]["requestId"].is_string());
2040 assert_eq!(
2041 body["links"]["stream"],
2042 "/v1/agent/tasks/execution-1/stream"
2043 );
2044 }
2045
2046 #[tokio::test]
2047 async fn openai_background_response_uses_openai_envelope() {
2048 let response = app()
2049 .oneshot(
2050 Request::post("/v1/responses")
2051 .header("authorization", "Bearer token")
2052 .header("content-type", "application/json")
2053 .body(Body::from(
2054 r#"{"model":"runtime-1","input":"hello","background":true,"max_output_tokens":256}"#,
2055 ))
2056 .unwrap(),
2057 )
2058 .await
2059 .unwrap();
2060 assert_eq!(response.status(), StatusCode::OK);
2061 let body = response.into_body().collect().await.unwrap().to_bytes();
2062 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2063 assert_eq!(body["object"], "response");
2064 assert!(body["id"].as_str().unwrap().starts_with("resp_"));
2065 assert_eq!(body["status"], "in_progress");
2066 assert_eq!(body["max_output_tokens"], 256);
2067 }
2068
2069 #[test]
2070 fn provider_parameters_fuse_into_one_generation_contract() {
2071 let openai: OpenAiResponseRequest = serde_json::from_value(json!({
2072 "model":"runtime-1",
2073 "input":"hello",
2074 "max_output_tokens":512,
2075 "temperature":0.25,
2076 "text":{"format":{"type":"json_object"}},
2077 "tool_choice":"none"
2078 }))
2079 .unwrap();
2080 let execution = openai_execution_request(&openai).unwrap();
2081 assert_eq!(execution.generation.max_output_tokens, Some(512));
2082 assert_eq!(execution.generation.temperature, Some(0.25));
2083 assert_eq!(
2084 execution.generation.response_format,
2085 Some(json!({"type":"json_object"}))
2086 );
2087 assert_eq!(execution.generation.tool_choice, ModelToolChoice::None);
2088
2089 let claude: ClaudeMessageRequest = serde_json::from_value(json!({
2090 "model":"runtime-1",
2091 "max_tokens":256,
2092 "messages":[{"role":"user","content":"hello"}],
2093 "temperature":0.4,
2094 "stop_sequences":["</done>"],
2095 "tool_choice":{"type":"none"}
2096 }))
2097 .unwrap();
2098 let execution = claude_execution_request(&claude).unwrap();
2099 assert_eq!(execution.generation.max_output_tokens, Some(256));
2100 assert_eq!(execution.generation.temperature, Some(0.4));
2101 assert_eq!(execution.generation.stop_sequences, ["</done>"]);
2102 assert_eq!(execution.generation.tool_choice, ModelToolChoice::None);
2103 assert_eq!(execution.generation.response_format, None);
2104 }
2105
2106 #[tokio::test]
2107 async fn provider_adapters_reject_fields_the_kernel_cannot_preserve() {
2108 let openai = app()
2109 .oneshot(
2110 Request::post("/v1/responses")
2111 .header("authorization", "Bearer token")
2112 .header("content-type", "application/json")
2113 .body(Body::from(
2114 r#"{"model":"runtime-1","input":"hello","tools":[{"type":"function","name":"remote"}]}"#,
2115 ))
2116 .unwrap(),
2117 )
2118 .await
2119 .unwrap();
2120 assert_eq!(openai.status(), StatusCode::BAD_REQUEST);
2121 let body = openai.into_body().collect().await.unwrap().to_bytes();
2122 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2123 assert_eq!(body["error"]["param"], "tools");
2124
2125 let claude = app()
2126 .oneshot(
2127 Request::post("/v1/messages")
2128 .header("authorization", "Bearer token")
2129 .header("content-type", "application/json")
2130 .body(Body::from(
2131 r#"{"model":"runtime-1","max_tokens":128,"messages":[{"role":"user","content":"hello"}]}"#,
2132 ))
2133 .unwrap(),
2134 )
2135 .await
2136 .unwrap();
2137 assert_eq!(claude.status(), StatusCode::BAD_REQUEST);
2138 let body = claude.into_body().collect().await.unwrap().to_bytes();
2139 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2140 assert_eq!(body["type"], "error");
2141 assert_eq!(body["error"]["type"], "invalid_request_error");
2142 }
2143
2144 #[tokio::test]
2145 async fn claude_sdk_style_api_key_authentication_is_accepted() {
2146 let response = app_with_terminal(true)
2147 .oneshot(
2148 Request::post("/v1/messages")
2149 .header("x-api-key", "token")
2150 .header("anthropic-version", "2023-06-01")
2151 .header("content-type", "application/json")
2152 .body(Body::from(
2153 r#"{"model":"runtime-1","max_tokens":128,"messages":[{"role":"user","content":"hello"}]}"#,
2154 ))
2155 .unwrap(),
2156 )
2157 .await
2158 .unwrap();
2159 assert_eq!(response.status(), StatusCode::OK);
2160 let body = response.into_body().collect().await.unwrap().to_bytes();
2161 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
2162 assert_eq!(body["type"], "message");
2163 }
2164
2165 #[tokio::test]
2166 async fn provider_sse_uses_native_event_names_and_terminal_order() {
2167 let openai = app_with_terminal(true)
2168 .oneshot(
2169 Request::post("/v1/responses")
2170 .header("authorization", "Bearer token")
2171 .header("content-type", "application/json")
2172 .body(Body::from(
2173 r#"{"model":"runtime-1","input":"complete","stream":true}"#,
2174 ))
2175 .unwrap(),
2176 )
2177 .await
2178 .unwrap();
2179 assert_eq!(openai.status(), StatusCode::OK);
2180 let openai = String::from_utf8(
2181 openai
2182 .into_body()
2183 .collect()
2184 .await
2185 .unwrap()
2186 .to_bytes()
2187 .to_vec(),
2188 )
2189 .unwrap();
2190 let created = openai.find("event: response.created").unwrap();
2191 let delta = openai.find("event: response.output_text.delta").unwrap();
2192 let completed = openai.find("event: response.completed").unwrap();
2193 assert!(created < delta && delta < completed);
2194
2195 let claude = app_with_terminal(true)
2196 .oneshot(
2197 Request::post("/v1/messages")
2198 .header("authorization", "Bearer token")
2199 .header("anthropic-version", "2023-06-01")
2200 .header("content-type", "application/json")
2201 .body(Body::from(
2202 r#"{"model":"runtime-1","max_tokens":128,"messages":[{"role":"user","content":"complete"}],"stream":true}"#,
2203 ))
2204 .unwrap(),
2205 )
2206 .await
2207 .unwrap();
2208 assert_eq!(claude.status(), StatusCode::OK);
2209 let claude = String::from_utf8(
2210 claude
2211 .into_body()
2212 .collect()
2213 .await
2214 .unwrap()
2215 .to_bytes()
2216 .to_vec(),
2217 )
2218 .unwrap();
2219 let start = claude.find("event: message_start").unwrap();
2220 let delta = claude.find("event: content_block_delta").unwrap();
2221 let stop = claude.find("event: message_stop").unwrap();
2222 assert!(start < delta && delta < stop);
2223 }
2224
2225 #[test]
2226 fn sse_resume_accepts_standard_header_and_rejects_ambiguous_cursors() {
2227 let mut headers = HeaderMap::new();
2228 headers.insert("last-event-id", "17".parse().unwrap());
2229 assert_eq!(sse_resume_after(&headers, None).unwrap(), Some(17));
2230 assert_eq!(sse_resume_after(&headers, Some(17)).unwrap(), Some(17));
2231 assert!(sse_resume_after(&headers, Some(18)).is_err());
2232 }
2233
2234 #[test]
2235 fn provider_message_history_preserves_roles_for_the_kernel() {
2236 let openai: OpenAiResponseRequest = serde_json::from_value(json!({
2237 "model": "runtime-1",
2238 "input": [
2239 {"role":"user","content":"question"},
2240 {"role":"assistant","content":"draft"},
2241 {"role":"user","content":"continue"}
2242 ]
2243 }))
2244 .unwrap();
2245 let execution = openai_execution_request(&openai).unwrap();
2246 let RuntimeInput::Messages { messages } = execution.input else {
2247 panic!("provider history must remain structured");
2248 };
2249 assert_eq!(
2250 messages
2251 .iter()
2252 .map(|message| message.role)
2253 .collect::<Vec<_>>(),
2254 vec![
2255 RuntimeMessageRole::User,
2256 RuntimeMessageRole::Assistant,
2257 RuntimeMessageRole::User,
2258 ]
2259 );
2260 }
2261
2262 #[tokio::test]
2263 async fn trusted_gateway_scope_requires_the_shared_ingress_secret() {
2264 let authenticator =
2265 TrustedGatewayAuthenticator::new("a-runtime-gateway-secret-with-at-least-32-bytes")
2266 .unwrap();
2267 let mut headers = HeaderMap::new();
2268 headers.insert("authorization", "Bearer delegated-token".parse().unwrap());
2269 headers.insert("x-runtime-subject", "user".parse().unwrap());
2270 headers.insert("x-runtime-tenant-id", "tenant".parse().unwrap());
2271 headers.insert("x-runtime-project-id", "project".parse().unwrap());
2272 assert!(authenticator.authenticate(&headers).await.is_err());
2273 headers.insert(
2274 "x-runtime-gateway-secret",
2275 "a-runtime-gateway-secret-with-at-least-32-bytes"
2276 .parse()
2277 .unwrap(),
2278 );
2279 let authority = authenticator.authenticate(&headers).await.unwrap();
2280 assert_eq!(authority.caller.subject, "user");
2281 }
2282
2283 #[tokio::test]
2284 async fn transport_routes_are_registered_only_when_enabled() {
2285 let auth = StaticTokenAuthenticator::new(
2286 "token",
2287 CallerScope {
2288 subject: "user".into(),
2289 tenant_id: "tenant".into(),
2290 project_id: "project".into(),
2291 capabilities: Vec::new(),
2292 },
2293 )
2294 .unwrap();
2295 let app = router(
2296 Arc::new(FakeLink { terminal: false }),
2297 Arc::new(auth),
2298 Arc::new(AlwaysReady),
2299 RuntimeTransportConfig {
2300 agent_tasks: true,
2301 openai_responses: false,
2302 claude_messages: false,
2303 sse: false,
2304 websocket: false,
2305 websocket_max_message_bytes: 1024,
2306 },
2307 );
2308 let stream = app
2309 .clone()
2310 .oneshot(
2311 Request::get("/v1/agent/tasks/execution-1/stream")
2312 .header("authorization", "Bearer token")
2313 .body(Body::empty())
2314 .unwrap(),
2315 )
2316 .await
2317 .unwrap();
2318 assert_eq!(stream.status(), StatusCode::NOT_FOUND);
2319 let openai = app
2320 .clone()
2321 .oneshot(Request::post("/v1/responses").body(Body::empty()).unwrap())
2322 .await
2323 .unwrap();
2324 assert_eq!(openai.status(), StatusCode::NOT_FOUND);
2325 let openapi = app
2326 .oneshot(Request::get("/openapi.json").body(Body::empty()).unwrap())
2327 .await
2328 .unwrap();
2329 let body = openapi.into_body().collect().await.unwrap().to_bytes();
2330 let document: serde_json::Value = serde_json::from_slice(&body).unwrap();
2331 assert!(
2332 document["paths"]
2333 .get("/v1/agent/tasks/{taskId}/stream")
2334 .is_none()
2335 );
2336 assert!(
2337 document["paths"]
2338 .get("/v1/agent/tasks/{taskId}/ws")
2339 .is_none()
2340 );
2341 assert!(document["paths"].get("/v1/responses").is_none());
2342 assert!(document["paths"].get("/v1/messages").is_none());
2343 }
2344
2345 #[tokio::test]
2346 async fn websocket_is_authenticated_and_accepts_scoped_commands() {
2347 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2348 let address = listener.local_addr().unwrap();
2349 let server = tokio::spawn(async move { axum::serve(listener, app()).await.unwrap() });
2350
2351 let mut request = format!("ws://{address}/v1/agent/tasks/execution-1/ws")
2352 .into_client_request()
2353 .unwrap();
2354 request
2355 .headers_mut()
2356 .insert("authorization", "Bearer token".parse().unwrap());
2357 let (mut socket, response) = connect_async(request).await.unwrap();
2358 assert_eq!(response.status(), 101);
2359
2360 let connected = socket.next().await.unwrap().unwrap().into_text().unwrap();
2361 let connected: AgentTaskWebSocketServerMessage = serde_json::from_str(&connected).unwrap();
2362 assert!(matches!(
2363 connected,
2364 AgentTaskWebSocketServerMessage::Connected { .. }
2365 ));
2366
2367 socket
2368 .send(ClientMessage::Text(r#"{"type":"cancel"}"#.into()))
2369 .await
2370 .unwrap();
2371 let execution = socket.next().await.unwrap().unwrap().into_text().unwrap();
2372 let execution: AgentTaskWebSocketServerMessage = serde_json::from_str(&execution).unwrap();
2373 assert!(matches!(
2374 execution,
2375 AgentTaskWebSocketServerMessage::Task { .. }
2376 ));
2377 socket.close(None).await.unwrap();
2378 server.abort();
2379 }
2380}