Source file convert_response.ml

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
open Melange_json.Primitives

type function_call_json = {
  name : string; [@json.default ""]
  arguments : string; [@json.default ""]
}
[@@json.allow_extra_fields] [@@deriving of_json]

type tool_call_json = {
  id : string; [@json.default ""]
  type_ : string; [@json.key "type"] [@json.default "function"]
  function_ : function_call_json; [@json.key "function"]
}
[@@json.allow_extra_fields] [@@deriving of_json]

type reasoning_detail_json = {
  type_ : string; [@json.key "type"] [@json.default ""]
  text : string option; [@json.default None]
  signature : string option; [@json.default None]
  data : string option; [@json.default None]
  summary : string option; [@json.default None]
}
[@@json.allow_extra_fields] [@@deriving of_json]

type annotation_json = {
  type_ : string; [@json.key "type"] [@json.default ""]
  url : string option; [@json.default None]
  title : string option; [@json.default None]
  start_index : int option; [@json.default None]
  end_index : int option; [@json.default None]
}
[@@json.allow_extra_fields] [@@deriving of_json]

type image_json = { url : string } [@@json.allow_extra_fields] [@@deriving of_json]

type choice_message_json = {
  role : string option; [@json.default None]
  content : string option; [@json.default None]
  reasoning : string option; [@json.default None]
  reasoning_details : reasoning_detail_json list; [@json.default []]
  tool_calls : tool_call_json list; [@json.default []]
  annotations : annotation_json list; [@json.default []]
  images : image_json list; [@json.default []]
}
[@@json.allow_extra_fields] [@@deriving of_json]

type choice_json = {
  index : int; [@json.default 0]
  message : choice_message_json;
  finish_reason : string option; [@json.default None]
}
[@@json.allow_extra_fields] [@@deriving of_json]

type openrouter_response_json = {
  id : string option; [@json.default None]
  model : string option; [@json.default None]
  provider : string option; [@json.default None]
  choices : choice_json list; [@json.default []]
  usage : Convert_usage.openrouter_usage option; [@json.default None]
}
[@@json.allow_extra_fields] [@@deriving of_json]

type _ Ai_provider.Provider_options.key +=
  | Openrouter_provider : string Ai_provider.Provider_options.key
  | Openrouter_reasoning_details : reasoning_detail_json list Ai_provider.Provider_options.key

let map_finish_reason = function
  | Some "stop" -> Ai_provider.Finish_reason.Stop
  | Some "length" -> Ai_provider.Finish_reason.Length
  | Some "content_filter" -> Ai_provider.Finish_reason.Content_filter
  | Some "tool_calls" -> Ai_provider.Finish_reason.Tool_calls
  | Some "function_call" -> Ai_provider.Finish_reason.Tool_calls
  | Some other -> Ai_provider.Finish_reason.Other other
  | None -> Ai_provider.Finish_reason.Unknown

let default_usage = { Ai_provider.Usage.input_tokens = 0; output_tokens = 0; total_tokens = Some 0 }

let convert_reasoning_detail (d : reasoning_detail_json) =
  match d.type_ with
  | "reasoning.text" ->
    (match d.text with
    | Some text when String.length text > 0 ->
      Some
        (Ai_provider.Content.Reasoning
           { text; signature = d.signature; provider_options = Ai_provider.Provider_options.empty })
    | Some _ | None -> None)
  | "reasoning.encrypted" ->
    (* Encrypted reasoning is an opaque blob for multi-turn roundtripping.
       It is preserved in response-level providerMetadata and does not
       produce a visible reasoning content part (matches upstream). *)
    None
  | "reasoning.summary" ->
    (match d.summary with
    | Some summary when String.length summary > 0 ->
      Some
        (Ai_provider.Content.Reasoning
           { text = summary; signature = None; provider_options = Ai_provider.Provider_options.empty })
    | Some _ | None -> None)
  | _ -> None

let convert_annotation ~index (a : annotation_json) =
  match a.type_ with
  | "url_citation" ->
    (match a.url with
    | Some url ->
      let id = Printf.sprintf "source-%d" index in
      Some
        (Ai_provider.Content.Source
           { source_type = "url"; id; url; title = a.title; provider_options = Ai_provider.Provider_options.empty })
    | None -> None)
  | _ -> None

let convert_image (img : image_json) =
  let url = img.url in
  if String.starts_with ~prefix:"data:" url then (
    match String.index_opt url ',' with
    | Some comma_pos ->
      let header = String.sub url 5 (comma_pos - 5) in
      let media_type =
        match String.index_opt header ';' with
        | Some semi -> String.sub header 0 semi
        | None -> header
      in
      let b64_data = String.sub url (comma_pos + 1) (String.length url - comma_pos - 1) in
      Some (Ai_provider.Content.File { data = Bytes.of_string b64_data; media_type })
    | None -> None)
  else Some (Ai_provider.Content.File { data = Bytes.of_string url; media_type = "image/png" })

let has_encrypted_reasoning details =
  List.exists
    (fun (d : reasoning_detail_json) ->
      match d.type_, d.data with
      | "reasoning.encrypted", Some data when String.length data > 0 -> true
      | _ -> false)
    details

let override_finish_reason ~has_tool_calls ~has_encrypted (finish_reason : Ai_provider.Finish_reason.t) =
  match has_tool_calls, has_encrypted, finish_reason with
  | true, true, Stop -> Ai_provider.Finish_reason.Tool_calls
  | true, _, Other _ -> Ai_provider.Finish_reason.Tool_calls
  | _ -> finish_reason

let parse_response json =
  let resp = openrouter_response_json_of_json json in
  let choice = List.nth_opt resp.choices 0 in
  let content =
    match choice with
    | None -> []
    | Some { message; _ } ->
      let reasoning_content =
        match message.reasoning_details with
        | _ :: _ as details -> List.filter_map convert_reasoning_detail details
        | [] ->
        match message.reasoning with
        | Some text when String.length text > 0 ->
          [
            Ai_provider.Content.Reasoning
              { text; signature = None; provider_options = Ai_provider.Provider_options.empty };
          ]
        | Some _ | None -> []
      in
      let text_content =
        match message.content with
        | Some text when String.length text > 0 -> [ Ai_provider.Content.Text { text } ]
        | Some _ | None -> []
      in
      let tool_content =
        List.map
          (fun (tc : tool_call_json) ->
            Ai_provider.Content.Tool_call
              {
                tool_call_type = "function";
                tool_call_id = tc.id;
                tool_name = tc.function_.name;
                args = tc.function_.arguments;
              })
          message.tool_calls
      in
      let source_content =
        message.annotations |> List.mapi (fun i a -> convert_annotation ~index:i a) |> List.filter_map Fun.id
      in
      let image_content = List.filter_map convert_image message.images in
      reasoning_content @ text_content @ tool_content @ source_content @ image_content
  in
  let has_tool_calls =
    match choice with
    | Some { message; _ } -> message.tool_calls <> []
    | None -> false
  in
  let has_encrypted =
    match choice with
    | Some { message; _ } -> has_encrypted_reasoning message.reasoning_details
    | None -> false
  in
  let finish_reason =
    match choice with
    | Some { finish_reason; _ } -> map_finish_reason finish_reason
    | None -> Ai_provider.Finish_reason.Unknown
  in
  let finish_reason = override_finish_reason ~has_tool_calls ~has_encrypted finish_reason in
  let usage, provider_metadata =
    match resp.usage with
    | Some u ->
      let metadata = Convert_usage.to_metadata u in
      let provider_metadata =
        Ai_provider.Provider_options.set Convert_usage.Openrouter_usage metadata Ai_provider.Provider_options.empty
      in
      Convert_usage.to_usage u, provider_metadata
    | None -> default_usage, Ai_provider.Provider_options.empty
  in
  let provider_metadata =
    match resp.provider with
    | Some p -> Ai_provider.Provider_options.set Openrouter_provider p provider_metadata
    | None -> provider_metadata
  in
  (* Preserve raw reasoning_details for multi-turn roundtripping (encrypted reasoning) *)
  let provider_metadata =
    match choice with
    | Some { message = { reasoning_details = _ :: _ as details; _ }; _ } ->
      Ai_provider.Provider_options.set Openrouter_reasoning_details details provider_metadata
    | Some _ | None -> provider_metadata
  in
  {
    Ai_provider.Generate_result.content;
    finish_reason;
    usage;
    warnings = [];
    provider_metadata;
    request = { body = json };
    response = { id = resp.id; model = resp.model; headers = []; body = json };
  }