Source file session.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
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
type t = {
  mutable session_model : string;
  mutable defaults : Options.t;
  mutable session_messages : Message.t list;
  mutable session_tools : Tool.t list;
  mutable session_last_result : Result.generation option;
}

let field = Chatoyant_runtime.Json.field
let string value = Chatoyant_runtime.Json.String value
let bool value = Chatoyant_runtime.Json.Bool value

let string_field name json =
  Option.bind (field name json) Chatoyant_runtime.Json.as_string

let add_opt name value fields =
  match value with None -> fields | Some value -> (name, value) :: fields

let add_non_empty name values fields =
  match values with
  | [] -> fields
  | _ -> (name, Chatoyant_runtime.Json.Array (List.map string values)) :: fields

let messages_of_json json =
  match field "messages" json with
  | Some (Chatoyant_runtime.Json.Array values) ->
      let rec loop acc = function
        | [] -> Ok (List.rev acc)
        | value :: rest -> (
            match Message.of_json value with
            | Ok message -> loop (message :: acc) rest
            | Error _ as err -> err)
      in
      loop [] values
  | _ -> Ok []

let options_to_json (options : Options.t) =
  let int value = Chatoyant_runtime.Json.Float (Float.of_int value) in
  let float value = Chatoyant_runtime.Json.Float value in
  [
    ("retries", int options.retries);
    ("max_tool_iterations", int options.max_tool_iterations);
  ]
  |> add_opt "provider"
       (Option.map
          (fun provider ->
            string (Chatoyant_provider.Provider.string_of_id provider))
          options.provider)
  |> add_opt "model" (Option.map string options.model)
  |> add_opt "timeout_ms" (Option.map int options.timeout_ms)
  |> add_opt "local_base_url" (Option.map string options.local_base_url)
  |> add_opt "local_api_key" (Option.map string options.local_api_key)
  |> add_opt "local_timeout_ms" (Option.map int options.local_timeout_ms)
  |> add_opt "temperature" (Option.map float options.temperature)
  |> add_opt "max_tokens" (Option.map int options.max_tokens)
  |> add_opt "top_p" (Option.map float options.top_p)
  |> add_non_empty "stop" options.stop
  |> add_opt "frequency_penalty" (Option.map float options.frequency_penalty)
  |> add_opt "presence_penalty" (Option.map float options.presence_penalty)
  |> add_opt "web_search" (Option.map bool options.web_search)
  |> add_opt "thinking_budget" (Option.map int options.thinking_budget)
  |> add_opt "tool_timeout_ms" (Option.map int options.tool_timeout_ms)
  |> add_opt "extra" options.extra
  |> List.rev
  |> fun fields -> Chatoyant_runtime.Json.Object fields

let int_field name json =
  Option.bind (field name json) Chatoyant_runtime.Json.as_int

let float_field name json =
  Option.bind (field name json) Chatoyant_runtime.Json.as_float

let bool_field name json =
  Option.bind (field name json) Chatoyant_runtime.Json.as_bool

let string_list_field name json =
  match field name json with
  | Some (Chatoyant_runtime.Json.Array values) ->
      List.filter_map Chatoyant_runtime.Json.as_string values
  | _ -> []

let options_of_json json =
  let provider =
    Option.bind
      (string_field "provider" json)
      Chatoyant_provider.Provider.id_of_string
  in
  {
    Options.default with
    provider;
    model = string_field "model" json;
    timeout_ms = int_field "timeout_ms" json;
    local_base_url = string_field "local_base_url" json;
    local_api_key = string_field "local_api_key" json;
    local_timeout_ms = int_field "local_timeout_ms" json;
    retries =
      Option.value (int_field "retries" json) ~default:Options.default.retries;
    temperature = float_field "temperature" json;
    max_tokens = int_field "max_tokens" json;
    top_p = float_field "top_p" json;
    stop = string_list_field "stop" json;
    frequency_penalty = float_field "frequency_penalty" json;
    presence_penalty = float_field "presence_penalty" json;
    web_search = bool_field "web_search" json;
    thinking_budget = int_field "thinking_budget" json;
    max_tool_iterations =
      Option.value
        (int_field "max_tool_iterations" json)
        ~default:Options.default.max_tool_iterations;
    tool_timeout_ms = int_field "tool_timeout_ms" json;
    extra = field "extra" json;
  }

module Make
    (Provider : Chatoyant_provider.Provider.CHAT)
    (Clock : Chatoyant_runtime.Effect.CLOCK) =
struct
  module Generator = Generator.Make (Provider) (Clock)

  let create ?(model = "gpt-4o") ?(defaults = Options.default) () =
    {
      session_model = model;
      defaults;
      session_messages = [];
      session_tools = [];
      session_last_result = None;
    }

  let model session = session.session_model

  let set_model model session =
    session.session_model <- model;
    session

  let messages session = session.session_messages
  let tools session = session.session_tools
  let last_result session = session.session_last_result

  let add_message message session =
    session.session_messages <- session.session_messages @ [ message ];
    session

  let add_messages messages session =
    session.session_messages <- session.session_messages @ messages;
    session

  let system content session = add_message (Message.system content) session
  let user content session = add_message (Message.user content) session

  let assistant content session =
    add_message (Message.assistant content) session

  let clear_messages session =
    session.session_messages <- [];
    session

  let add_tool tool session =
    session.session_tools <- session.session_tools @ [ tool ];
    session

  let add_tools tools session =
    session.session_tools <- session.session_tools @ tools;
    session

  let clear_tools session =
    session.session_tools <- [];
    session

  let immutable_chat_with messages session =
    let chat =
      Chat.create ~model:session.session_model ~defaults:session.defaults ()
    in
    let chat =
      List.fold_left
        (fun chat message -> Chat.add_message message chat)
        chat messages
    in
    List.fold_left
      (fun chat tool -> Chat.add_tool tool chat)
      chat session.session_tools

  let merged_options session options = Options.merge session.defaults options

  let add_usage left right =
    {
      Chatoyant_tokens.Cost.input_tokens =
        left.Chatoyant_tokens.Cost.input_tokens
        + right.Chatoyant_tokens.Cost.input_tokens;
      output_tokens = left.output_tokens + right.output_tokens;
      reasoning_tokens = left.reasoning_tokens + right.reasoning_tokens;
      cached_tokens = left.cached_tokens + right.cached_tokens;
      cache_write_tokens = left.cache_write_tokens + right.cache_write_tokens;
      total_tokens = left.total_tokens + right.total_tokens;
      actual_cost_usd =
        (match (left.actual_cost_usd, right.actual_cost_usd) with
        | Some left, Some right -> Some (left +. right)
        | Some value, None | None, Some value -> Some value
        | None, None -> None);
    }
    |> Chatoyant_tokens.Cost.normalize_total

  let combine_usage_source left right =
    match (left, right) with
    | Chatoyant_tokens.Cost.Unknown, _ | _, Chatoyant_tokens.Cost.Unknown ->
        Chatoyant_tokens.Cost.Unknown
    | Chatoyant_tokens.Cost.Estimated, _ | _, Chatoyant_tokens.Cost.Estimated ->
        Chatoyant_tokens.Cost.Estimated
    | ( Chatoyant_tokens.Cost.Provider_reported,
        Chatoyant_tokens.Cost.Provider_reported ) ->
        Chatoyant_tokens.Cost.Provider_reported
    | Chatoyant_tokens.Cost.Unmetered, Chatoyant_tokens.Cost.Unmetered ->
        Chatoyant_tokens.Cost.Unmetered
    | Chatoyant_tokens.Cost.Provider_reported, Chatoyant_tokens.Cost.Unmetered
    | Chatoyant_tokens.Cost.Unmetered, Chatoyant_tokens.Cost.Provider_reported
      ->
        Chatoyant_tokens.Cost.Provider_reported

  let add_actual_cost left right =
    match (left, right) with
    | Some left, Some right -> Some (left +. right)
    | Some value, None | None, Some value -> Some value
    | None, None -> None

  let join_reasoning left right =
    match (left, right) with
    | "", value | value, "" -> value
    | left, right -> left ^ "\n" ^ right

  let combine_results left right =
    let usage = add_usage left.Result.usage right.Result.usage in
    let latency_ms = left.timing.latency_ms + right.timing.latency_ms in
    {
      right with
      Result.reasoning_content =
        join_reasoning left.reasoning_content right.reasoning_content;
      usage;
      usage_source = combine_usage_source left.usage_source right.usage_source;
      timing =
        {
          Result.latency_ms;
          time_to_first_token_ms =
            (match left.timing.time_to_first_token_ms with
            | Some _ as value -> value
            | None -> right.timing.time_to_first_token_ms);
        };
      token_speed = Result.token_speed ~latency_ms usage;
      cost =
        {
          Result.estimated_usd =
            left.cost.estimated_usd +. right.cost.estimated_usd;
          actual_usd =
            add_actual_cost left.cost.actual_usd right.cost.actual_usd;
        };
      tool_calls = left.tool_calls @ right.tool_calls;
      cached = left.cached || right.cached;
      iterations = left.iterations + right.iterations;
    }

  let provider_call_to_tool_call (call : Chatoyant_provider.Provider.tool_call)
      : Tool.call =
    { Tool.id = call.id; name = call.name; arguments = call.arguments }

  let unknown_tool_result (call : Chatoyant_provider.Provider.tool_call) :
      Tool.result =
    {
      Tool.id = call.id;
      ok = false;
      value = None;
      error = Some ("Unknown tool: " ^ call.name);
    }

  let execute_tool_call context tools
      (call : Chatoyant_provider.Provider.tool_call) =
    match List.find_opt (fun tool -> Tool.name tool = call.name) tools with
    | None -> unknown_tool_result call
    | Some tool ->
        Tool.execute_call context (provider_call_to_tool_call call) tool

  let message_of_tool_result (result : Tool.result) =
    let content =
      Tool.result_to_json result |> Chatoyant_runtime.Json.to_string
    in
    Message.tool ~is_error:(not result.ok) ~tool_call_id:result.id content

  let assistant_message_of_result result =
    if result.Result.tool_calls = [] then Message.assistant result.content
    else
      Message.assistant_with_tool_calls ~content:result.content
        result.tool_calls

  let generate_with_result ?(options = Options.default) session =
    let options = merged_options session options in
    let model = Option.value options.model ~default:session.session_model in
    let context = { Tool.model; provider = Provider.id } in
    let max_tool_iterations = 8 in
    let rec loop iteration messages accumulated =
      if iteration > max_tool_iterations then
        Error
          (Chatoyant_provider.Provider.Runtime_error
             ("tool iteration limit exceeded after "
             ^ string_of_int max_tool_iterations
             ^ " turns"))
      else
        match
          Generator.generate ~options (immutable_chat_with messages session)
        with
        | Error _ as err -> err
        | Ok result ->
            let accumulated =
              match accumulated with
              | None -> result
              | Some previous -> combine_results previous result
            in
            let messages = messages @ [ assistant_message_of_result result ] in
            if result.tool_calls = [] then (
              session.session_messages <- messages;
              session.session_last_result <- Some accumulated;
              Ok accumulated)
            else
              let tool_messages =
                result.tool_calls
                |> List.map (execute_tool_call context session.session_tools)
                |> List.map message_of_tool_result
              in
              loop (iteration + 1) (messages @ tool_messages) (Some accumulated)
    in
    loop 1 session.session_messages None

  let generate ?options session =
    match generate_with_result ?options session with
    | Error _ as err -> err
    | Ok result -> Ok result.content

  let stream_accumulate ?(options = Options.default) frames session =
    let options = merged_options session options in
    let started_ms = Clock.now_ms () in
    let state =
      List.fold_left
        (fun state frame ->
          let state =
            if Stream.content state = "" && Stream.reasoning_content state = ""
            then Stream.note_first_token ~now_ms:(Clock.now_ms ()) state
            else state
          in
          Stream.apply state frame)
        Stream.empty frames
    in
    let finished_ms = Clock.now_ms () in
    let model = Option.value options.model ~default:session.session_model in
    let result =
      Stream.to_generation ~provider:Provider.id ~model ~started_ms ~finished_ms
        state
    in
    session.session_last_result <- Some result;
    session.session_messages <-
      session.session_messages @ [ Message.assistant result.content ];
    result

  let to_json session =
    [
      ("model", string session.session_model);
      ( "messages",
        Chatoyant_runtime.Json.Array
          (List.map Message.to_json session.session_messages) );
      ( "config",
        Chatoyant_runtime.Json.Object
          [ ("defaults", options_to_json session.defaults) ] );
    ]
    |> add_opt "lastResult"
         (Option.map Result.generation_to_json session.session_last_result)
    |> List.rev
    |> fun fields -> Chatoyant_runtime.Json.Object fields

  let stringify ?(pretty = false) session =
    let json = to_json session |> Chatoyant_runtime.Json.to_string in
    if pretty then json else json

  let load_json json session =
    match messages_of_json json with
    | Error _ as err -> err
    | Ok messages ->
        session.session_model <-
          Option.value
            (string_field "model" json)
            ~default:session.session_model;
        session.session_messages <- messages;
        session.defaults <-
          (match field "config" json with
          | Some config -> (
              match field "defaults" config with
              | Some defaults -> options_of_json defaults
              | None -> session.defaults)
          | None -> session.defaults);
        session.session_last_result <- None;
        Ok session

  let of_json json =
    let session = create () in
    load_json json session

  let clone session =
    {
      session_model = session.session_model;
      defaults = session.defaults;
      session_messages = session.session_messages;
      session_tools = session.session_tools;
      session_last_result = session.session_last_result;
    }

  let fork session =
    {
      session_model = session.session_model;
      defaults = session.defaults;
      session_messages = session.session_messages;
      session_tools = session.session_tools;
      session_last_result = None;
    }
end