Skip to main content

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}