mindfork 0.11.1

A terminal AI chat written in Rust: local models via llama.cpp or OpenAI, Anthropic, Gemini and Grok in the cloud, with persistent memory, notes, RAG and tools.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
//! Serde types for the OpenAI Responses protocol (`/v1/responses`) and translating the domain
//! [`ChatRequest`] into its format. Differences from Chat Completions (see ADR 0004,
//! docs/research/openai-responses-client.md):
//! - the system message — top-level `instructions` (not a role in `messages`);
//! - history — an `input` array of **items** (messages, `reasoning`,
//!   `function_call`, `function_call_output`), not `messages` with `tool_calls`;
//! - a tool result — a `function_call_output` item (there's no `tool` role);
//! - the token limit — `max_output_tokens` (includes reasoning tokens!);
//! - a reasoning item with `encrypted_content` is returned **before** its own
//!   `function_call` (stateless mode `store:false`, an analog of Anthropic's thinking signature).
//!
//! Event-based SSE: the event tag is the `type` field inside `data` (like Anthropic).

use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

use crate::shared::api::contract::{ApiImage, ApiMessage, ApiRole, ChatRequest, FinishReason};

// ---------- request ----------

#[derive(Debug, Serialize)]
pub struct RespRequest {
    pub model: String,
    /// The system message (top-level, not in `input`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub instructions: Option<String>,
    pub input: Vec<Value>,
    pub stream: bool,
    /// The app keeps its own history → don't ask the server to store replies (privacy,
    /// stateless). With `store:false`, reasoning items are returned in `input`.
    pub store: bool,
    /// `include: ["reasoning.encrypted_content"]` — ask for the encrypted reasoning
    /// in reasoning items (needed for resending on tool-use). Only sent when
    /// reasoning is enabled (otherwise pointless/extraneous on non-reasoning models).
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub include: Vec<&'static str>,
    /// The reply's token limit (includes reasoning tokens — with a stingy value the reasoning
    /// eats the budget and `output_text` comes back empty; see docs/research/openai-responses-client.md).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_output_tokens: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reasoning: Option<RespReasoning>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<RespText>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tools: Option<Vec<RespTool>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_choice: Option<&'static str>,
}

/// The reasoning config. `effort` — depth (`none`/`minimal`/`low`/`medium`/`high`/
/// `xhigh`); `summary` — `auto` for a visible "thoughts" summary (the API doesn't return raw CoT).
#[derive(Debug, Serialize)]
pub struct RespReasoning {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub effort: Option<&'static str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<&'static str>,
}

/// `text.verbosity` — how verbose the reply is.
#[derive(Debug, Serialize)]
pub struct RespText {
    pub verbosity: &'static str,
}

/// A function tool in Responses' flat form (`{type,name,description,parameters,strict}`).
/// `strict:false` — our schemas don't satisfy strict mode's requirements
/// (`additionalProperties:false` + every field in `required`), and by default Responses
/// "tries strict" — disable it explicitly.
#[derive(Debug, Serialize)]
pub struct RespTool {
    #[serde(rename = "type")]
    pub kind: &'static str,
    pub name: String,
    pub description: String,
    pub parameters: Value,
    pub strict: bool,
}

/// Builds the Responses request body from the domain [`ChatRequest`]. `model` is required.
/// From sampling, only `max_tokens`→`max_output_tokens`, `thinking`/
/// `reasoning_effort`→`reasoning`, `verbosity`→`text` apply — Responses has no other fields.
pub fn build_request(req: &ChatRequest, model: &str, stream: bool) -> RespRequest {
    let s = &req.sampling;
    // reasoning_budget==0 forces "thoughts" off (impersonation/auto-title),
    // like llama.cpp's reasoning_budget=0: effort=none, no summary.
    let force_off = s.reasoning_budget == Some(0);
    let want_summary = !force_off && s.thinking == Some(true);
    let effort = if force_off {
        Some("none")
    } else {
        s.reasoning_effort.map(|e| e.as_wire())
    };
    // summary: "detailed", not "auto" — some models return an EMPTY summary at "auto",
    // but text at "detailed" (per developer reports; gpt-5.x supports detailed).
    // IMPORTANT: reasoning summaries only reach organizations that passed verification
    // (platform.openai.com/settings/organization/general) — otherwise the "thoughts" stream is empty
    // (or an unverified org gets a 400 on the mere presence of `reasoning.summary`).
    let reasoning = (want_summary || effort.is_some()).then_some(RespReasoning {
        effort,
        summary: want_summary.then_some("detailed"),
    });
    // The encrypted reasoning is only needed when reasoning is actually enabled.
    let include = if reasoning.is_some() {
        vec!["reasoning.encrypted_content"]
    } else {
        Vec::new()
    };

    let tools = if req.tools.is_empty() {
        None
    } else {
        Some(
            req.tools
                .iter()
                .map(|t| RespTool {
                    kind: "function",
                    name: t.name.clone(),
                    description: t.description.clone(),
                    parameters: t.parameters.clone(),
                    strict: false,
                })
                .collect(),
        )
    };
    let tool_choice = tools.as_ref().map(|_| "auto");

    RespRequest {
        model: model.to_string(),
        instructions: req.system.clone(),
        input: build_input(req),
        stream,
        store: false,
        include,
        max_output_tokens: s.max_tokens,
        reasoning,
        text: s.verbosity.map(|v| RespText {
            verbosity: v.as_wire(),
        }),
        tools,
        tool_choice,
    }
}

/// Translates history into Responses' `input` array. The reasoning items (each
/// `id`+`encrypted_content`) are placed **before** the `function_call` items of the
/// same assistant turn — Responses expects them ahead of the calls, every one of
/// them and untouched (see [`ThinkingBlock`](crate::shared::api::contract::ThinkingBlock)).
fn build_input(req: &ChatRequest) -> Vec<Value> {
    let mut items = Vec::new();
    for m in &req.messages {
        match m.role {
            // The system message goes into top-level `instructions`.
            ApiRole::System => continue,
            ApiRole::User => items.push(json!({
                "type": "message", "role": "user", "content": user_content(m),
            })),
            ApiRole::Assistant => push_assistant_items(m, &mut items),
            ApiRole::Tool => items.push(json!({
                "type": "function_call_output",
                "call_id": m.tool_call_id.clone().unwrap_or_default(),
                "output": tool_output(m),
            })),
        }
    }
    items
}

/// The `content` of a user item: the bare string when there are no images, otherwise an
/// array of typed parts — images (each behind its label) first, then the text.
///
/// Responses names its input parts `input_text`/`input_image` rather than the
/// `text`/`image_url` of Chat Completions, and takes the payload as a `data:` URI on
/// `image_url` directly (not nested in an object, as the older API does). A message with
/// no images keeps serializing as a plain string, so nothing about an existing request
/// changes — the same guarantee the Chat Completions builder makes.
fn user_content(m: &ApiMessage) -> Value {
    if m.images.is_empty() {
        return json!(m.content);
    }
    let mut parts = image_parts(&m.images);
    if !m.content.is_empty() {
        parts.push(json!({ "type": "input_text", "text": m.content }));
    }
    json!(parts)
}

/// The `output` of a `function_call_output` item (spec §9.10,
/// docs/research/mcp-tool-images.md §2.2).
///
/// Responses accepts an **array of parts** here as well as a string — verified live on
/// gpt-5-nano — which is how an MCP screenshot reaches the model. The result text comes
/// first (it is the tool's answer; the image illustrates it), then each image behind its
/// label. With no images the output stays the bare string it always was, so no stored
/// conversation changes shape.
fn tool_output(m: &ApiMessage) -> Value {
    if m.images.is_empty() {
        return json!(m.content);
    }
    let mut parts = Vec::with_capacity(m.images.len() * 2 + 1);
    if !m.content.is_empty() {
        parts.push(json!({ "type": "input_text", "text": m.content }));
    }
    parts.extend(image_parts(&m.images));
    json!(parts)
}

/// Image parts, each preceded by its label when it has one. Shared by the user message and
/// the tool output so both carry the payload in the same `data:` URI form.
fn image_parts(images: &[ApiImage]) -> Vec<Value> {
    let mut parts = Vec::with_capacity(images.len() * 2);
    for image in images {
        if let Some(label) = &image.label {
            parts.push(json!({ "type": "input_text", "text": label }));
        }
        parts.push(json!({
            "type": "input_image",
            "image_url": format!("data:{};base64,{}", image.mime, image.data),
        }));
    }
    parts
}

/// An assistant turn's `input` items: the reasoning items (which must precede
/// the calls), the text message, then the `function_call` items.
fn push_assistant_items(m: &ApiMessage, items: &mut Vec<Value>) {
    // The current turn's reasoning items, in the reply's order (only those with an
    // id — only OpenAI Responses carries it; other backends have thinking.id == None).
    // A reply carries several when the model reasons in stages (gpt-5.6: two to
    // five in half the replies probed), every one before the calls in every shape
    // observed; the API accepts them in that order and rejects a fusion of two
    // under one id (`400 invalid_encrypted_content`) — docs/journal/engine.md.
    for tb in &m.thinking {
        let Some(id) = &tb.id else { continue };
        // `summary` is a REQUIRED field of a reasoning item in the Responses API
        // (otherwise 400 `Missing required parameter: 'input[N].summary'`). Send an
        // empty array: the meaning is carried by `encrypted_content`, and the summary text
        // isn't needed for resending (and for an unverified org it's empty, §7a
        // docs/research/openai-responses-client.md).
        items.push(json!({
            "type": "reasoning",
            "id": id,
            "summary": [],
            "encrypted_content": tb.signature,
        }));
    }
    if !m.content.is_empty() {
        items.push(json!({
            "type": "message", "role": "assistant", "content": m.content,
        }));
    }
    for tc in &m.tool_calls {
        // arguments — a JSON string; an argument-less call → an empty object.
        let args = if tc.arguments.is_empty() {
            "{}"
        } else {
            tc.arguments.as_str()
        };
        items.push(json!({
            "type": "function_call",
            "call_id": tc.id,
            "name": tc.name,
            "arguments": args,
        }));
    }
}

// ---------- streaming events ----------

/// A Responses SSE event (the tag is the `type` field in `data`). Uninteresting events
/// (`response.created`, `*.part.added`, `*.done` other than `output_item.done`, `ping`)
/// land in [`RespEvent::Other`].
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum RespEvent {
    #[serde(rename = "response.output_text.delta")]
    OutputTextDelta {
        #[serde(default)]
        delta: String,
    },
    #[serde(rename = "response.reasoning_summary_text.delta")]
    ReasoningSummaryDelta {
        #[serde(default)]
        delta: String,
    },
    /// Some reasoning models/settings stream the reasoning via this event rather than the
    /// summary event — handle both (both → `ChatChunk::Thoughts`).
    #[serde(rename = "response.reasoning_text.delta")]
    ReasoningTextDelta {
        #[serde(default)]
        delta: String,
    },
    #[serde(rename = "response.output_item.added")]
    OutputItemAdded {
        #[serde(default)]
        output_index: usize,
        item: RespItem,
    },
    #[serde(rename = "response.output_item.done")]
    OutputItemDone { item: RespItem },
    #[serde(rename = "response.function_call_arguments.delta")]
    FunctionArgsDelta {
        #[serde(default)]
        output_index: usize,
        #[serde(default)]
        delta: String,
    },
    #[serde(rename = "response.completed")]
    Completed { response: RespBody },
    #[serde(rename = "response.incomplete")]
    Incomplete {
        #[serde(default)]
        response: Option<RespBody>,
    },
    /// The response died after the stream had been accepted. The reason travels in
    /// the response object's `error`, which is why it is carried here rather than
    /// dropped — a bare "failed" is a note that says nothing.
    #[serde(rename = "response.failed")]
    Failed {
        #[serde(default)]
        response: Option<RespBody>,
    },
    #[serde(rename = "error")]
    Error {
        #[serde(default)]
        code: Option<String>,
        #[serde(default)]
        message: Option<String>,
    },
    #[serde(other)]
    Other,
}

/// An output item (`output_item.added`/`done`). Only `function_call` (gives
/// `call_id`+name) and `reasoning` (in `done` carries `encrypted_content`) matter; anything else (a text
/// message etc.) — [`RespItem::Other`].
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
pub enum RespItem {
    #[serde(rename = "function_call")]
    FunctionCall {
        #[serde(default)]
        call_id: String,
        #[serde(default)]
        name: String,
    },
    #[serde(rename = "reasoning")]
    Reasoning {
        #[serde(default)]
        id: String,
        #[serde(default)]
        encrypted_content: Option<String>,
    },
    #[serde(other)]
    Other,
}

/// The `response` object in terminal events (`completed`/`incomplete`). Only the
/// token counter matters — `status`/other fields are ignored (the client infers the finish reason).
#[derive(Debug, Default, Deserialize)]
pub struct RespBody {
    #[serde(default)]
    pub usage: Option<RespUsage>,
    /// Populated on `response.failed` — why the response died mid-stream.
    #[serde(default)]
    pub error: Option<RespError>,
    /// Populated on `response.incomplete` — which limit cut the response.
    #[serde(default)]
    pub incomplete_details: Option<RespIncomplete>,
}

/// The `incomplete_details` object of an incomplete response: `reason` is
/// `max_output_tokens` or `content_filter`.
#[derive(Debug, Default, Deserialize)]
pub struct RespIncomplete {
    #[serde(default)]
    pub reason: String,
}

impl RespIncomplete {
    /// The domain reason for an incomplete response. Anything but the filter is a
    /// limit, which is what every incomplete response was read as before the
    /// reason was looked at
    /// ([docs/research/content-filter-finish.md](../../../../docs/research/content-filter-finish.md)).
    pub fn finish_reason(details: Option<&Self>) -> FinishReason {
        match details.map(|d| d.reason.as_str()) {
            Some("content_filter") => FinishReason::Filtered,
            _ => FinishReason::Length,
        }
    }
}

/// The `error` object of a failed response: a string `code`
/// (`rate_limit_exceeded`, `server_error`, …) plus prose.
#[derive(Debug, Default, Deserialize)]
pub struct RespError {
    #[serde(default)]
    pub code: Option<String>,
    #[serde(default)]
    pub message: String,
}

/// The token counter of a Responses reply (`input_tokens`/`output_tokens` +
/// `output_tokens_details.reasoning_tokens`).
#[derive(Debug, Default, Deserialize)]
pub struct RespUsage {
    #[serde(default)]
    pub input_tokens: u32,
    #[serde(default)]
    pub output_tokens: u32,
    #[serde(default)]
    pub output_tokens_details: RespOutputTokensDetails,
}

/// Token breakdown of the reply (only "thoughts" reasoning tokens matter).
#[derive(Debug, Default, Deserialize)]
pub struct RespOutputTokensDetails {
    #[serde(default)]
    pub reasoning_tokens: u32,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::entities::sampling::{ReasoningEffort, SamplingConfig, Verbosity};
    use crate::shared::api::contract::{ApiMessage, ApiToolCall, ThinkingBlock, ToolSchema};

    fn base_req(messages: Vec<ApiMessage>) -> ChatRequest {
        ChatRequest {
            continue_final: false,
            system: Some("Ты — ассистент.".into()),
            messages,
            sampling: SamplingConfig {
                max_tokens: Some(256),
                ..Default::default()
            },
            tools: vec![],
        }
    }

    /// The mirror of the Chat Completions guarantee (spec §9.10): with no images the
    /// user item's `content` stays a bare string, not a one-element parts array.
    #[test]
    fn a_text_only_user_item_keeps_its_string_content() {
        let json = serde_json::to_value(build_request(
            &base_req(vec![ApiMessage::user("привет")]),
            "gpt-5",
            false,
        ))
        .unwrap();
        assert_eq!(json["input"][0]["type"], "message");
        assert!(
            json["input"][0]["content"].is_string(),
            "got {:?}",
            json["input"][0]["content"]
        );
        assert_eq!(json["input"][0]["content"], "привет");
    }

    #[test]
    fn images_become_input_image_parts_ahead_of_the_text() {
        let msg =
            ApiMessage::user("what is this?").with_images(vec![crate::shared::api::ApiImage::new(
                "image/png",
                "QUJD",
                Some("Image #1 — \"a.png\":".into()),
            )]);
        let json =
            serde_json::to_value(build_request(&base_req(vec![msg]), "gpt-5", false)).unwrap();
        let parts = json["input"][0]["content"].as_array().unwrap();
        assert_eq!(parts.len(), 3);
        // Responses names its input parts differently from Chat Completions, and takes
        // the data URI on `image_url` directly rather than nested in an object.
        assert_eq!(parts[0]["type"], "input_text");
        assert_eq!(parts[0]["text"], "Image #1 — \"a.png\":");
        assert_eq!(parts[1]["type"], "input_image");
        assert_eq!(parts[1]["image_url"], "data:image/png;base64,QUJD");
        assert_eq!(parts[2]["type"], "input_text");
        assert_eq!(parts[2]["text"], "what is this?");
    }

    #[test]
    fn an_image_only_item_carries_no_empty_text_part() {
        let msg = ApiMessage::user("").with_images(vec![crate::shared::api::ApiImage::new(
            "image/jpeg",
            "QQ==",
            None,
        )]);
        let json =
            serde_json::to_value(build_request(&base_req(vec![msg]), "gpt-5", false)).unwrap();
        let parts = json["input"][0]["content"].as_array().unwrap();
        assert_eq!(parts.len(), 1);
        assert_eq!(parts[0]["image_url"], "data:image/jpeg;base64,QQ==");
    }

    /// The tool-side mirror: a `function_call_output` with no images keeps a bare string
    /// `output`, not a one-element parts array.
    #[test]
    fn a_tool_output_without_images_keeps_its_string_form() {
        let r = base_req(vec![
            ApiMessage::user("посчитай"),
            ApiMessage::assistant_tool_calls(
                "",
                vec![ApiToolCall {
                    thought_signature: None,
                    id: "call_1".into(),
                    name: "calc".into(),
                    arguments: "{}".into(),
                }],
            ),
            ApiMessage::tool("call_1", "2"),
        ]);
        let json = serde_json::to_value(build_request(&r, "gpt-x", false)).unwrap();
        assert_eq!(json["input"][2]["type"], "function_call_output");
        assert!(
            json["input"][2]["output"].is_string(),
            "got {:?}",
            json["input"][2]["output"]
        );
        assert_eq!(json["input"][2]["output"], "2");
    }

    /// An MCP screenshot tool: the output widens into a parts array — result text first,
    /// then the labelled image as a `data:` URI on `input_image`.
    #[test]
    fn a_tool_output_image_follows_the_result_text() {
        let r = base_req(vec![
            ApiMessage::user("сними скриншот"),
            ApiMessage::assistant_tool_calls(
                "",
                vec![ApiToolCall {
                    thought_signature: None,
                    id: "call_1".into(),
                    name: "screenshot".into(),
                    arguments: "{}".into(),
                }],
            ),
            ApiMessage::tool("call_1", "screenshot taken").with_images(vec![
                crate::shared::api::ApiImage::new(
                    "image/png",
                    "QUJD",
                    Some("Image #1 — \"shot.png\":".into()),
                ),
            ]),
        ]);
        let json = serde_json::to_value(build_request(&r, "gpt-x", false)).unwrap();
        let item = &json["input"][2];
        assert_eq!(item["type"], "function_call_output");
        assert_eq!(item["call_id"], "call_1");
        let parts = item["output"].as_array().unwrap();
        assert_eq!(parts.len(), 3);
        assert_eq!(parts[0]["type"], "input_text");
        assert_eq!(parts[0]["text"], "screenshot taken");
        assert_eq!(parts[1]["type"], "input_text");
        assert_eq!(parts[1]["text"], "Image #1 — \"shot.png\":");
        assert_eq!(parts[2]["type"], "input_image");
        assert_eq!(parts[2]["image_url"], "data:image/png;base64,QUJD");
    }

    /// A tool that returns only an image adds no empty text part.
    #[test]
    fn an_image_only_tool_output_carries_no_empty_text_part() {
        let r = base_req(vec![ApiMessage::tool("call_1", "").with_images(vec![
            crate::shared::api::ApiImage::new("image/jpeg", "QQ==", None),
        ])]);
        let json = serde_json::to_value(build_request(&r, "gpt-x", false)).unwrap();
        let parts = json["input"][0]["output"].as_array().unwrap();
        assert_eq!(parts.len(), 1);
        assert_eq!(parts[0]["type"], "input_image");
        assert_eq!(parts[0]["image_url"], "data:image/jpeg;base64,QQ==");
    }

    #[test]
    fn system_is_instructions_and_store_false() {
        let json = serde_json::to_value(build_request(
            &base_req(vec![ApiMessage::user("hi")]),
            "gpt-x",
            true,
        ))
        .unwrap();
        assert_eq!(json["model"], "gpt-x");
        assert_eq!(json["instructions"], "Ты — ассистент.");
        assert_eq!(json["store"], false);
        assert_eq!(json["max_output_tokens"], 256);
        assert!(json.get("max_tokens").is_none());
        // The first input item — a user message with string content.
        assert_eq!(json["input"][0]["type"], "message");
        assert_eq!(json["input"][0]["role"], "user");
        assert_eq!(json["input"][0]["content"], "hi");
        // Without reasoning, don't ask for encrypted_content and don't send reasoning/text.
        assert!(json.get("include").is_none());
        assert!(json.get("reasoning").is_none());
        assert!(json.get("text").is_none());
    }

    #[test]
    fn reasoning_summary_and_effort_and_verbosity() {
        let mut r = base_req(vec![ApiMessage::user("посчитай")]);
        r.sampling.thinking = Some(true);
        r.sampling.reasoning_effort = Some(ReasoningEffort::XHigh);
        r.sampling.verbosity = Some(Verbosity::Low);
        let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
        assert_eq!(json["reasoning"]["effort"], "xhigh");
        assert_eq!(json["reasoning"]["summary"], "detailed");
        assert_eq!(json["text"]["verbosity"], "low");
        // Reasoning is enabled → ask for the encrypted reasoning.
        assert_eq!(json["include"][0], "reasoning.encrypted_content");
    }

    #[test]
    fn reasoning_budget_zero_forces_off() {
        // thinking is enabled, but reasoning_budget=0 (impersonation/auto-title) →
        // effort=none, no summary; include isn't sent.
        let mut r = base_req(vec![ApiMessage::user("hi")]);
        r.sampling.thinking = Some(true);
        r.sampling.reasoning_budget = Some(0);
        let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
        assert_eq!(json["reasoning"]["effort"], "none");
        assert!(json["reasoning"].get("summary").is_none());
    }

    #[test]
    fn effort_without_summary_when_thinking_off() {
        let mut r = base_req(vec![ApiMessage::user("hi")]);
        r.sampling.reasoning_effort = Some(ReasoningEffort::Low);
        let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
        assert_eq!(json["reasoning"]["effort"], "low");
        assert!(json["reasoning"].get("summary").is_none());
        // effort is set (reasoning exists) → include is present.
        assert_eq!(json["include"][0], "reasoning.encrypted_content");
    }

    #[test]
    fn tools_are_flat_with_strict_false() {
        let mut r = base_req(vec![ApiMessage::user("hi")]);
        r.tools = vec![ToolSchema {
            name: "calc".into(),
            description: "Считает".into(),
            parameters: json!({"type":"object"}),
        }];
        let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
        assert_eq!(json["tools"][0]["type"], "function");
        assert_eq!(json["tools"][0]["name"], "calc");
        assert_eq!(json["tools"][0]["strict"], false);
        assert_eq!(json["tools"][0]["parameters"]["type"], "object");
        assert_eq!(json["tool_choice"], "auto");
    }

    #[test]
    fn tool_call_and_result_become_items() {
        let r = base_req(vec![
            ApiMessage::user("посчитай"),
            ApiMessage::assistant_tool_calls(
                "",
                vec![ApiToolCall {
                    thought_signature: None,
                    id: "call_1".into(),
                    name: "calc".into(),
                    arguments: "{\"x\":1}".into(),
                }],
            ),
            ApiMessage::tool("call_1", "2"),
        ]);
        let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
        // [0] user message, [1] function_call, [2] function_call_output.
        assert_eq!(json["input"][1]["type"], "function_call");
        assert_eq!(json["input"][1]["call_id"], "call_1");
        assert_eq!(json["input"][1]["name"], "calc");
        assert_eq!(json["input"][1]["arguments"], "{\"x\":1}");
        assert_eq!(json["input"][2]["type"], "function_call_output");
        assert_eq!(json["input"][2]["call_id"], "call_1");
        assert_eq!(json["input"][2]["output"], "2");
    }

    #[test]
    fn reasoning_item_precedes_function_call() {
        // An assistant turn with thinking (id+encrypted) → a reasoning item before function_call.
        let r = base_req(vec![
            ApiMessage::user("посчитай"),
            ApiMessage::assistant_tool_calls(
                "",
                vec![ApiToolCall {
                    thought_signature: None,
                    id: "call_1".into(),
                    name: "calc".into(),
                    arguments: "{}".into(),
                }],
            )
            .with_thinking_blocks(vec![ThinkingBlock {
                text: "резюме".into(),
                signature: "gAAA-enc".into(),
                id: Some("rs_42".into()),
            }]),
            ApiMessage::tool("call_1", "2"),
        ]);
        let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
        // [0] user, [1] reasoning (id+summary+encrypted), [2] function_call, [3] output.
        assert_eq!(json["input"][1]["type"], "reasoning");
        assert_eq!(json["input"][1]["id"], "rs_42");
        assert_eq!(json["input"][1]["encrypted_content"], "gAAA-enc");
        // `summary` is required for a reasoning item (otherwise 400) — send an empty array.
        assert_eq!(json["input"][1]["summary"], json!([]));
        assert_eq!(json["input"][2]["type"], "function_call");
    }

    #[test]
    fn thinking_without_id_omits_reasoning_item() {
        // A thinking block with no id (Anthropic-style) gives no reasoning item in Responses.
        let r = base_req(vec![
            ApiMessage::user("hi"),
            ApiMessage::assistant_tool_calls(
                "",
                vec![ApiToolCall {
                    thought_signature: None,
                    id: "call_1".into(),
                    name: "calc".into(),
                    arguments: "{}".into(),
                }],
            )
            .with_thinking_blocks(vec![ThinkingBlock {
                text: "x".into(),
                signature: "sig".into(),
                id: None,
            }]),
        ]);
        let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
        assert_eq!(json["input"][1]["type"], "function_call");
    }

    #[test]
    fn several_reasoning_items_are_resent_each_under_its_own_id_in_order() {
        // gpt-5.6 returns two to five reasoning items in one reply; every one goes
        // back as its own item, in the reply's order, before the calls. Fusing them
        // under the last id is what the API rejects (400 invalid_encrypted_content).
        let blocks: Vec<ThinkingBlock> = ["rs_1", "rs_2", "rs_3"]
            .iter()
            .enumerate()
            .map(|(i, id)| ThinkingBlock {
                text: String::new(),
                signature: format!("enc-{i}"),
                id: Some((*id).into()),
            })
            .collect();
        let r = base_req(vec![
            ApiMessage::user("hi"),
            ApiMessage::assistant_tool_calls(
                "",
                vec![ApiToolCall {
                    thought_signature: None,
                    id: "call_1".into(),
                    name: "calc".into(),
                    arguments: "{}".into(),
                }],
            )
            .with_thinking_blocks(blocks),
            ApiMessage::tool("call_1", "2"),
        ]);
        let json = serde_json::to_value(build_request(&r, "gpt-x", true)).unwrap();
        let input = json["input"].as_array().unwrap();
        // [0] user, [1..=3] the three reasoning items, [4] function_call, [5] output.
        assert_eq!(input.len(), 6);
        for (i, id) in ["rs_1", "rs_2", "rs_3"].iter().enumerate() {
            assert_eq!(input[i + 1]["type"], "reasoning");
            assert_eq!(input[i + 1]["id"], *id);
            assert_eq!(input[i + 1]["encrypted_content"], format!("enc-{i}"));
            assert_eq!(input[i + 1]["summary"], json!([]));
        }
        assert_eq!(input[4]["type"], "function_call");
        assert_eq!(input[5]["type"], "function_call_output");
    }

    #[test]
    fn parses_stream_events() {
        let td = r#"{"type":"response.output_text.delta","delta":"hi"}"#;
        assert!(matches!(
            serde_json::from_str::<RespEvent>(td).unwrap(),
            RespEvent::OutputTextDelta { delta } if delta == "hi"
        ));
        let rd = r#"{"type":"response.reasoning_summary_text.delta","delta":"думаю"}"#;
        assert!(matches!(
            serde_json::from_str::<RespEvent>(rd).unwrap(),
            RespEvent::ReasoningSummaryDelta { delta } if delta == "думаю"
        ));
        // The alternate reasoning event — also recognized.
        let rt = r#"{"type":"response.reasoning_text.delta","delta":"шаг"}"#;
        assert!(matches!(
            serde_json::from_str::<RespEvent>(rt).unwrap(),
            RespEvent::ReasoningTextDelta { delta } if delta == "шаг"
        ));
        let added = r#"{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"calc","arguments":""}}"#;
        match serde_json::from_str::<RespEvent>(added).unwrap() {
            RespEvent::OutputItemAdded {
                output_index,
                item: RespItem::FunctionCall { call_id, name },
            } => {
                assert_eq!(output_index, 1);
                assert_eq!(call_id, "call_1");
                assert_eq!(name, "calc");
            }
            other => panic!("expected function_call added, got {other:?}"),
        }
        let done = r#"{"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs_9","encrypted_content":"ENC"}}"#;
        match serde_json::from_str::<RespEvent>(done).unwrap() {
            RespEvent::OutputItemDone {
                item:
                    RespItem::Reasoning {
                        id,
                        encrypted_content: Some(enc),
                    },
                ..
            } => {
                assert_eq!(id, "rs_9");
                assert_eq!(enc, "ENC");
            }
            other => panic!("expected reasoning done, got {other:?}"),
        }
        let args = r#"{"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"x\":1}"}"#;
        assert!(matches!(
            serde_json::from_str::<RespEvent>(args).unwrap(),
            RespEvent::FunctionArgsDelta { output_index, delta } if output_index == 1 && delta == "{\"x\":1}"
        ));
        let completed = r#"{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":42,"output_tokens":7}}}"#;
        match serde_json::from_str::<RespEvent>(completed).unwrap() {
            RespEvent::Completed { response } => {
                let u = response.usage.unwrap();
                assert_eq!(u.input_tokens, 42);
                assert_eq!(u.output_tokens, 7);
            }
            other => panic!("expected completed, got {other:?}"),
        }
        // An uninteresting event → Other (no crash).
        assert!(matches!(
            serde_json::from_str::<RespEvent>(r#"{"type":"response.created","response":{}}"#)
                .unwrap(),
            RespEvent::Other
        ));
        // A text message as an output item → RespItem::Other.
        let msg_added = r#"{"type":"response.output_item.added","output_index":2,"item":{"type":"message","role":"assistant","content":[]}}"#;
        assert!(matches!(
            serde_json::from_str::<RespEvent>(msg_added).unwrap(),
            RespEvent::OutputItemAdded {
                item: RespItem::Other,
                ..
            }
        ));
    }
}

/// Terminal failure events. Both used to end the turn with no reason attached —
/// `response.failed` carried none at all, and the `error` event's went to the log.
#[cfg(test)]
mod failure_event_tests {
    use super::*;

    #[test]
    fn response_failed_carries_its_reason() {
        let data = r#"{"type":"response.failed","response":{"id":"resp_1","status":"failed",
            "error":{"code":"server_error","message":"The model failed to generate a response."}}}"#;
        let RespEvent::Failed { response } = serde_json::from_str(data).unwrap() else {
            panic!("expected response.failed")
        };
        let err = response
            .and_then(|r| r.error)
            .expect("the reason must survive");
        assert_eq!(err.code.as_deref(), Some("server_error"));
        assert!(err.message.contains("failed to generate"));
        assert!(crate::shared::api::error::stream_error_transient(
            err.code.as_deref().unwrap_or_default(),
            None
        ));
    }

    /// A `failed` without an error object must still parse — the note then names
    /// the event rather than nothing.
    #[test]
    fn response_failed_without_a_reason_still_parses() {
        let data = r#"{"type":"response.failed"}"#;
        let RespEvent::Failed { response } = serde_json::from_str(data).unwrap() else {
            panic!("expected response.failed")
        };
        assert!(response.is_none());
    }

    #[test]
    fn the_error_event_carries_code_and_message() {
        let data = r#"{"type":"error","code":"rate_limit_exceeded","message":"Rate limit reached","param":null,"sequence_number":7}"#;
        let RespEvent::Error { code, message } = serde_json::from_str(data).unwrap() else {
            panic!("expected an error event")
        };
        assert_eq!(code.as_deref(), Some("rate_limit_exceeded"));
        assert_eq!(message.as_deref(), Some("Rate limit reached"));
    }

    /// `response.completed` must keep parsing now that RespBody has a new field.
    #[test]
    fn completed_still_parses_with_usage() {
        let data = r#"{"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":7,
            "output_tokens_details":{"reasoning_tokens":2}}}}"#;
        let RespEvent::Completed { response } = serde_json::from_str(data).unwrap() else {
            panic!("expected response.completed")
        };
        let u = response.usage.unwrap();
        assert_eq!((u.input_tokens, u.output_tokens), (5, 7));
        assert!(response.error.is_none());
    }
}