Skip to main content

monoloop_loop/transaction/
openai_encoder.rs

1//! OpenAI Chat Completions v1 outbound encoder (streaming SSE dialect).
2//!
3//! Encodes provider-neutral canonical input into a Chat Completions request body.
4//! Does not speak HTTP; Connector transports the bytes. OpenAI Responses is unsupported.
5
6use monoloop_contracts::{
7    Bytes, CanonicalMessage, CanonicalToolOutput, CanonicalToolResult, CanonicalToolResultOutcome,
8    DialectDescriptor, EncodedExchange, EncodingError, ExchangeInputPolicy, InitialEncodeRequest,
9    OutboundDialectEncoder, ReasoningEffort, ResponseFormat, ToolContinuationEncodeRequest,
10    ToolSpec,
11};
12use serde_json::{json, Map, Value};
13
14/// Options controlling Chat Completions field names and capability gates.
15#[derive(Clone, Debug)]
16pub struct OpenAiEncoderOptions {
17    /// When true, emit `max_completion_tokens` instead of `max_tokens`.
18    pub use_max_completion_tokens: bool,
19    /// Whether `reasoning_effort` may be encoded.
20    pub allow_reasoning_effort: bool,
21    /// Maximum encoded body bytes.
22    pub max_encoded_bytes: usize,
23}
24
25impl Default for OpenAiEncoderOptions {
26    fn default() -> Self {
27        Self {
28            use_max_completion_tokens: false,
29            allow_reasoning_effort: false,
30            max_encoded_bytes: 4 * 1024 * 1024,
31        }
32    }
33}
34
35/// Chat Completions v1 encoder producing `stream: true` JSON bodies.
36#[derive(Clone, Debug, Default)]
37pub struct OpenAiChatCompletionsEncoder {
38    /// Capability-dependent options.
39    pub options: OpenAiEncoderOptions,
40}
41
42impl OpenAiChatCompletionsEncoder {
43    /// Construct with options.
44    pub fn new(options: OpenAiEncoderOptions) -> Self {
45        Self { options }
46    }
47
48    fn dialect() -> DialectDescriptor {
49        DialectDescriptor::openai_chat_completions("v1")
50    }
51
52    fn encode_body(
53        &self,
54        messages: Value,
55        tools: &[ToolSpec],
56        config: &monoloop_contracts::EffectiveConfig,
57    ) -> Result<EncodedExchange, EncodingError> {
58        let mut body = Map::new();
59        let model = config
60            .model
61            .clone()
62            .ok_or(EncodingError::InvalidConfiguration)?;
63        body.insert("model".into(), Value::String(model));
64        body.insert("messages".into(), messages);
65        body.insert("stream".into(), Value::Bool(true));
66
67        if let Some(t) = config.temperature {
68            body.insert("temperature".into(), json!(t));
69        }
70        if let Some(max) = config.max_output_tokens {
71            let key = if self.options.use_max_completion_tokens {
72                "max_completion_tokens"
73            } else {
74                "max_tokens"
75            };
76            body.insert(key.into(), json!(max));
77        }
78        if !config.stop.is_empty() {
79            body.insert("stop".into(), json!(config.stop));
80        }
81        if let Some(fmt) = &config.response_format {
82            match fmt {
83                ResponseFormat::Text => {
84                    body.insert("response_format".into(), json!({ "type": "text" }));
85                }
86                ResponseFormat::JsonObject => {
87                    body.insert("response_format".into(), json!({ "type": "json_object" }));
88                }
89            }
90        }
91        if let Some(effort) = config.reasoning_effort {
92            if !self.options.allow_reasoning_effort {
93                return Err(EncodingError::Unsupported("reasoning_effort"));
94            }
95            let label = match effort {
96                ReasoningEffort::Low => "low",
97                ReasoningEffort::Medium => "medium",
98                ReasoningEffort::High => "high",
99            };
100            body.insert("reasoning_effort".into(), Value::String(label.into()));
101        }
102
103        // D-023: encode admitted openai.* extensions; never silently drop.
104        encode_openai_extensions(&mut body, &config.extensions)?;
105
106        if !tools.is_empty() {
107            let tool_defs: Vec<Value> = tools
108                .iter()
109                .map(|t| {
110                    json!({
111                        "type": "function",
112                        "function": {
113                            "name": t.name.as_str(),
114                            "description": t.description,
115                            "parameters": t.input_schema.as_value(),
116                        }
117                    })
118                })
119                .collect();
120            body.insert("tools".into(), Value::Array(tool_defs));
121        }
122
123        let bytes = serde_json::to_vec(&Value::Object(body))
124            .map_err(|_| EncodingError::UnrepresentableInput)?;
125        if bytes.len() > self.options.max_encoded_bytes {
126            return Err(EncodingError::LimitExceeded);
127        }
128        Ok(EncodedExchange {
129            bytes: Bytes::from(bytes),
130            required_input_dialect: Self::dialect(),
131            input_policy: ExchangeInputPolicy::SendAndFinish,
132        })
133    }
134}
135
136impl OutboundDialectEncoder for OpenAiChatCompletionsEncoder {
137    fn encode_initial(
138        &self,
139        request: InitialEncodeRequest<'_>,
140    ) -> Result<EncodedExchange, EncodingError> {
141        let messages = encode_messages(request.input.messages())?;
142        self.encode_body(messages, request.tools, request.config)
143    }
144
145    fn encode_tool_continuation(
146        &self,
147        request: ToolContinuationEncodeRequest<'_>,
148    ) -> Result<EncodedExchange, EncodingError> {
149        let mut msgs = encode_messages_vec(request.context.messages())?;
150        // Append tool results in model-declared ordinal order when possible.
151        let mut results: Vec<&CanonicalToolResult> = request.results.iter().collect();
152        results.sort_by_key(|r| r.request_ordinal);
153        for result in results {
154            msgs.push(encode_tool_result_message(result)?);
155        }
156        self.encode_body(Value::Array(msgs), request.tools, request.config)
157    }
158}
159
160/// Map admitted `openai.*` extensions into Chat Completions body fields (D-023).
161///
162/// Any extension that cannot be represented fails closed — never silently dropped.
163fn encode_openai_extensions(
164    body: &mut Map<String, Value>,
165    extensions: &std::collections::BTreeMap<
166        monoloop_contracts::ExtensionKey,
167        monoloop_contracts::VersionedExtension,
168    >,
169) -> Result<(), EncodingError> {
170    for (key, ext) in extensions {
171        let Some(field) = key.as_str().strip_prefix("openai.") else {
172            return Err(EncodingError::Unsupported("non-openai extension"));
173        };
174        match field {
175            "seed" | "user" | "top_p" | "n" | "frequency_penalty" | "presence_penalty"
176            | "logit_bias" | "logprobs" | "top_logprobs" | "metadata" => {
177                if body.contains_key(field) {
178                    return Err(EncodingError::Unsupported("extension overrides body field"));
179                }
180                body.insert(field.to_string(), ext.value.clone());
181            }
182            _ => return Err(EncodingError::Unsupported("openai extension field")),
183        }
184    }
185    Ok(())
186}
187
188fn encode_messages(messages: &[CanonicalMessage]) -> Result<Value, EncodingError> {
189    Ok(Value::Array(encode_messages_vec(messages)?))
190}
191
192fn encode_messages_vec(messages: &[CanonicalMessage]) -> Result<Vec<Value>, EncodingError> {
193    let mut out = Vec::with_capacity(messages.len());
194    for msg in messages {
195        out.push(encode_message(msg)?);
196    }
197    Ok(out)
198}
199
200fn encode_message(msg: &CanonicalMessage) -> Result<Value, EncodingError> {
201    match msg {
202        CanonicalMessage::System { content, name } => {
203            let mut m = Map::new();
204            m.insert("role".into(), Value::String("system".into()));
205            m.insert("content".into(), Value::String(join_text(content)));
206            if let Some(n) = name {
207                m.insert("name".into(), Value::String(n.clone()));
208            }
209            Ok(Value::Object(m))
210        }
211        CanonicalMessage::User { content, name } => {
212            let mut m = Map::new();
213            m.insert("role".into(), Value::String("user".into()));
214            m.insert("content".into(), Value::String(join_text(content)));
215            if let Some(n) = name {
216                m.insert("name".into(), Value::String(n.clone()));
217            }
218            Ok(Value::Object(m))
219        }
220        CanonicalMessage::Assistant {
221            content,
222            tool_calls,
223        } => {
224            let mut m = Map::new();
225            m.insert("role".into(), Value::String("assistant".into()));
226            if content.is_empty() {
227                m.insert("content".into(), Value::Null);
228            } else {
229                m.insert("content".into(), Value::String(join_text(content)));
230            }
231            if !tool_calls.is_empty() {
232                let calls: Vec<Value> = tool_calls
233                    .iter()
234                    .map(|c| {
235                        let args =
236                            serde_json::to_string(&c.arguments).unwrap_or_else(|_| "{}".into());
237                        json!({
238                            "id": c.tool_call_id,
239                            "type": "function",
240                            "function": {
241                                "name": c.tool_name.as_str(),
242                                "arguments": args,
243                            }
244                        })
245                    })
246                    .collect();
247                m.insert("tool_calls".into(), Value::Array(calls));
248            }
249            Ok(Value::Object(m))
250        }
251        CanonicalMessage::Tool {
252            tool_call_id,
253            content,
254        } => Ok(json!({
255            "role": "tool",
256            "tool_call_id": tool_call_id,
257            "content": join_text(content),
258        })),
259    }
260}
261
262fn encode_tool_result_message(result: &CanonicalToolResult) -> Result<Value, EncodingError> {
263    let content = match &result.outcome {
264        CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Json(v)) => {
265            serde_json::to_string(v).map_err(|_| EncodingError::UnrepresentableInput)?
266        }
267        CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Text(t)) => t.clone(),
268        CanonicalToolResultOutcome::DomainFailed(err) => serde_json::to_string(&json!({
269            "error": { "code": err.code, "message": err.message, "data": err.data },
270        }))
271        .map_err(|_| EncodingError::UnrepresentableInput)?,
272    };
273    // Preserve provider tool call id exactly.
274    Ok(json!({
275        "role": "tool",
276        "tool_call_id": result.provider_tool_call_id,
277        "content": content,
278    }))
279}
280
281fn join_text(parts: &[monoloop_contracts::TextPart]) -> String {
282    parts.iter().map(|p| p.text()).collect::<Vec<_>>().join("")
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use monoloop_contracts::merge_effective_config;
289    use monoloop_contracts::{
290        user_text_input, CanonicalInput, ChannelDefaults, EffectiveConfig, ExchangeId,
291        InvocationConfig, JsonSchema, ToolCancellationPolicy, ToolId, ToolLimits, ToolName,
292        ToolOutputContract, ToolSuccessContract, TransactionId,
293    };
294    use monoloop_contracts::{ExtensionLimits, OptionPolicy};
295
296    fn effective(model: &str) -> EffectiveConfig {
297        let inv = InvocationConfig {
298            model: Some(model.into()),
299            temperature: Some(0.2),
300            max_output_tokens: Some(128),
301            ..Default::default()
302        };
303        merge_effective_config(
304            &ChannelDefaults::default(),
305            None,
306            None,
307            &inv,
308            &OptionPolicy {
309                supported_invocation: [
310                    monoloop_contracts::ConfigOption::Model,
311                    monoloop_contracts::ConfigOption::Temperature,
312                    monoloop_contracts::ConfigOption::MaxOutputTokens,
313                    monoloop_contracts::ConfigOption::ContinuationPolicy,
314                ]
315                .into_iter()
316                .collect(),
317                ..Default::default()
318            },
319            &ExtensionLimits::default(),
320        )
321        .unwrap()
322    }
323
324    #[test]
325    fn initial_encode_golden_shape() {
326        let enc = OpenAiChatCompletionsEncoder::default();
327        let input = user_text_input("Hello").unwrap();
328        let tid = TransactionId::generate();
329        let eid = ExchangeId::generate();
330        let cfg = effective("gpt-test");
331        let encoded = enc
332            .encode_initial(InitialEncodeRequest {
333                transaction_id: &tid,
334                exchange_id: &eid,
335                input: &input,
336                config: &cfg,
337                tools: &[],
338            })
339            .unwrap();
340        assert_eq!(
341            encoded.required_input_dialect.family,
342            monoloop_contracts::DialectFamily::OpenAiChatCompletions
343        );
344        assert_eq!(encoded.input_policy, ExchangeInputPolicy::SendAndFinish);
345        let v: Value = serde_json::from_slice(&encoded.bytes).unwrap();
346        assert_eq!(v["model"], "gpt-test");
347        assert_eq!(v["stream"], true);
348        assert_eq!(v["messages"][0]["role"], "user");
349        assert_eq!(v["messages"][0]["content"], "Hello");
350        assert_eq!(v["max_tokens"], 128);
351        assert!((v["temperature"].as_f64().unwrap() - 0.2).abs() < 1e-5);
352    }
353
354    #[test]
355    fn tools_and_max_completion_tokens() {
356        let enc = OpenAiChatCompletionsEncoder::new(OpenAiEncoderOptions {
357            use_max_completion_tokens: true,
358            ..Default::default()
359        });
360        let schema = JsonSchema::try_new(json!({"type": "object"})).unwrap();
361        let tool = ToolSpec::try_new(
362            ToolId::try_new("search").unwrap(),
363            ToolName::try_new("search").unwrap(),
364            "Search",
365            schema.clone(),
366            ToolOutputContract {
367                success: ToolSuccessContract::json(schema),
368                error_data_schema: None,
369            },
370            ToolLimits::default(),
371            ToolCancellationPolicy::Abortable,
372        )
373        .unwrap();
374        let input = user_text_input("q").unwrap();
375        let tid = TransactionId::generate();
376        let eid = ExchangeId::generate();
377        let cfg = effective("m");
378        let encoded = enc
379            .encode_initial(InitialEncodeRequest {
380                transaction_id: &tid,
381                exchange_id: &eid,
382                input: &input,
383                config: &cfg,
384                tools: &[tool],
385            })
386            .unwrap();
387        let v: Value = serde_json::from_slice(&encoded.bytes).unwrap();
388        assert!(v.get("max_tokens").is_none());
389        assert_eq!(v["max_completion_tokens"], 128);
390        assert_eq!(v["tools"][0]["function"]["name"], "search");
391    }
392
393    #[test]
394    fn reasoning_effort_rejected_without_capability() {
395        let enc = OpenAiChatCompletionsEncoder::default();
396        let inv = InvocationConfig {
397            model: Some("m".into()),
398            reasoning_effort: Some(ReasoningEffort::High),
399            ..Default::default()
400        };
401        let cfg = merge_effective_config(
402            &ChannelDefaults::default(),
403            None,
404            None,
405            &inv,
406            &OptionPolicy {
407                supported_invocation: [
408                    monoloop_contracts::ConfigOption::Model,
409                    monoloop_contracts::ConfigOption::ReasoningEffort,
410                    monoloop_contracts::ConfigOption::ContinuationPolicy,
411                ]
412                .into_iter()
413                .collect(),
414                ..Default::default()
415            },
416            &ExtensionLimits::default(),
417        )
418        .unwrap();
419        let input = user_text_input("x").unwrap();
420        let tid = TransactionId::generate();
421        let eid = ExchangeId::generate();
422        let err = enc
423            .encode_initial(InitialEncodeRequest {
424                transaction_id: &tid,
425                exchange_id: &eid,
426                input: &input,
427                config: &cfg,
428                tools: &[],
429            })
430            .unwrap_err();
431        assert!(matches!(
432            err,
433            EncodingError::Unsupported("reasoning_effort")
434        ));
435    }
436
437    #[test]
438    fn missing_model_invalid() {
439        let enc = OpenAiChatCompletionsEncoder::default();
440        let input = user_text_input("x").unwrap();
441        let tid = TransactionId::generate();
442        let eid = ExchangeId::generate();
443        let cfg = EffectiveConfig {
444            model: None,
445            ..effective("unused")
446        };
447        // override model to None
448        let mut cfg = cfg;
449        cfg.model = None;
450        let err = enc
451            .encode_initial(InitialEncodeRequest {
452                transaction_id: &tid,
453                exchange_id: &eid,
454                input: &input,
455                config: &cfg,
456                tools: &[],
457            })
458            .unwrap_err();
459        assert!(matches!(err, EncodingError::InvalidConfiguration));
460        let _ = CanonicalInput::try_new(vec![], &Default::default());
461    }
462
463    #[test]
464    fn openai_seed_extension_round_trip() {
465        use monoloop_contracts::{ExtensionKey, VersionedExtension};
466        let enc = OpenAiChatCompletionsEncoder::default();
467        let input = user_text_input("Hi").unwrap();
468        let tid = TransactionId::generate();
469        let eid = ExchangeId::generate();
470        let mut cfg = effective("gpt-test");
471        let key = ExtensionKey::try_new("openai.seed", 64).unwrap();
472        cfg.extensions.insert(
473            key,
474            VersionedExtension {
475                version: 1,
476                value: serde_json::json!(42),
477            },
478        );
479        let encoded = enc
480            .encode_initial(InitialEncodeRequest {
481                transaction_id: &tid,
482                exchange_id: &eid,
483                input: &input,
484                config: &cfg,
485                tools: &[],
486            })
487            .unwrap();
488        let v: Value = serde_json::from_slice(&encoded.bytes).unwrap();
489        assert_eq!(v["seed"], 42);
490    }
491
492    #[test]
493    fn unknown_openai_extension_fails_encode() {
494        use monoloop_contracts::{ExtensionKey, VersionedExtension};
495        let enc = OpenAiChatCompletionsEncoder::default();
496        let input = user_text_input("Hi").unwrap();
497        let tid = TransactionId::generate();
498        let eid = ExchangeId::generate();
499        let mut cfg = effective("gpt-test");
500        let key = ExtensionKey::try_new("openai.not_a_real_field", 64).unwrap();
501        cfg.extensions.insert(
502            key,
503            VersionedExtension {
504                version: 1,
505                value: serde_json::json!(1),
506            },
507        );
508        let err = enc
509            .encode_initial(InitialEncodeRequest {
510                transaction_id: &tid,
511                exchange_id: &eid,
512                input: &input,
513                config: &cfg,
514                tools: &[],
515            })
516            .unwrap_err();
517        assert!(matches!(err, EncodingError::Unsupported(_)));
518    }
519
520    #[test]
521    fn non_openai_extension_fails_encode() {
522        use monoloop_contracts::{ExtensionKey, VersionedExtension};
523        let enc = OpenAiChatCompletionsEncoder::default();
524        let input = user_text_input("Hi").unwrap();
525        let tid = TransactionId::generate();
526        let eid = ExchangeId::generate();
527        let mut cfg = effective("gpt-test");
528        let key = ExtensionKey::try_new("other.vendor", 64).unwrap();
529        cfg.extensions.insert(
530            key,
531            VersionedExtension {
532                version: 1,
533                value: serde_json::json!(true),
534            },
535        );
536        assert!(enc
537            .encode_initial(InitialEncodeRequest {
538                transaction_id: &tid,
539                exchange_id: &eid,
540                input: &input,
541                config: &cfg,
542                tools: &[],
543            })
544            .is_err());
545    }
546}