Skip to main content

tensor_wasm_api/
openai_translator.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! OpenAI request → internal invoke translation (T41, v0.4 wire-up).
5//!
6//! The v0.3.7 scaffold (`crates/tensor-wasm-api/src/openai.rs`) parsed
7//! OpenAI request bodies and returned `501 openai_not_yet_wired`. T41
8//! lands the translation step that resolves the `model` field against
9//! a deploy-time configured `model → FunctionId` map and prepares an
10//! invocation against the existing `tensor-wasm-exec` executor.
11//!
12//! ## Configuration
13//!
14//! The map is read from the env var
15//! `TENSOR_WASM_API_OPENAI_MODEL_MAP`. Format:
16//!
17//! ```text
18//! model_id_1:function_uuid_1,model_id_2:function_uuid_2,...
19//! ```
20//!
21//! Example:
22//!
23//! ```text
24//! gpt-3.5-turbo:00000000-0000-4000-8000-000000000001,gpt-4:00000000-0000-4000-8000-000000000002
25//! ```
26//!
27//! Empty / unset env var produces an empty map. With an empty map every
28//! request fails the `model` lookup and returns `404 model_not_found`
29//! (per the OpenAI contract). A YAML config file alternative is deferred
30//! to v0.5.
31//!
32//! ## Prompt → guest input channel (pull model)
33//!
34//! The translator returns a [`TranslatedRequest`] carrying the resolved
35//! function id, the assembled prompt text, the prompt's byte length
36//! (`prompt_len_hint`), and an `args` vector.
37//!
38//! **How the prompt reaches the guest.** The handler in `openai.rs`
39//! stages the assembled `prompt` bytes on
40//! [`SpawnConfig::input`](tensor_wasm_exec::executor::SpawnConfig::input)
41//! at spawn time. The shared
42//! [`tensor_wasm_exec::executor::TensorWasmExecutor`] installs those
43//! bytes on the per-instance host state and wires the `wasi:tensor/host`
44//! pull-model input channel — `input-len() -> u32` and `read-input(ptr,
45//! len) -> s32` — so the guest copies the prompt into its own linear
46//! memory at the start of the invocation (size the buffer from
47//! `input-len()`, then drain it with `read-input`). This is the input
48//! counterpart to the output-only
49//! [`StreamingContext`](tensor_wasm_wasi_gpu::streaming::StreamingContext)
50//! `emit-chunk` channel.
51//!
52//! Note this is a *bytes* channel, distinct from the numeric
53//! [`WasmArg`] call args:
54//!
55//! * The assembled prompt and its byte length are preserved on
56//!   [`TranslatedRequest`] (`prompt` / `prompt_len_hint`); the handler
57//!   moves `prompt` into `SpawnConfig::input` so the guest can pull it.
58//! * [`TranslatedRequest::args`] is left **empty**, deliberately, and
59//!   for a load-bearing reason: the executor's `call_export_with_args`
60//!   uses wasmtime's *dynamic* `Func::call_async` path for any non-empty
61//!   arg slice, which validates the param arity against the export's
62//!   declared signature **exactly**. The standard WASI command export is
63//!   `_start () -> ()`; handing it even a single `i32` would make
64//!   wasmtime reject the call (`ExecError::Wasmtime`) and break every
65//!   off-the-shelf guest. The empty-args path takes the typed
66//!   `func.typed::<(), ()>()` fast path that those guests rely on —
67//!   identical to how `routes.rs`'s `/invoke` runs an argument-less body.
68//!   The prompt travels through the `wasi:tensor/host` input channel
69//!   instead of the numeric argv, so lowering it into `args` is neither
70//!   needed nor safe.
71//!
72//! A guest that does not import `read-input` still runs argument-less and
73//! produces its response via the
74//! `wasi:tensor/host.emit-chunk` host function; the handler drains the
75//! receiver and surfaces the emitted bytes as the completion text.
76//! Staging input is therefore non-breaking for such guests — the
77//! staged bytes simply go unread.
78//!
79//! ## Unsupported sampling knobs
80//!
81//! `max_tokens`, `temperature`, `n`, `echo`, and `tools` are parsed off
82//! the wire but the executor exposes no knob for any of them. Rather
83//! than silently ignore a caller's explicit setting (which would make
84//! the gateway lie about honouring it), the translator returns a clear
85//! `400 invalid_request_error` (`code: "unsupported_parameter"`,
86//! `param` naming the field) whenever one of these is set to a value the
87//! gateway cannot satisfy.
88//!
89//! A value that coincides with a no-op default is accepted so common
90//! SDK boilerplate still works: `temperature: 1.0`, `n: 1`,
91//! `echo: false`, and an empty / null / absent `tools` all translate
92//! cleanly. `max_tokens` has no honourable value — we cannot cap
93//! generation — so any explicit `max_tokens` is rejected. This is
94//! revisited once the executor grows the corresponding sampling
95//! controls.
96//!
97//! ## Chat → prompt
98//!
99//! For `/v1/chat/completions` the translator concatenates the
100//! `messages` array into a single prompt string with role-tagged
101//! lines:
102//!
103//! ```text
104//! system: You are a helpful assistant.
105//! user: Hello!
106//! assistant:
107//! ```
108//!
109//! Empty content fields are tolerated; the role line is still emitted
110//! so a guest that splits on `:\n` sees a stable shape. The trailing
111//! `assistant:` line signals "complete this turn" to the guest.
112
113use std::collections::HashMap;
114use std::sync::Arc;
115
116use tensor_wasm_exec::executor::WasmArg;
117use uuid::Uuid;
118
119use crate::openai::{ChatCompletionsRequest, ChatMessage, CompletionsRequest, OpenAiError};
120
121/// Environment variable carrying the comma-separated
122/// `model:function_uuid` map. See module-level docs.
123pub const ENV_OPENAI_MODEL_MAP: &str = "TENSOR_WASM_API_OPENAI_MODEL_MAP";
124
125/// Resolved `model → FunctionId` map, shared across handler invocations
126/// via `Arc`.
127pub type ModelMap = Arc<HashMap<String, Uuid>>;
128
129/// Parse the `TENSOR_WASM_API_OPENAI_MODEL_MAP` env-var value into a
130/// `HashMap<String, Uuid>`.
131///
132/// Empty / unset value yields an empty map. Malformed entries (missing
133/// colon, unparseable UUID) are skipped with a `tracing::warn!` so a
134/// single bad row does not block startup — the remaining valid entries
135/// stay live.
136pub fn parse_model_map_env(raw: &str) -> HashMap<String, Uuid> {
137    let mut out: HashMap<String, Uuid> = HashMap::new();
138    let trimmed = raw.trim();
139    if trimmed.is_empty() {
140        return out;
141    }
142    for entry in trimmed.split(',') {
143        let frag = entry.trim();
144        if frag.is_empty() {
145            continue;
146        }
147        let (model, uuid_str) = match frag.split_once(':') {
148            Some((m, u)) => (m.trim(), u.trim()),
149            None => {
150                tracing::warn!(
151                    target: "tensor_wasm_api::openai_translator",
152                    entry = %frag,
153                    var = ENV_OPENAI_MODEL_MAP,
154                    "ignoring malformed entry: missing ':' separator (expected model:function_uuid)",
155                );
156                continue;
157            }
158        };
159        if model.is_empty() {
160            tracing::warn!(
161                target: "tensor_wasm_api::openai_translator",
162                entry = %frag,
163                var = ENV_OPENAI_MODEL_MAP,
164                "ignoring malformed entry: empty model id",
165            );
166            continue;
167        }
168        let id = match Uuid::parse_str(uuid_str) {
169            Ok(u) => u,
170            Err(e) => {
171                tracing::warn!(
172                    target: "tensor_wasm_api::openai_translator",
173                    entry = %frag,
174                    var = ENV_OPENAI_MODEL_MAP,
175                    error = %e,
176                    "ignoring malformed entry: function uuid does not parse",
177                );
178                continue;
179            }
180        };
181        out.insert(model.to_owned(), id);
182    }
183    out
184}
185
186/// Read `TENSOR_WASM_API_OPENAI_MODEL_MAP` from the ambient process env
187/// and return an [`Arc`]-wrapped `HashMap` for installation into
188/// [`crate::AppState`]. Empty / unset env var produces an empty map.
189pub fn model_map_from_env() -> ModelMap {
190    let raw = std::env::var(ENV_OPENAI_MODEL_MAP).unwrap_or_default();
191    let map = parse_model_map_env(&raw);
192    if !map.is_empty() {
193        tracing::info!(
194            target: "tensor_wasm_api::openai_translator",
195            entries = map.len(),
196            "OpenAI model map configured",
197        );
198    }
199    Arc::new(map)
200}
201
202/// Translation output: the resolved [`Uuid`] function id, the
203/// fully-assembled prompt text, and the synthetic [`WasmArg`] vector to
204/// pass into `call_export_with_args`.
205///
206/// **args policy:** the `args` vector is empty, deliberately. The
207/// standard WASI command export is `_start () -> ()`, and the executor's
208/// `call_export_with_args` switches to wasmtime's dynamic call path for
209/// any non-empty arg slice — which validates param arity against the
210/// export signature exactly and would reject a `()`-arity guest handed
211/// an argument. The prompt is a *bytes* payload, not a numeric arg, so
212/// it travels through the `wasi:tensor/host` pull-model input channel
213/// (`input-len` / `read-input`) rather than `args`: the handler moves
214/// `prompt` into
215/// [`SpawnConfig::input`](tensor_wasm_exec::executor::SpawnConfig::input)
216/// at spawn time. See the module-level "Prompt → guest input channel"
217/// section for the full rationale. Guests stream their reply via
218/// `wasi:tensor/host.emit-chunk` — the host buffers / forwards every
219/// emitted chunk.
220#[derive(Debug, Clone)]
221pub struct TranslatedRequest {
222    /// Resolved function id.
223    pub function_id: Uuid,
224    /// Fully-assembled prompt text (concatenated messages array for
225    /// `/v1/chat/completions`). The handler stages these bytes on
226    /// [`SpawnConfig::input`](tensor_wasm_exec::executor::SpawnConfig::input)
227    /// so the guest can pull them via `wasi:tensor/host.read-input`.
228    pub prompt: String,
229    /// Byte length of the assembled prompt, clamped to `i32::MAX`.
230    /// Preserved as an observability hint (e.g. for tracing the staged
231    /// prompt size); the prompt itself is delivered to the guest via the
232    /// `wasi:tensor/host` input channel.
233    pub prompt_len_hint: i32,
234    /// Args to pass into the executor. Empty in v0.4 so the standard
235    /// `_start () -> ()` guest links via the typed fast path; see the
236    /// struct-level note.
237    pub args: Vec<WasmArg>,
238    /// `true` if the original request had `stream: true`.
239    pub stream: bool,
240}
241
242/// Extract a single prompt string from the OpenAI `prompt` field. The
243/// OpenAI wire contract accepts either a JSON string or an array of
244/// strings; we accept both, concatenating the array with newlines.
245/// Any other shape (number, null, object) is treated as an empty
246/// prompt with no error — the scaffold deliberately stays permissive
247/// on this surface.
248fn extract_prompt_text(prompt: &serde_json::Value) -> String {
249    match prompt {
250        serde_json::Value::String(s) => s.clone(),
251        serde_json::Value::Array(items) => items
252            .iter()
253            .filter_map(|v| v.as_str().map(str::to_owned))
254            .collect::<Vec<_>>()
255            .join("\n"),
256        _ => String::new(),
257    }
258}
259
260/// Extract the textual content of a single chat message. The OpenAI
261/// contract accepts either a plain string or an array of content parts
262/// (for multimodal). For v0.4 we only consume the text parts; binary
263/// / image parts are deferred to v0.5.
264fn extract_message_content(msg: &ChatMessage) -> String {
265    match &msg.content {
266        serde_json::Value::String(s) => s.clone(),
267        serde_json::Value::Array(parts) => parts
268            .iter()
269            .filter_map(|p| {
270                p.get("text")
271                    .and_then(serde_json::Value::as_str)
272                    .map(str::to_owned)
273            })
274            .collect::<Vec<_>>()
275            .join(" "),
276        _ => String::new(),
277    }
278}
279
280/// Assemble the chat-messages array into a single prompt string with
281/// role-tagged lines. Each entry becomes one `role: content` line; a
282/// final `assistant:` line signals the guest that it should generate
283/// the next turn.
284///
285/// Public so tests can pin the assembly contract without driving the
286/// full HTTP surface.
287pub fn assemble_chat_prompt(messages: &[ChatMessage]) -> String {
288    let mut out = String::new();
289    for msg in messages {
290        let role = if msg.role.is_empty() {
291            "user"
292        } else {
293            msg.role.as_str()
294        };
295        let content = extract_message_content(msg);
296        out.push_str(role);
297        out.push_str(": ");
298        out.push_str(&content);
299        out.push('\n');
300    }
301    // Trailing turn marker so the guest sees a stable "generate the
302    // assistant response now" signal.
303    out.push_str("assistant:");
304    out
305}
306
307/// Translate a [`CompletionsRequest`] into a [`TranslatedRequest`].
308///
309/// Returns `Err(OpenAiError)` with the OpenAI 404 envelope
310/// (`type: "invalid_request_error"`, `code: "model_not_found"`) when
311/// `req.model` is not present in the `model_map`, or the OpenAI 400
312/// envelope (`code: "unsupported_parameter"`) when an unsupported
313/// sampling knob (`max_tokens` / `temperature` / `n` / `echo`) is set
314/// to a non-default value — see `reject_unsupported_knob`.
315///
316/// Args policy (v0.4): an empty `Vec<WasmArg>` is returned — see
317/// [`TranslatedRequest`] for why the prompt is preserved on the struct
318/// but not lowered into `args`.
319pub fn translate_completions_request(
320    req: &CompletionsRequest,
321    model_map: &HashMap<String, Uuid>,
322) -> Result<TranslatedRequest, OpenAiError> {
323    let function_id = lookup_model(&req.model, model_map)?;
324    // Honest-rejection policy: the executor exposes no sampling knobs,
325    // so a caller that explicitly sets one gets a 400 rather than a
326    // response that silently ignored it.
327    reject_unsupported_knob("max_tokens", req.max_tokens.map(|v| v as f64), None)?;
328    reject_unsupported_knob("temperature", req.temperature.map(|v| v as f64), Some(1.0))?;
329    reject_unsupported_knob("n", req.n.map(|v| v as f64), Some(1.0))?;
330    reject_unsupported_bool("echo", req.echo)?;
331    let prompt = extract_prompt_text(&req.prompt);
332    let prompt_len_hint = clamp_len_to_i32(prompt.len());
333    Ok(TranslatedRequest {
334        function_id,
335        prompt,
336        prompt_len_hint,
337        args: Vec::new(),
338        stream: req.stream.unwrap_or(false),
339    })
340}
341
342/// Translate a [`ChatCompletionsRequest`] into a [`TranslatedRequest`].
343///
344/// See [`assemble_chat_prompt`] for the message-array concatenation
345/// contract. Same `model_not_found` envelope as
346/// [`translate_completions_request`].
347pub fn translate_chat_completions_request(
348    req: &ChatCompletionsRequest,
349    model_map: &HashMap<String, Uuid>,
350) -> Result<TranslatedRequest, OpenAiError> {
351    let function_id = lookup_model(&req.model, model_map)?;
352    // Honest-rejection policy: see translate_completions_request.
353    reject_unsupported_knob("max_tokens", req.max_tokens.map(|v| v as f64), None)?;
354    reject_unsupported_knob("temperature", req.temperature.map(|v| v as f64), Some(1.0))?;
355    reject_unsupported_knob("n", req.n.map(|v| v as f64), Some(1.0))?;
356    reject_unsupported_tools(req.tools.as_ref())?;
357    let prompt = assemble_chat_prompt(&req.messages);
358    let prompt_len_hint = clamp_len_to_i32(prompt.len());
359    Ok(TranslatedRequest {
360        function_id,
361        prompt,
362        prompt_len_hint,
363        args: Vec::new(),
364        stream: req.stream.unwrap_or(false),
365    })
366}
367
368/// Common model-lookup helper. Returns a `model_not_found` OpenAI
369/// envelope on miss.
370fn lookup_model(model: &str, model_map: &HashMap<String, Uuid>) -> Result<Uuid, OpenAiError> {
371    match model_map.get(model) {
372        Some(id) => Ok(*id),
373        None => Err(OpenAiError::model_not_found(format!(
374            "model `{model}` is not configured in TENSOR_WASM_API_OPENAI_MODEL_MAP; \
375             ask your operator to add a `{model}:<function_uuid>` entry",
376        ))),
377    }
378}
379
380/// Reject a numeric sampling knob the executor cannot honour, *unless*
381/// the caller's value coincides with a no-op default the gateway can
382/// satisfy without doing anything.
383///
384/// The executor exposes no sampling controls, so honouring a non-default
385/// value is impossible today. Per the module's honest-rejection policy
386/// we return a `400 invalid_request_error`
387/// (`code: "unsupported_parameter"`, `param: <field>`) whenever the
388/// caller sets a value the gateway would otherwise silently ignore.
389///
390/// `honored_default` names the one value (if any) that is a no-op for
391/// us and therefore accepted:
392/// * `temperature` → `Some(1.0)` (OpenAI's default sampling temperature
393///   is a no-op relative to "no sampling control");
394/// * `n` → `Some(1.0)` (the single-choice envelope only ever produces
395///   one completion, which is also OpenAI's default);
396/// * `max_tokens` → `None` (there is no value we can honour — we cannot
397///   cap generation at all — so any explicit `max_tokens` is rejected).
398// The honored defaults (`1.0`) are exactly representable, so the direct
399// equality check is intentional and correct here.
400#[allow(clippy::float_cmp)]
401fn reject_unsupported_knob(
402    field: &'static str,
403    value: Option<f64>,
404    honored_default: Option<f64>,
405) -> Result<(), OpenAiError> {
406    let Some(v) = value else {
407        return Ok(());
408    };
409    if Some(v) == honored_default {
410        return Ok(());
411    }
412    Err(OpenAiError::invalid_request(
413        format!(
414            "`{field}` is not supported by this gateway: the underlying executor exposes no \
415             sampling controls. Omit `{field}` and retry.",
416        ),
417        Some(field.to_string()),
418    )
419    .with_code("unsupported_parameter"))
420}
421
422/// Reject a boolean knob (`echo`) when set to a non-default `true`.
423/// `false` / absent is the default and translates cleanly.
424fn reject_unsupported_bool(field: &'static str, value: Option<bool>) -> Result<(), OpenAiError> {
425    if value == Some(true) {
426        return Err(OpenAiError::invalid_request(
427            format!(
428                "`{field}` is not supported by this gateway: the underlying executor cannot \
429                 echo the prompt. Omit `{field}` (or send `{field}: false`) and retry.",
430            ),
431            Some(field.to_string()),
432        )
433        .with_code("unsupported_parameter"));
434    }
435    Ok(())
436}
437
438/// Reject a non-empty `tools` array. The executor has no tool-calling
439/// dispatch, so a populated `tools` field would be silently ignored;
440/// an absent / null / empty-array value translates cleanly.
441fn reject_unsupported_tools(tools: Option<&serde_json::Value>) -> Result<(), OpenAiError> {
442    let populated = match tools {
443        None | Some(serde_json::Value::Null) => false,
444        Some(serde_json::Value::Array(a)) => !a.is_empty(),
445        // Any other non-null shape counts as "the caller set it".
446        Some(_) => true,
447    };
448    if populated {
449        return Err(OpenAiError::invalid_request(
450            "`tools` is not supported by this gateway: the underlying executor has no \
451             tool-calling dispatch. Omit `tools` and retry."
452                .to_string(),
453            Some("tools".to_string()),
454        )
455        .with_code("unsupported_parameter"));
456    }
457    Ok(())
458}
459
460/// Clamp a `usize` byte length to `i32`. Prompts above `i32::MAX`
461/// bytes are saturated to `i32::MAX`; that's a 2 GiB prompt and any
462/// real prompt is many orders of magnitude smaller. We deliberately
463/// do not return an error here — the body-size cap
464/// (`MAX_REQUEST_BODY_BYTES`, 64 MiB) at the middleware layer is the
465/// authoritative bound. This saturation is just defensive arithmetic
466/// against the `as i32` cast.
467fn clamp_len_to_i32(len: usize) -> i32 {
468    if len > i32::MAX as usize {
469        i32::MAX
470    } else {
471        len as i32
472    }
473}
474
475// ---------------------------------------------------------------------------
476// Tests
477// ---------------------------------------------------------------------------
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use serde_json::json;
483
484    fn fixture_uuid_1() -> Uuid {
485        Uuid::parse_str("00000000-0000-4000-8000-000000000001").unwrap()
486    }
487
488    fn fixture_uuid_2() -> Uuid {
489        Uuid::parse_str("00000000-0000-4000-8000-000000000002").unwrap()
490    }
491
492    #[test]
493    fn parse_empty_yields_empty_map() {
494        assert!(parse_model_map_env("").is_empty());
495        assert!(parse_model_map_env("   ").is_empty());
496    }
497
498    #[test]
499    fn parse_single_entry() {
500        let raw = "gpt-3.5-turbo:00000000-0000-4000-8000-000000000001";
501        let map = parse_model_map_env(raw);
502        assert_eq!(map.len(), 1);
503        assert_eq!(map.get("gpt-3.5-turbo"), Some(&fixture_uuid_1()));
504    }
505
506    #[test]
507    fn parse_multiple_entries() {
508        let raw = "gpt-3.5-turbo:00000000-0000-4000-8000-000000000001,gpt-4:00000000-0000-4000-8000-000000000002";
509        let map = parse_model_map_env(raw);
510        assert_eq!(map.len(), 2);
511        assert_eq!(map.get("gpt-3.5-turbo"), Some(&fixture_uuid_1()));
512        assert_eq!(map.get("gpt-4"), Some(&fixture_uuid_2()));
513    }
514
515    #[test]
516    fn parse_skips_malformed_entries() {
517        let raw = "good:00000000-0000-4000-8000-000000000001,bad-no-colon,empty:,:no-model,gpt-4:not-a-uuid";
518        let map = parse_model_map_env(raw);
519        // Only `good` survives.
520        assert_eq!(map.len(), 1);
521        assert!(map.contains_key("good"));
522    }
523
524    #[test]
525    fn parse_tolerates_whitespace() {
526        let raw = " gpt-3.5-turbo : 00000000-0000-4000-8000-000000000001 , gpt-4:00000000-0000-4000-8000-000000000002 ";
527        let map = parse_model_map_env(raw);
528        assert_eq!(map.len(), 2);
529        assert!(map.contains_key("gpt-3.5-turbo"));
530        assert!(map.contains_key("gpt-4"));
531    }
532
533    #[test]
534    fn translate_completions_returns_function_id_on_hit() {
535        let mut map = HashMap::new();
536        map.insert("m1".to_owned(), fixture_uuid_1());
537        let req = CompletionsRequest {
538            model: "m1".to_owned(),
539            prompt: json!("hello"),
540            stream: Some(false),
541            ..Default::default()
542        };
543        let out = translate_completions_request(&req, &map).expect("translates");
544        assert_eq!(out.function_id, fixture_uuid_1());
545        assert_eq!(out.prompt, "hello");
546        assert!(!out.stream);
547        // v0.4 args policy: empty vec (the standard `_start () -> ()`
548        // guest links via the typed fast path; the executor has no
549        // channel to deliver the prompt bytes), length preserved in hint.
550        assert!(out.args.is_empty(), "args must be empty in v0.4");
551        assert_eq!(out.prompt_len_hint, 5);
552    }
553
554    #[test]
555    fn translate_completions_rejects_unsupported_knobs() {
556        let mut map = HashMap::new();
557        map.insert("m1".to_owned(), fixture_uuid_1());
558        let base = CompletionsRequest {
559            model: "m1".to_owned(),
560            prompt: json!("hi"),
561            ..Default::default()
562        };
563
564        // max_tokens set → 400 unsupported_parameter, param names the field.
565        let req = CompletionsRequest {
566            max_tokens: Some(64),
567            ..base.clone()
568        };
569        let err = translate_completions_request(&req, &map).expect_err("rejects max_tokens");
570        assert_eq!(err.error.code.as_deref(), Some("unsupported_parameter"));
571        assert_eq!(err.error.param.as_deref(), Some("max_tokens"));
572
573        // temperature set → rejected.
574        let req = CompletionsRequest {
575            temperature: Some(0.7),
576            ..base.clone()
577        };
578        assert!(translate_completions_request(&req, &map).is_err());
579
580        // echo: true → rejected; echo: false is the default and passes.
581        let req = CompletionsRequest {
582            echo: Some(true),
583            ..base.clone()
584        };
585        assert!(translate_completions_request(&req, &map).is_err());
586        let req = CompletionsRequest {
587            echo: Some(false),
588            ..base.clone()
589        };
590        assert!(translate_completions_request(&req, &map).is_ok());
591
592        // n: 1 is the honoured default; n: 2 is rejected.
593        let req = CompletionsRequest {
594            n: Some(1),
595            ..base.clone()
596        };
597        assert!(translate_completions_request(&req, &map).is_ok());
598        let req = CompletionsRequest { n: Some(2), ..base };
599        assert!(translate_completions_request(&req, &map).is_err());
600    }
601
602    #[test]
603    fn translate_chat_completions_rejects_nonempty_tools() {
604        let mut map = HashMap::new();
605        map.insert("m".to_owned(), fixture_uuid_1());
606        let base = ChatCompletionsRequest {
607            model: "m".to_owned(),
608            messages: vec![ChatMessage {
609                role: "user".to_owned(),
610                content: json!("hi"),
611                name: None,
612            }],
613            ..Default::default()
614        };
615
616        // Populated tools array → 400 unsupported_parameter.
617        let req = ChatCompletionsRequest {
618            tools: Some(json!([{"type": "function"}])),
619            ..base.clone()
620        };
621        let err = translate_chat_completions_request(&req, &map).expect_err("rejects tools");
622        assert_eq!(err.error.code.as_deref(), Some("unsupported_parameter"));
623        assert_eq!(err.error.param.as_deref(), Some("tools"));
624
625        // Empty array / null / absent all translate cleanly.
626        let req = ChatCompletionsRequest {
627            tools: Some(json!([])),
628            ..base.clone()
629        };
630        assert!(translate_chat_completions_request(&req, &map).is_ok());
631        let req = ChatCompletionsRequest {
632            tools: Some(serde_json::Value::Null),
633            ..base
634        };
635        assert!(translate_chat_completions_request(&req, &map).is_ok());
636    }
637
638    #[test]
639    fn translate_completions_returns_model_not_found_on_miss() {
640        let map = HashMap::new();
641        let req = CompletionsRequest {
642            model: "unknown".to_owned(),
643            prompt: json!("hello"),
644            ..Default::default()
645        };
646        let err = translate_completions_request(&req, &map).expect_err("misses");
647        assert_eq!(err.error.code.as_deref(), Some("model_not_found"));
648    }
649
650    #[test]
651    fn translate_completions_array_prompt_joined_with_newlines() {
652        let mut map = HashMap::new();
653        map.insert("m1".to_owned(), fixture_uuid_1());
654        let req = CompletionsRequest {
655            model: "m1".to_owned(),
656            prompt: json!(["a", "b", "c"]),
657            ..Default::default()
658        };
659        let out = translate_completions_request(&req, &map).expect("translates");
660        assert_eq!(out.prompt, "a\nb\nc");
661    }
662
663    #[test]
664    fn translate_chat_completions_assembles_role_tagged_prompt() {
665        let mut map = HashMap::new();
666        map.insert("m2".to_owned(), fixture_uuid_2());
667        let req = ChatCompletionsRequest {
668            model: "m2".to_owned(),
669            messages: vec![
670                ChatMessage {
671                    role: "system".to_owned(),
672                    content: json!("You are helpful."),
673                    name: None,
674                },
675                ChatMessage {
676                    role: "user".to_owned(),
677                    content: json!("Hi."),
678                    name: None,
679                },
680            ],
681            stream: Some(true),
682            ..Default::default()
683        };
684        let out = translate_chat_completions_request(&req, &map).expect("translates");
685        assert_eq!(out.function_id, fixture_uuid_2());
686        assert!(out.stream);
687        assert!(
688            out.prompt.contains("system: You are helpful."),
689            "missing system role tag: {}",
690            out.prompt,
691        );
692        assert!(
693            out.prompt.contains("user: Hi."),
694            "missing user role tag: {}",
695            out.prompt,
696        );
697        assert!(
698            out.prompt.trim_end().ends_with("assistant:"),
699            "must end with trailing `assistant:` turn marker: {}",
700            out.prompt,
701        );
702    }
703
704    #[test]
705    fn translate_chat_completions_empty_messages_yields_only_marker() {
706        let mut map = HashMap::new();
707        map.insert("m".to_owned(), fixture_uuid_1());
708        let req = ChatCompletionsRequest {
709            model: "m".to_owned(),
710            messages: vec![],
711            ..Default::default()
712        };
713        let out = translate_chat_completions_request(&req, &map).expect("translates");
714        assert_eq!(out.prompt, "assistant:");
715    }
716
717    #[test]
718    fn translate_chat_completions_default_role_is_user() {
719        let mut map = HashMap::new();
720        map.insert("m".to_owned(), fixture_uuid_1());
721        let req = ChatCompletionsRequest {
722            model: "m".to_owned(),
723            messages: vec![ChatMessage {
724                role: String::new(),
725                content: json!("hi"),
726                name: None,
727            }],
728            ..Default::default()
729        };
730        let out = translate_chat_completions_request(&req, &map).expect("translates");
731        assert!(out.prompt.contains("user: hi"), "got {}", out.prompt);
732    }
733}