tensor_wasm_api/openai.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! OpenAI-compatible inference gateway shim.
5//!
6//! v0.3.5 landed the *scaffold* (request types, error envelope, two
7//! handlers returning `501 openai_not_yet_wired`); **T41 (v0.4)** wires
8//! the handlers through to the internal invoke protocol. The handlers
9//! accept the standard OpenAI request bodies (so off-the-shelf SDKs
10//! send valid JSON) and now resolve the `model` field against an
11//! env-configured `model → function_uuid` map, dispatch the call via
12//! the shared [`tensor_wasm_exec::executor::TensorWasmExecutor`], and
13//! wrap the guest output in the OpenAI response envelope (or stream it
14//! as OpenAI-shape `data:` SSE frames when `stream: true`).
15//!
16//! Legacy `501 Not Implemented` error envelope shape (still emitted by
17//! the deprecated [`OpenAiError::not_yet_wired`] helper for routes that
18//! deliberately remain stubs):
19//!
20//! ```json
21//! { "error": {
22//! "message": "...",
23//! "type": "not_implemented",
24//! "param": null,
25//! "code": "openai_not_yet_wired"
26//! }}
27//! ```
28//!
29//! The v0.4 follow-up wires the actual translation step (resolve the
30//! requested `model` to a deployed `FunctionRecord`, marshal the prompt
31//! / messages into the wasm guest's `_start` argv, stream tokens out as
32//! OpenAI-shape `data:` SSE chunks). The scaffold exists so:
33//!
34//! * the route shape (path, method, request body) is locked in early —
35//! clients can begin integrating against the gateway's URL surface
36//! without depending on the translator's readiness;
37//! * the error envelope shape (the four-field OpenAI object, distinct
38//! from the gateway's native `{ error: { kind, message } }` shell) is
39//! committed to the public contract and exercised by integration
40//! tests;
41//! * the OpenAPI spec at `openapi/tensor-wasm-api.yaml` documents the
42//! surface up front, so downstream API-doc tooling renders both
43//! surfaces from a single source.
44//!
45//! ## Security: tenant resolution
46//!
47//! OpenAI clients send `Authorization: Bearer <api_key>` but never an
48//! `X-TensorWasm-Tenant` header. The gateway's native routes derive the
49//! tenant from that header (via the `tenant_scope` middleware); the
50//! OpenAI routes cannot, because the header is absent on the wire.
51//!
52//! The v0.4 implementation will derive the tenant from the bearer
53//! token's [`TokenScope`](crate::token_scope::TokenScope): a scoped
54//! token (`mykey:tenant=7`) implies tenant 7; a wildcard token implies
55//! the default tenant (0) with a one-shot warning. Clients should
56//! provision one bearer token per tenant in
57//! `$TENSOR_WASM_API_TOKENS`. This is why the OpenAI routes are mounted
58//! *outside* the `tenant_scope` middleware in `server.rs` — the layer
59//! would reject every OpenAI request as `missing_tenant` 400 otherwise.
60//!
61//! Bearer auth itself still runs on these routes (mounted inside the
62//! `bearer_auth` middleware): an unauthenticated OpenAI client must
63//! receive `401`, not `501`. The current scaffold leaves the auth /
64//! rate-limit / audit composition for the server module to wire; this
65//! file owns only the request type definitions, the error envelope, and
66//! the two handlers.
67
68use std::sync::Arc;
69use std::time::{Duration, SystemTime, UNIX_EPOCH};
70
71use axum::extract::{Extension, State};
72use axum::http::StatusCode;
73use axum::response::sse::{Event, KeepAlive, Sse};
74use axum::response::{IntoResponse, Response};
75use axum::{extract::rejection::JsonRejection, Json};
76use serde::{Deserialize, Serialize};
77use tensor_wasm_core::types::TenantId;
78use tensor_wasm_exec::executor::{ExecError, SpawnConfig};
79use tensor_wasm_wasi_gpu::streaming::StreamingContext;
80use uuid::Uuid;
81
82use crate::openai_translator::{
83 translate_chat_completions_request, translate_completions_request, TranslatedRequest,
84};
85use crate::rate_limit::AuthContext;
86use crate::routes::AppState;
87
88// ---------------------------------------------------------------------------
89// Request types
90// ---------------------------------------------------------------------------
91
92/// Body of `POST /v1/completions`.
93///
94/// Mirrors the public OpenAI REST contract documented at
95/// <https://platform.openai.com/docs/api-reference/completions/create>.
96///
97/// Every field is `#[serde(default)]` so an SDK that omits an optional
98/// knob still deserialises cleanly. We do not (yet) validate the values
99/// — the scaffold's only contract is "the request parses; we then
100/// reject with 501". The v0.4 wiring step adds:
101///
102/// * `model` → `FunctionRecord` lookup;
103/// * `prompt` → guest argv marshalling;
104/// * `max_tokens` / `temperature` / `stream` → executor knobs.
105#[derive(Debug, Deserialize, Serialize, Clone, Default)]
106#[non_exhaustive]
107pub struct CompletionsRequest {
108 /// Model identifier. In v0.4 this will resolve to a deployed
109 /// `FunctionRecord` via a `model` → `function_id` map (env-driven
110 /// allowlist + alias table).
111 #[serde(default)]
112 pub model: String,
113 /// Prompt text. Accepts either a single string or an array of
114 /// strings on the wire — represented here as `serde_json::Value`
115 /// so the scaffold does not commit to one shape ahead of the
116 /// translator.
117 #[serde(default)]
118 pub prompt: serde_json::Value,
119 /// Maximum tokens to generate. Optional in the OpenAI contract;
120 /// defaults to 16 if absent on the wire (v0.4 will mirror).
121 #[serde(default)]
122 pub max_tokens: Option<u32>,
123 /// Sampling temperature in `[0.0, 2.0]`. Optional; defaults to 1.0.
124 #[serde(default)]
125 pub temperature: Option<f32>,
126 /// Stream the response as SSE if true. v0.4 wires; scaffold ignores.
127 #[serde(default)]
128 pub stream: Option<bool>,
129 /// Echo of the input plus completion (OpenAI compat knob).
130 #[serde(default)]
131 pub echo: Option<bool>,
132 /// Number of completions to generate per prompt.
133 #[serde(default)]
134 pub n: Option<u32>,
135 /// Caller-supplied request id, surfaced back on the response in
136 /// OpenAI's own logs. We accept and ignore.
137 #[serde(default)]
138 pub user: Option<String>,
139}
140
141/// One entry in the `messages` array of `POST /v1/chat/completions`.
142#[derive(Debug, Deserialize, Serialize, Clone, Default)]
143#[non_exhaustive]
144pub struct ChatMessage {
145 /// One of `system`, `user`, `assistant`, `tool` (OpenAI shape).
146 /// Free-form on the wire; the scaffold does not validate.
147 #[serde(default)]
148 pub role: String,
149 /// Message content. May be a string or a content-array on the wire
150 /// (OpenAI supports multimodal messages); we accept either shape
151 /// via `serde_json::Value`.
152 #[serde(default)]
153 pub content: serde_json::Value,
154 /// Optional speaker name for the `system` / `user` roles.
155 #[serde(default)]
156 pub name: Option<String>,
157}
158
159/// Body of `POST /v1/chat/completions`.
160///
161/// Mirrors the public OpenAI REST contract documented at
162/// <https://platform.openai.com/docs/api-reference/chat/create>.
163#[derive(Debug, Deserialize, Serialize, Clone, Default)]
164#[non_exhaustive]
165pub struct ChatCompletionsRequest {
166 /// Model identifier. See [`CompletionsRequest::model`].
167 #[serde(default)]
168 pub model: String,
169 /// Conversation history. Required by OpenAI; the scaffold rejects
170 /// at the 501 step so an empty vector still parses.
171 #[serde(default)]
172 pub messages: Vec<ChatMessage>,
173 /// Maximum tokens to generate per response.
174 #[serde(default)]
175 pub max_tokens: Option<u32>,
176 /// Sampling temperature in `[0.0, 2.0]`.
177 #[serde(default)]
178 pub temperature: Option<f32>,
179 /// Stream the response as SSE if true.
180 #[serde(default)]
181 pub stream: Option<bool>,
182 /// Number of completions to generate per prompt.
183 #[serde(default)]
184 pub n: Option<u32>,
185 /// Optional `tools` array (OpenAI tool-calling). v0.4 wires.
186 #[serde(default)]
187 pub tools: Option<serde_json::Value>,
188 /// Caller-supplied opaque user identifier.
189 #[serde(default)]
190 pub user: Option<String>,
191}
192
193// ---------------------------------------------------------------------------
194// Error envelope (OpenAI-shape, distinct from the native ApiErrorEnvelope)
195// ---------------------------------------------------------------------------
196
197/// Inner OpenAI error body. The shape is:
198///
199/// ```json
200/// { "message": "...", "type": "...", "param": null, "code": "..." }
201/// ```
202///
203/// This intentionally does **not** match the gateway's native
204/// `{ error: { kind, message } }` envelope: OpenAI SDKs parse the
205/// four-field shape verbatim and will not look at our native shell.
206#[derive(Debug, Serialize, Deserialize, Clone)]
207pub struct OpenAiErrorBody {
208 /// Human-readable error description.
209 pub message: String,
210 /// OpenAI-conventional error category (e.g. `invalid_request_error`,
211 /// `not_implemented`). String, not enum, because the OpenAI contract
212 /// itself adds new types over time.
213 #[serde(rename = "type")]
214 pub kind: String,
215 /// Name of the request field that triggered the error, if any.
216 /// `null` for whole-request errors (the scaffold's 501).
217 pub param: Option<String>,
218 /// Stable machine-readable code that callers branch on. Scaffold
219 /// returns `openai_not_yet_wired`.
220 pub code: Option<String>,
221}
222
223/// Top-level OpenAI error envelope: `{ "error": { ... } }`.
224#[derive(Debug, Serialize, Deserialize, Clone)]
225pub struct OpenAiError {
226 /// Inner error body.
227 pub error: OpenAiErrorBody,
228}
229
230impl OpenAiError {
231 /// Construct a not-implemented envelope. Used by both scaffold
232 /// handlers to keep the wire output identical.
233 pub fn not_yet_wired(message: impl Into<String>) -> Self {
234 Self {
235 error: OpenAiErrorBody {
236 message: message.into(),
237 kind: "not_implemented".to_string(),
238 param: None,
239 code: Some("openai_not_yet_wired".to_string()),
240 },
241 }
242 }
243
244 /// Construct an `invalid_request_error` envelope for malformed input.
245 /// `param` should name the field that triggered the error, if known.
246 pub fn invalid_request(message: impl Into<String>, param: Option<String>) -> Self {
247 Self {
248 error: OpenAiErrorBody {
249 message: message.into(),
250 kind: "invalid_request_error".to_string(),
251 param,
252 code: Some("openai_invalid_request".to_string()),
253 },
254 }
255 }
256
257 /// Override the machine-readable `code` on an existing envelope.
258 ///
259 /// Used by the translator to stamp a more specific code
260 /// (e.g. `unsupported_parameter`) onto an `invalid_request` envelope
261 /// while keeping its `type` / `param` / status mapping intact — an
262 /// `invalid_request_error` with any code other than `model_not_found`
263 /// still maps to `400` in [`OpenAiError::into_response`].
264 pub fn with_code(mut self, code: &'static str) -> Self {
265 self.error.code = Some(code.to_string());
266 self
267 }
268
269 /// Construct a `model_not_found` envelope for an unknown model id.
270 ///
271 /// Mirrors the OpenAI `404 model_not_found` response shape so SDKs
272 /// surface a clean "this model is not available on this endpoint"
273 /// error to the caller. `param` is fixed to `Some("model")` —
274 /// every `model_not_found` is caused by the same field.
275 pub fn model_not_found(message: impl Into<String>) -> Self {
276 Self {
277 error: OpenAiErrorBody {
278 message: message.into(),
279 kind: "invalid_request_error".to_string(),
280 param: Some("model".to_string()),
281 code: Some("model_not_found".to_string()),
282 },
283 }
284 }
285
286 /// Construct a generic server-side `internal` envelope.
287 ///
288 /// Used when guest execution fails in a way that doesn't map to a
289 /// caller-correctable category (e.g. a wasm trap, an executor
290 /// configuration error). The OpenAI `type` is set to
291 /// `server_error` and `code` to `wasm_error` so callers can branch
292 /// on either the OpenAI-conventional category or the
293 /// implementation-specific code.
294 pub fn server_error(message: impl Into<String>, code: &'static str) -> Self {
295 Self {
296 error: OpenAiErrorBody {
297 message: message.into(),
298 kind: "server_error".to_string(),
299 param: None,
300 code: Some(code.to_string()),
301 },
302 }
303 }
304
305 /// Construct a `403 tenant_scope_denied` envelope.
306 ///
307 /// Mirrors the native invoke handlers' per-resource owner check
308 /// (`routes.rs`, e.g. `FunctionRecord::tenant_id != tenant` →
309 /// [`ApiError::forbidden`](crate::routes::ApiError::forbidden) with
310 /// `kind = "tenant_scope_denied"`): a wildcard-scoped caller from
311 /// tenant B must not drive a model that resolves to tenant A's
312 /// function. The OpenAI `type` is `invalid_request_error` (OpenAI has
313 /// no dedicated authorization category) with `code = "tenant_scope_denied"`,
314 /// which [`OpenAiError::into_response`] maps to `403`.
315 pub fn tenant_scope_denied(message: impl Into<String>) -> Self {
316 Self {
317 error: OpenAiErrorBody {
318 message: message.into(),
319 kind: "invalid_request_error".to_string(),
320 param: None,
321 code: Some("tenant_scope_denied".to_string()),
322 },
323 }
324 }
325}
326
327impl IntoResponse for OpenAiError {
328 fn into_response(self) -> Response {
329 // Status is chosen from the (kind, code) pair. The OpenAI public
330 // contract reuses `invalid_request_error` for both "your request
331 // body was malformed" (400) and "the requested model does not
332 // exist" (404); SDKs branch on the `code` to disambiguate. We
333 // mirror that wire shape here, and additionally map our
334 // `tenant_scope_denied` code to `403` for the per-resource owner
335 // check (api S-IDOR).
336 let status = match (self.error.kind.as_str(), self.error.code.as_deref()) {
337 ("invalid_request_error", Some("model_not_found")) => StatusCode::NOT_FOUND,
338 ("invalid_request_error", Some("tenant_scope_denied")) => StatusCode::FORBIDDEN,
339 ("invalid_request_error", _) => StatusCode::BAD_REQUEST,
340 ("server_error", _) => StatusCode::INTERNAL_SERVER_ERROR,
341 _ => StatusCode::NOT_IMPLEMENTED,
342 };
343 (status, Json(self)).into_response()
344 }
345}
346
347// ---------------------------------------------------------------------------
348// Wired handlers (T41 v0.4)
349// ---------------------------------------------------------------------------
350
351/// Per-invocation deadline applied to every OpenAI-routed call. Matches
352/// the native `/invoke` default so the two surfaces share a budget.
353const OPENAI_INVOKE_DEADLINE: Duration = Duration::from_secs(30);
354
355/// Channel buffer for streaming chunks (mirrors the
356/// `STREAMING_CHANNEL_BUFFER` constant on `/invoke-stream`). Enough to
357/// absorb a short burst from the guest while the SSE writer drains its
358/// TCP send buffer; small enough to apply natural back-pressure once
359/// the writer stalls.
360const OPENAI_STREAM_BUFFER: usize = 32;
361
362/// Generate a millisecond-precision Unix `created` timestamp for the
363/// response envelope. OpenAI's contract emits seconds; we emit seconds
364/// here too so SDKs that compute `time.time() - created` work without
365/// scaling.
366fn unix_seconds_now() -> u64 {
367 match SystemTime::now().duration_since(UNIX_EPOCH) {
368 Ok(d) => d.as_secs(),
369 Err(_) => 0,
370 }
371}
372
373/// Build the OpenAI `usage` block. v0.4 ships with zeros because the
374/// gateway does not yet wire a tokenizer; v0.5 lands a real counter
375/// (see `docs/OPENAI-COMPAT.md`).
376fn empty_usage() -> serde_json::Value {
377 serde_json::json!({
378 "prompt_tokens": 0,
379 "completion_tokens": 0,
380 "total_tokens": 0,
381 })
382}
383
384/// `POST /v1/completions` — OpenAI completions shim, wired through to
385/// the internal invoke protocol (T41, v0.4).
386///
387/// Flow:
388///
389/// 1. Apply the tenant-scope gate (T2) on the bearer token's
390/// [`crate::token_scope::TokenScope`] against the resolved tenant
391/// (`TenantId(0)` for OpenAI SDKs that don't send
392/// `X-TensorWasm-Tenant`).
393/// 2. Parse the request body. Malformed JSON → `400
394/// openai_invalid_request`.
395/// 3. Resolve `req.model` against
396/// [`AppState::openai_model_map`](crate::routes::AppState::openai_model_map).
397/// Miss → `404 model_not_found`.
398/// 4. Look up the resolved function id in
399/// [`AppState::functions`](crate::routes::AppState::functions). Miss
400/// → `404 model_not_found` (the map points at a deleted record).
401/// A hit owned by a different tenant → `403 tenant_scope_denied`
402/// (per-resource owner check, api S-IDOR).
403/// 5. If `stream: true`, build a
404/// [`StreamingContext`]
405/// + receiver pair (the T34 plumbing), spawn the call, and stream
406/// chunks back as OpenAI `data: { ... }` SSE frames terminated by
407/// `data: [DONE]\n\n`.
408/// 6. If `stream: false`, run the call synchronously, collect any
409/// emitted chunks (or fall back to a host-stamped placeholder), and
410/// respond with the OpenAI `text_completion` envelope.
411#[tracing::instrument(
412 name = "http.openai.completions",
413 skip(state, payload, auth),
414 fields(model = tracing::field::Empty, stream = tracing::field::Empty),
415)]
416pub async fn completions_handler(
417 State(state): State<Arc<AppState>>,
418 tenant: Option<Extension<TenantId>>,
419 auth: Option<Extension<AuthContext>>,
420 payload: Result<Json<CompletionsRequest>, JsonRejection>,
421) -> Response {
422 let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
423 // Fail closed if the AuthContext extension is absent (router wired
424 // without `bearer_auth`): `require_authorize` surfaces `500 internal`
425 // rather than silently skipping the per-tenant scope check.
426 if let Err(err) = crate::routes::require_authorize(&auth, tenant) {
427 return err.into_response();
428 }
429
430 let req = match payload {
431 Ok(Json(r)) => r,
432 Err(rej) => {
433 return (
434 StatusCode::BAD_REQUEST,
435 Json(OpenAiError::invalid_request(rej.body_text(), None)),
436 )
437 .into_response();
438 }
439 };
440 tracing::Span::current().record("model", tracing::field::display(&req.model));
441 let model_echo = req.model.clone();
442
443 let translated = match translate_completions_request(&req, state.openai_model_map.as_ref()) {
444 Ok(t) => t,
445 Err(e) => return e.into_response(),
446 };
447 tracing::Span::current().record("stream", tracing::field::display(translated.stream));
448
449 run_translated(
450 state,
451 tenant,
452 translated,
453 model_echo,
454 OpenAiObject::TextCompletion,
455 )
456 .await
457}
458
459/// `POST /v1/chat/completions` — OpenAI chat-completions shim, wired
460/// through to the internal invoke protocol (T41, v0.4).
461///
462/// Same flow as [`completions_handler`] but with the chat envelope
463/// (response `object: "chat.completion"`, choices carry a
464/// `{role, content}` message object) and the `messages` array
465/// concatenated into a single prompt string via
466/// [`crate::openai_translator::assemble_chat_prompt`].
467#[tracing::instrument(
468 name = "http.openai.chat_completions",
469 skip(state, payload, auth),
470 fields(model = tracing::field::Empty, stream = tracing::field::Empty),
471)]
472pub async fn chat_completions_handler(
473 State(state): State<Arc<AppState>>,
474 tenant: Option<Extension<TenantId>>,
475 auth: Option<Extension<AuthContext>>,
476 payload: Result<Json<ChatCompletionsRequest>, JsonRejection>,
477) -> Response {
478 let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
479 // Fail closed if the AuthContext extension is absent (router wired
480 // without `bearer_auth`): `require_authorize` surfaces `500 internal`
481 // rather than silently skipping the per-tenant scope check.
482 if let Err(err) = crate::routes::require_authorize(&auth, tenant) {
483 return err.into_response();
484 }
485
486 let req = match payload {
487 Ok(Json(r)) => r,
488 Err(rej) => {
489 return (
490 StatusCode::BAD_REQUEST,
491 Json(OpenAiError::invalid_request(rej.body_text(), None)),
492 )
493 .into_response();
494 }
495 };
496 tracing::Span::current().record("model", tracing::field::display(&req.model));
497 let model_echo = req.model.clone();
498
499 let translated = match translate_chat_completions_request(&req, state.openai_model_map.as_ref())
500 {
501 Ok(t) => t,
502 Err(e) => return e.into_response(),
503 };
504 tracing::Span::current().record("stream", tracing::field::display(translated.stream));
505
506 run_translated(
507 state,
508 tenant,
509 translated,
510 model_echo,
511 OpenAiObject::ChatCompletion,
512 )
513 .await
514}
515
516/// Which OpenAI response envelope to wrap the guest output in.
517///
518/// `TextCompletion` is the `/v1/completions` shape:
519/// `choices[i].text` carries the generated string. `ChatCompletion` is
520/// `/v1/chat/completions`: `choices[i].message = {"role":"assistant","content":...}`.
521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
522enum OpenAiObject {
523 /// `object: "text_completion"`.
524 TextCompletion,
525 /// `object: "chat.completion"`.
526 ChatCompletion,
527}
528
529impl OpenAiObject {
530 fn object_kind(self) -> &'static str {
531 match self {
532 OpenAiObject::TextCompletion => "text_completion",
533 OpenAiObject::ChatCompletion => "chat.completion",
534 }
535 }
536
537 fn chunk_object_kind(self) -> &'static str {
538 match self {
539 OpenAiObject::TextCompletion => "text_completion",
540 OpenAiObject::ChatCompletion => "chat.completion.chunk",
541 }
542 }
543
544 fn id_prefix(self) -> &'static str {
545 match self {
546 OpenAiObject::TextCompletion => "cmpl-",
547 OpenAiObject::ChatCompletion => "chatcmpl-",
548 }
549 }
550}
551
552/// Drive a translated request through the executor. Branches on
553/// `translated.stream` to either return a buffered JSON envelope
554/// (non-streaming) or a `text/event-stream` response (streaming).
555async fn run_translated(
556 state: Arc<AppState>,
557 tenant: TenantId,
558 translated: TranslatedRequest,
559 model_echo: String,
560 object_kind: OpenAiObject,
561) -> Response {
562 // Look up the resolved function id in the deployment registry.
563 // Missing record = the map points at something that has been
564 // deleted; surface as model_not_found so the caller knows to
565 // refresh their model alias.
566 let wasm_bytes = match state.functions.get(&translated.function_id) {
567 Some(entry) => {
568 // Per-resource owner check (api S-IDOR): the model→function map
569 // is process-global, so a model alias could resolve to a record
570 // owned by a different tenant. The native invoke handlers enforce
571 // `FunctionRecord::tenant_id == tenant` before touching the bytes
572 // (see routes.rs); mirror that here so a wildcard-scoped caller
573 // from tenant B cannot drive tenant A's function. Capture
574 // `tenant_id` from the same `entry` ref before it is dropped.
575 let owner = entry.value().tenant_id;
576 if owner != tenant {
577 return OpenAiError::tenant_scope_denied(
578 "model maps to a function owned by a different tenant",
579 )
580 .into_response();
581 }
582 Arc::clone(&entry.value().wasm_bytes)
583 }
584 None => {
585 return OpenAiError::model_not_found(format!(
586 "model maps to function {} which is no longer deployed",
587 translated.function_id,
588 ))
589 .into_response();
590 }
591 };
592
593 // The prompt now reaches the guest via the `wasi:tensor/host`
594 // pull-model input channel: `run_buffered` / `run_streaming` stage
595 // `translated.prompt` bytes on `SpawnConfig::input`, and the guest
596 // copies them into its own linear memory via
597 // `wasi:tensor/host.read-input` (sizing the read from
598 // `input-len()`). A guest that ignores the input channel still runs
599 // argument-less and produces its output via
600 // `wasi:tensor/host.emit-chunk` as before — staging input is
601 // non-breaking for those guests (`input-len()` is simply unread).
602
603 if translated.stream {
604 run_streaming(
605 state,
606 tenant,
607 wasm_bytes,
608 translated,
609 model_echo,
610 object_kind,
611 )
612 .await
613 } else {
614 run_buffered(
615 state,
616 tenant,
617 wasm_bytes,
618 translated,
619 model_echo,
620 object_kind,
621 )
622 .await
623 }
624}
625
626/// Non-streaming branch. Spawns the call with a `StreamingContext`
627/// attached, buffers every chunk the guest emits into a single string,
628/// and returns the standard OpenAI JSON envelope.
629async fn run_buffered(
630 state: Arc<AppState>,
631 tenant: TenantId,
632 wasm_bytes: Arc<[u8]>,
633 translated: TranslatedRequest,
634 model_echo: String,
635 object_kind: OpenAiObject,
636) -> Response {
637 let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(OPENAI_STREAM_BUFFER);
638 let streaming = StreamingContext::with_channel(chunk_tx);
639
640 let executor = state.executor.clone();
641 let args = translated.args.clone();
642 let function_id = translated.function_id;
643 // Stage the assembled prompt so the guest can pull it via
644 // `wasi:tensor/host.read-input`. Empty prompts stage nothing (the
645 // guest sees `input-len() == 0`).
646 let input = translated.prompt.into_bytes();
647
648 let exec_handle = tokio::spawn(async move {
649 let cfg = SpawnConfig::for_tenant(tenant)
650 .with_deadline(OPENAI_INVOKE_DEADLINE)
651 .with_streaming(streaming)
652 .with_input(input);
653 let instance_id = executor.spawn_instance(cfg, &wasm_bytes).await?;
654 executor
655 .call_export_with_args_then_terminate(instance_id, "_start", &args)
656 .await
657 .map(|_| ())
658 });
659
660 let mut collected: Vec<u8> = Vec::new();
661 while let Some(chunk) = chunk_rx.recv().await {
662 // Cheap upper bound check against pathologically large guest
663 // output; the per-stream MAX_TOTAL_STREAM_BYTES cap enforced
664 // inside StreamingContext::emit_chunk is the authoritative
665 // limit, but a second host-side ceiling keeps the buffered
666 // branch from OOMing on a misbehaving guest.
667 if collected.len() + chunk.len() > 16 * 1024 * 1024 {
668 tracing::warn!(
669 target: "tensor_wasm_api::openai",
670 function_id = %function_id,
671 "guest output exceeded 16 MiB on the buffered (non-streaming) branch; \
672 truncating",
673 );
674 break;
675 }
676 collected.extend_from_slice(&chunk);
677 }
678
679 let exec_result = match exec_handle.await {
680 Ok(r) => r,
681 Err(e) => {
682 return OpenAiError::server_error(format!("executor task panicked: {e}"), "wasm_error")
683 .into_response();
684 }
685 };
686 if let Err(e) = exec_result {
687 return map_exec_error_to_openai(e).into_response();
688 }
689
690 let text = String::from_utf8_lossy(&collected).into_owned();
691 let response_id = format!("{}{}", object_kind.id_prefix(), Uuid::new_v4());
692 let created = unix_seconds_now();
693
694 let envelope = match object_kind {
695 OpenAiObject::TextCompletion => serde_json::json!({
696 "id": response_id,
697 "object": object_kind.object_kind(),
698 "created": created,
699 "model": model_echo,
700 "choices": [{
701 "text": text,
702 "index": 0,
703 "finish_reason": "stop",
704 "logprobs": serde_json::Value::Null,
705 }],
706 "usage": empty_usage(),
707 }),
708 OpenAiObject::ChatCompletion => serde_json::json!({
709 "id": response_id,
710 "object": object_kind.object_kind(),
711 "created": created,
712 "model": model_echo,
713 "choices": [{
714 "index": 0,
715 "message": {
716 "role": "assistant",
717 "content": text,
718 },
719 "finish_reason": "stop",
720 }],
721 "usage": empty_usage(),
722 }),
723 };
724
725 (StatusCode::OK, Json(envelope)).into_response()
726}
727
728/// Streaming branch. Builds the same StreamingContext + executor
729/// spawn, then wraps the receiver in a futures::Stream that emits one
730/// OpenAI `data:` SSE frame per guest chunk and a final
731/// `data: [DONE]\n\n` terminator.
732async fn run_streaming(
733 state: Arc<AppState>,
734 tenant: TenantId,
735 wasm_bytes: Arc<[u8]>,
736 translated: TranslatedRequest,
737 model_echo: String,
738 object_kind: OpenAiObject,
739) -> Response {
740 let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(OPENAI_STREAM_BUFFER);
741 let streaming = StreamingContext::with_channel(chunk_tx);
742 let (done_tx, done_rx) = tokio::sync::oneshot::channel::<Result<(), ExecError>>();
743
744 let executor = state.executor.clone();
745 let args = translated.args.clone();
746 // Stage the assembled prompt for the guest's `wasi:tensor/host`
747 // pull-model input channel (`read-input`); empty prompts stage
748 // nothing.
749 let input = translated.prompt.into_bytes();
750
751 tokio::spawn(async move {
752 let cfg = SpawnConfig::for_tenant(tenant)
753 .with_deadline(OPENAI_INVOKE_DEADLINE)
754 .with_streaming(streaming)
755 .with_input(input);
756 let outcome = match executor.spawn_instance(cfg, &wasm_bytes).await {
757 Ok(instance_id) => executor
758 .call_export_with_args_then_terminate(instance_id, "_start", &args)
759 .await
760 .map(|_| ()),
761 Err(e) => Err(e),
762 };
763 let _ = done_tx.send(outcome);
764 });
765
766 let response_id = format!("{}{}", object_kind.id_prefix(), Uuid::new_v4());
767 let created = unix_seconds_now();
768
769 // Build the SSE body as an `async_stream`-style unfold so each
770 // emitted chunk becomes one OpenAI-shape `data: {...}` line, and
771 // the terminal `data: [DONE]` line lands after the channel closes.
772 let initial = OpenAiStreamState::Streaming {
773 rx: chunk_rx,
774 done_rx,
775 };
776 let sse_stream = futures::stream::unfold(initial, move |st| {
777 let response_id = response_id.clone();
778 let model_echo = model_echo.clone();
779 async move {
780 match st {
781 OpenAiStreamState::Streaming {
782 mut rx,
783 mut done_rx,
784 } => {
785 tokio::select! {
786 biased;
787 maybe = rx.recv() => match maybe {
788 Some(chunk) => {
789 let ev = make_chunk_event(
790 &response_id,
791 created,
792 &model_echo,
793 object_kind,
794 &chunk,
795 );
796 Some((
797 Ok::<Event, std::convert::Infallible>(ev),
798 OpenAiStreamState::Streaming { rx, done_rx },
799 ))
800 }
801 None => {
802 // Channel closed; pick up terminal status.
803 let terminal = done_rx.await.unwrap_or(Ok(()));
804 Some((
805 Ok(make_terminal_event(
806 &response_id,
807 created,
808 &model_echo,
809 object_kind,
810 terminal.as_ref().err(),
811 )),
812 OpenAiStreamState::EmitDone,
813 ))
814 }
815 },
816 done = &mut done_rx => {
817 // Guest finished before we drained. Try
818 // popping any remaining buffered chunks
819 // synchronously, then emit the terminal
820 // frame.
821 let terminal = done.unwrap_or(Ok(()));
822 match rx.try_recv() {
823 Ok(chunk) => {
824 let ev = make_chunk_event(
825 &response_id,
826 created,
827 &model_echo,
828 object_kind,
829 &chunk,
830 );
831 Some((
832 Ok(ev),
833 OpenAiStreamState::DrainOnly {
834 rx,
835 terminal: Some(terminal),
836 },
837 ))
838 }
839 Err(_) => Some((
840 Ok(make_terminal_event(
841 &response_id,
842 created,
843 &model_echo,
844 object_kind,
845 terminal.as_ref().err(),
846 )),
847 OpenAiStreamState::EmitDone,
848 )),
849 }
850 }
851 }
852 }
853 OpenAiStreamState::DrainOnly { mut rx, terminal } => match rx.try_recv() {
854 Ok(chunk) => {
855 let ev = make_chunk_event(
856 &response_id,
857 created,
858 &model_echo,
859 object_kind,
860 &chunk,
861 );
862 Some((Ok(ev), OpenAiStreamState::DrainOnly { rx, terminal }))
863 }
864 Err(_) => Some((
865 Ok(make_terminal_event(
866 &response_id,
867 created,
868 &model_echo,
869 object_kind,
870 terminal.as_ref().and_then(|r| r.as_ref().err()),
871 )),
872 OpenAiStreamState::EmitDone,
873 )),
874 },
875 OpenAiStreamState::EmitDone => Some((
876 Ok(Event::default().data("[DONE]")),
877 OpenAiStreamState::Closed,
878 )),
879 OpenAiStreamState::Closed => None,
880 }
881 }
882 });
883
884 Sse::new(sse_stream)
885 .keep_alive(KeepAlive::default())
886 .into_response()
887}
888
889enum OpenAiStreamState {
890 Streaming {
891 rx: tokio::sync::mpsc::Receiver<Vec<u8>>,
892 done_rx: tokio::sync::oneshot::Receiver<Result<(), ExecError>>,
893 },
894 DrainOnly {
895 rx: tokio::sync::mpsc::Receiver<Vec<u8>>,
896 terminal: Option<Result<(), ExecError>>,
897 },
898 EmitDone,
899 Closed,
900}
901
902/// Build one `data: { ... }` SSE event carrying a single OpenAI
903/// `chat.completion.chunk` (or `text_completion`) delta.
904///
905/// SECURITY (control-byte / escape-sequence hazard): the guest's emitted
906/// bytes are forwarded verbatim — they are decoded with
907/// `String::from_utf8_lossy` and embedded directly in the JSON `text` /
908/// `delta.content` field without sanitisation. Per `docs/STREAMING.md`,
909/// the host does NOT strip control bytes or ANSI / terminal escape
910/// sequences from guest output; a malicious or buggy guest can therefore
911/// emit NULs, ANSI escapes, or other control characters that flow
912/// straight through to the client. JSON string-escaping (applied by
913/// `serde_json` when the payload is serialised) neutralises structural
914/// injection into the SSE frame, but does not neutralise terminal escape
915/// sequences once a client un-escapes and prints the content. Sanitising
916/// for display is the client's responsibility (mirrors the native
917/// `/invoke-stream` chunked branch in `routes.rs`, where the CLI's T18
918/// sanitisation handles received text). We deliberately do not alter the
919/// stream bytes here so byte-exact, non-text payloads survive the trip.
920fn make_chunk_event(
921 id: &str,
922 created: u64,
923 model: &str,
924 object_kind: OpenAiObject,
925 chunk_bytes: &[u8],
926) -> Event {
927 let text = String::from_utf8_lossy(chunk_bytes).into_owned();
928 let payload = match object_kind {
929 OpenAiObject::TextCompletion => serde_json::json!({
930 "id": id,
931 "object": object_kind.chunk_object_kind(),
932 "created": created,
933 "model": model,
934 "choices": [{
935 "text": text,
936 "index": 0,
937 "finish_reason": serde_json::Value::Null,
938 "logprobs": serde_json::Value::Null,
939 }],
940 }),
941 OpenAiObject::ChatCompletion => serde_json::json!({
942 "id": id,
943 "object": object_kind.chunk_object_kind(),
944 "created": created,
945 "model": model,
946 "choices": [{
947 "index": 0,
948 "delta": { "content": text },
949 "finish_reason": serde_json::Value::Null,
950 }],
951 }),
952 };
953 Event::default().data(payload.to_string())
954}
955
956/// Build the terminal SSE event. On success this is a `finish_reason:
957/// "stop"` frame; on error we emit an OpenAI error envelope as a
958/// `data:` frame so clients can branch on it. The `[DONE]` sentinel is
959/// emitted as a separate event after this one.
960fn make_terminal_event(
961 id: &str,
962 created: u64,
963 model: &str,
964 object_kind: OpenAiObject,
965 err: Option<&ExecError>,
966) -> Event {
967 let payload = if let Some(err) = err {
968 // SECURITY (api S-22, api T3): emit the same fixed, sanitised
969 // per-variant text the synchronous `/invoke` path uses instead of
970 // the raw `ExecError` Display (which leaks the full wasmtime error
971 // chain and other internal state). The verbose original is logged
972 // upstream at the executor / mapping site.
973 serde_json::json!({
974 "id": id,
975 "object": object_kind.chunk_object_kind(),
976 "created": created,
977 "model": model,
978 "error": {
979 "message": crate::routes::sanitised_exec_error_message(err),
980 "type": "server_error",
981 "code": exec_error_code(err),
982 },
983 })
984 } else {
985 match object_kind {
986 OpenAiObject::TextCompletion => serde_json::json!({
987 "id": id,
988 "object": object_kind.chunk_object_kind(),
989 "created": created,
990 "model": model,
991 "choices": [{
992 "text": "",
993 "index": 0,
994 "finish_reason": "stop",
995 "logprobs": serde_json::Value::Null,
996 }],
997 }),
998 OpenAiObject::ChatCompletion => serde_json::json!({
999 "id": id,
1000 "object": object_kind.chunk_object_kind(),
1001 "created": created,
1002 "model": model,
1003 "choices": [{
1004 "index": 0,
1005 "delta": {},
1006 "finish_reason": "stop",
1007 }],
1008 }),
1009 }
1010 };
1011 Event::default().data(payload.to_string())
1012}
1013
1014/// Surface an [`ExecError`] as an OpenAI envelope. Keeps the wire
1015/// errors aligned with the native `/invoke` surface's error categories
1016/// even though the OpenAI SDK only sees the OpenAI shape.
1017fn map_exec_error_to_openai(err: ExecError) -> OpenAiError {
1018 let code = exec_error_code(&err);
1019 OpenAiError::server_error(format!("{err}"), code)
1020}
1021
1022fn exec_error_code(err: &ExecError) -> &'static str {
1023 match err {
1024 ExecError::Timeout(_) => "deadline_elapsed",
1025 ExecError::CapacityExhausted { .. } => "capacity_exhausted",
1026 ExecError::TenantCapacityExhausted { .. } => "tenant_capacity_exhausted",
1027 ExecError::ModuleMemoryTooLarge { .. } => "module_memory_too_large",
1028 ExecError::ModuleTooLarge { .. } => "module_too_large",
1029 ExecError::MissingExport(_) => "missing_export",
1030 ExecError::EpochTickerNotRunning => "epoch_ticker_not_running",
1031 ExecError::NotFound(_) => "instance_not_found",
1032 ExecError::Wasmtime(_) => "wasm_error",
1033 }
1034}
1035
1036// ---------------------------------------------------------------------------
1037// Tests
1038// ---------------------------------------------------------------------------
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043
1044 #[test]
1045 fn completions_request_deserialises_minimal_body() {
1046 let raw = r#"{"model":"gpt-3.5-turbo","prompt":"hello"}"#;
1047 let parsed: CompletionsRequest = serde_json::from_str(raw).expect("parses");
1048 assert_eq!(parsed.model, "gpt-3.5-turbo");
1049 assert_eq!(parsed.prompt, serde_json::json!("hello"));
1050 assert!(parsed.max_tokens.is_none());
1051 }
1052
1053 #[test]
1054 fn completions_request_accepts_array_prompt() {
1055 // OpenAI accepts `prompt: ["a","b"]` — must parse without error.
1056 let raw = r#"{"model":"m","prompt":["a","b","c"]}"#;
1057 let parsed: CompletionsRequest = serde_json::from_str(raw).expect("parses");
1058 assert!(parsed.prompt.is_array());
1059 }
1060
1061 #[test]
1062 fn chat_completions_request_deserialises_minimal_body() {
1063 let raw = r#"{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}"#;
1064 let parsed: ChatCompletionsRequest = serde_json::from_str(raw).expect("parses");
1065 assert_eq!(parsed.model, "gpt-4");
1066 assert_eq!(parsed.messages.len(), 1);
1067 assert_eq!(parsed.messages[0].role, "user");
1068 }
1069
1070 #[test]
1071 fn chat_completions_request_accepts_empty_messages() {
1072 // The scaffold does not validate semantics; an empty messages
1073 // array still deserialises so the 501 shape is observable from
1074 // a maximally-stripped request.
1075 let raw = r#"{"model":"m","messages":[]}"#;
1076 let parsed: ChatCompletionsRequest = serde_json::from_str(raw).expect("parses");
1077 assert!(parsed.messages.is_empty());
1078 }
1079
1080 #[test]
1081 fn openai_error_envelope_serialises_to_openai_shape() {
1082 let env = OpenAiError::not_yet_wired("nope");
1083 let v = serde_json::to_value(&env).expect("serialises");
1084 // Top-level key is `error`.
1085 let inner = v.get("error").expect("error key present");
1086 assert_eq!(inner.get("message").and_then(|x| x.as_str()), Some("nope"));
1087 assert_eq!(
1088 inner.get("type").and_then(|x| x.as_str()),
1089 Some("not_implemented"),
1090 );
1091 assert!(inner.get("param").is_some_and(|x| x.is_null()));
1092 assert_eq!(
1093 inner.get("code").and_then(|x| x.as_str()),
1094 Some("openai_not_yet_wired"),
1095 );
1096 }
1097
1098 #[test]
1099 fn openai_error_invalid_request_carries_param() {
1100 let env = OpenAiError::invalid_request("bad", Some("model".to_string()));
1101 let v = serde_json::to_value(&env).expect("serialises");
1102 let inner = v.get("error").unwrap();
1103 assert_eq!(
1104 inner.get("type").and_then(|x| x.as_str()),
1105 Some("invalid_request_error"),
1106 );
1107 assert_eq!(inner.get("param").and_then(|x| x.as_str()), Some("model"),);
1108 }
1109}