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}