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        // D-031 residual: actor already appends tool results into ContinuationContext
150        // via append_exchange_to_transcript — do not append `request.results` again.
151        let _ = request.results;
152        let msgs = encode_messages_vec(request.context.messages())?;
153        self.encode_body(Value::Array(msgs), request.tools, request.config)
154    }
155}
156
157/// Map admitted `openai.*` extensions into Chat Completions body fields (D-023).
158///
159/// Any extension that cannot be represented fails closed — never silently dropped.
160fn encode_openai_extensions(
161    body: &mut Map<String, Value>,
162    extensions: &std::collections::BTreeMap<
163        monoloop_contracts::ExtensionKey,
164        monoloop_contracts::VersionedExtension,
165    >,
166) -> Result<(), EncodingError> {
167    for (key, ext) in extensions {
168        let Some(field) = key.as_str().strip_prefix("openai.") else {
169            return Err(EncodingError::Unsupported("non-openai extension"));
170        };
171        match field {
172            "seed" | "user" | "top_p" | "n" | "frequency_penalty" | "presence_penalty"
173            | "logit_bias" | "logprobs" | "top_logprobs" | "metadata" => {
174                if body.contains_key(field) {
175                    return Err(EncodingError::Unsupported("extension overrides body field"));
176                }
177                body.insert(field.to_string(), ext.value.clone());
178            }
179            _ => return Err(EncodingError::Unsupported("openai extension field")),
180        }
181    }
182    Ok(())
183}
184
185fn encode_messages(messages: &[CanonicalMessage]) -> Result<Value, EncodingError> {
186    Ok(Value::Array(encode_messages_vec(messages)?))
187}
188
189fn encode_messages_vec(messages: &[CanonicalMessage]) -> Result<Vec<Value>, EncodingError> {
190    let mut out = Vec::with_capacity(messages.len());
191    for msg in messages {
192        out.push(encode_message(msg)?);
193    }
194    Ok(out)
195}
196
197fn encode_message(msg: &CanonicalMessage) -> Result<Value, EncodingError> {
198    match msg {
199        CanonicalMessage::System { content, name } => {
200            let mut m = Map::new();
201            m.insert("role".into(), Value::String("system".into()));
202            m.insert("content".into(), Value::String(join_text(content)));
203            if let Some(n) = name {
204                m.insert("name".into(), Value::String(n.clone()));
205            }
206            Ok(Value::Object(m))
207        }
208        CanonicalMessage::User { content, name } => {
209            let mut m = Map::new();
210            m.insert("role".into(), Value::String("user".into()));
211            m.insert("content".into(), Value::String(join_text(content)));
212            if let Some(n) = name {
213                m.insert("name".into(), Value::String(n.clone()));
214            }
215            Ok(Value::Object(m))
216        }
217        CanonicalMessage::Assistant {
218            content,
219            tool_calls,
220        } => {
221            let mut m = Map::new();
222            m.insert("role".into(), Value::String("assistant".into()));
223            if content.is_empty() {
224                m.insert("content".into(), Value::Null);
225            } else {
226                m.insert("content".into(), Value::String(join_text(content)));
227            }
228            if !tool_calls.is_empty() {
229                let calls: Vec<Value> = tool_calls
230                    .iter()
231                    .map(|c| {
232                        let args =
233                            serde_json::to_string(&c.arguments).unwrap_or_else(|_| "{}".into());
234                        json!({
235                            "id": c.tool_call_id,
236                            "type": "function",
237                            "function": {
238                                "name": c.tool_name.as_str(),
239                                "arguments": args,
240                            }
241                        })
242                    })
243                    .collect();
244                m.insert("tool_calls".into(), Value::Array(calls));
245            }
246            Ok(Value::Object(m))
247        }
248        CanonicalMessage::Tool {
249            tool_call_id,
250            content,
251        } => Ok(json!({
252            "role": "tool",
253            "tool_call_id": tool_call_id,
254            "content": join_text(content),
255        })),
256    }
257}
258
259#[allow(dead_code)] // retained for potential direct-result encoding; continuation uses transcript Tool messages
260fn encode_tool_result_message(result: &CanonicalToolResult) -> Result<Value, EncodingError> {
261    let content = match &result.outcome {
262        CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Json(v)) => {
263            serde_json::to_string(v).map_err(|_| EncodingError::UnrepresentableInput)?
264        }
265        CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Text(t)) => t.clone(),
266        CanonicalToolResultOutcome::DomainFailed(err) => serde_json::to_string(&json!({
267            "error": { "code": err.code, "message": err.message, "data": err.data },
268        }))
269        .map_err(|_| EncodingError::UnrepresentableInput)?,
270    };
271    // Preserve provider tool call id exactly.
272    Ok(json!({
273        "role": "tool",
274        "tool_call_id": result.provider_tool_call_id,
275        "content": content,
276    }))
277}
278
279fn join_text(parts: &[monoloop_contracts::TextPart]) -> String {
280    parts.iter().map(|p| p.text()).collect::<Vec<_>>().join("")
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use monoloop_contracts::merge_effective_config;
287    use monoloop_contracts::{
288        user_text_input, CanonicalInput, ChannelDefaults, EffectiveConfig, ExchangeId,
289        InvocationConfig, JsonSchema, ToolExecutionClass, ToolId, ToolLimits, ToolName,
290        ToolOutputContract, ToolSuccessContract, TransactionId,
291    };
292    use monoloop_contracts::{ExtensionLimits, OptionPolicy};
293
294    fn effective(model: &str) -> EffectiveConfig {
295        let inv = InvocationConfig {
296            model: Some(model.into()),
297            temperature: Some(0.2),
298            max_output_tokens: Some(128),
299            ..Default::default()
300        };
301        merge_effective_config(
302            &ChannelDefaults::default(),
303            None,
304            None,
305            &inv,
306            &OptionPolicy {
307                supported_invocation: [
308                    monoloop_contracts::ConfigOption::Model,
309                    monoloop_contracts::ConfigOption::Temperature,
310                    monoloop_contracts::ConfigOption::MaxOutputTokens,
311                    monoloop_contracts::ConfigOption::ContinuationPolicy,
312                ]
313                .into_iter()
314                .collect(),
315                ..Default::default()
316            },
317            &ExtensionLimits::default(),
318        )
319        .unwrap()
320    }
321
322    #[test]
323    fn initial_encode_golden_shape() {
324        let enc = OpenAiChatCompletionsEncoder::default();
325        let input = user_text_input("Hello").unwrap();
326        let tid = TransactionId::generate();
327        let eid = ExchangeId::generate();
328        let cfg = effective("gpt-test");
329        let encoded = enc
330            .encode_initial(InitialEncodeRequest {
331                transaction_id: &tid,
332                exchange_id: &eid,
333                input: &input,
334                config: &cfg,
335                tools: &[],
336            })
337            .unwrap();
338        assert_eq!(
339            encoded.required_input_dialect.family,
340            monoloop_contracts::DialectFamily::OpenAiChatCompletions
341        );
342        assert_eq!(encoded.input_policy, ExchangeInputPolicy::SendAndFinish);
343        let v: Value = serde_json::from_slice(&encoded.bytes).unwrap();
344        assert_eq!(v["model"], "gpt-test");
345        assert_eq!(v["stream"], true);
346        assert_eq!(v["messages"][0]["role"], "user");
347        assert_eq!(v["messages"][0]["content"], "Hello");
348        assert_eq!(v["max_tokens"], 128);
349        assert!((v["temperature"].as_f64().unwrap() - 0.2).abs() < 1e-5);
350    }
351
352    #[test]
353    fn tools_and_max_completion_tokens() {
354        let enc = OpenAiChatCompletionsEncoder::new(OpenAiEncoderOptions {
355            use_max_completion_tokens: true,
356            ..Default::default()
357        });
358        let schema = JsonSchema::try_new(json!({"type": "object"})).unwrap();
359        let tool = ToolSpec::try_new(
360            ToolId::try_new("search").unwrap(),
361            ToolName::try_new("search").unwrap(),
362            "Search",
363            schema.clone(),
364            ToolOutputContract {
365                success: ToolSuccessContract::json(schema),
366                error_data_schema: None,
367            },
368            ToolLimits::default(),
369            ToolExecutionClass::AbortableAtYield {
370                grace: std::time::Duration::from_secs(1),
371            },
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}