package ocaml-ai-sdk
sectionYPositions = computeSectionYPositions($el), 10)"
x-init="setTimeout(() => sectionYPositions = computeSectionYPositions($el), 10)"
>
OCaml AI SDK - Provider abstraction for AI models
Install
dune-project
Dependency
Authors
Maintainers
Sources
ocaml-ai-sdk-0.6.1.tbz
sha256=cb3b82428abcda76c02ec92f8103a4db28edf3f42795794a37135e9a3c40af96
sha512=11be8889ee25bee67b2be00553c42a4692bc73e5ce23985e8bb87f8a07e9d8f556b5a63b219fe5fe30366c8b0d4edae494afa80ccfbfcb112f1fa1e458de55bf
doc/src/ocaml-ai-sdk.ai_core/server_handler.ml.html
Source file server_handler.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 348open Melange_json.Primitives let empty_opts = Ai_provider.Provider_options.empty (** Roles in v6 UIMessage format. *) type role = | System | User | Assistant let role_of_string = function | "system" -> Some System | "user" -> Some User | "assistant" -> Some Assistant | _ -> None (** Part types in v6 UIMessage format. *) type part_type = | Text | File | Reasoning | Step_start | Source | Tool_invocation of string (** the full type string, e.g. "tool-weather" or "dynamic-tool" *) let part_type_of_string s = match s with | "text" -> Text | "file" -> File | "reasoning" -> Reasoning | "step-start" -> Step_start | "source" -> Source | s when String.starts_with ~prefix:"tool-" s -> Tool_invocation s | "dynamic-tool" -> Tool_invocation s | _ -> Source (* unknown types are skipped like source *) (** Tool invocation states in v6 UIMessage format. *) type tool_state = | Input_streaming | Input_available | Output_available | Output_error | Output_denied | Approval_requested | Approval_responded | Unknown_state let tool_state_of_string = function | "input-streaming" -> Input_streaming | "input-available" -> Input_available | "output-available" -> Output_available | "output-error" -> Output_error | "output-denied" -> Output_denied | "approval-requested" -> Approval_requested | "approval-responded" -> Approval_responded | _ -> Unknown_state (** A parsed v6 UIMessage part. Derived from JSON via melange-json PPX. *) type parsed_approval = { id : string option; [@json.option] approved : bool option; [@json.option] } [@@json.allow_extra_fields] [@@deriving of_json] type parsed_part = { type_ : string; [@json.key "type"] text : string option; [@json.option] media_type : string option; [@json.key "mediaType"] [@json.option] url : string option; [@json.option] data : string option; [@json.option] filename : string option; [@json.option] tool_call_id : string option; [@json.key "toolCallId"] [@json.option] tool_name : string option; [@json.key "toolName"] [@json.option] state : string option; [@json.option] input : Melange_json.t option; [@json.option] output : Melange_json.t option; [@json.option] error_text : string option; [@json.key "errorText"] [@json.option] approved : bool option; [@json.option] approval : parsed_approval option; [@json.option] provider_metadata : Melange_json.t option; [@json.key "providerMetadata"] [@json.option] result_provider_metadata : Melange_json.t option; [@json.key "resultProviderMetadata"] [@json.option] call_provider_metadata : Melange_json.t option; [@json.key "callProviderMetadata"] [@json.option] } [@@json.allow_extra_fields] [@@deriving of_json] type chat_message = { role : string; parts : parsed_part list; [@json.default []] } [@@json.allow_extra_fields] [@@deriving of_json] type chat_request = { messages : chat_message list } [@@json.allow_extra_fields] [@@deriving of_json] (** Extract tool name from [toolName] field, falling back to the type prefix (e.g. ["tool-get_weather"] -> ["get_weather"]). *) let resolve_tool_name (p : parsed_part) = match p.tool_name with | Some name -> Some name | None -> let t = p.type_ in let prefix = "tool-" in let plen = String.length prefix in (match String.length t > plen && String.sub t 0 plen = prefix with | true -> Some (String.sub t plen (String.length t - plen)) | false -> None) (** Extract approved status: check top-level [approved] first, then [approval.approved]. *) let resolve_approved (p : parsed_part) = match p.approved with | Some v -> Some v | None -> match p.approval with | Some a -> a.approved | None -> None (** Resolve provider metadata: prefer [resultProviderMetadata], fall back to [callProviderMetadata]. Matches upstream [convert-to-model-messages.ts]. *) let resolve_provider_metadata (p : parsed_part) : Yojson.Basic.t option = match p.result_provider_metadata with | Some _ as m -> m | None -> p.call_provider_metadata (** Build [provider_options] from resolved provider metadata. *) let provider_options_of_part (p : parsed_part) = match p.provider_metadata, resolve_provider_metadata p with | Some json, _ -> Ai_provider.Provider_options.of_provider_metadata json | None, Some json -> Ai_provider.Provider_options.of_provider_metadata json | None, None -> empty_opts let parse_file_data (p : parsed_part) = match p.media_type with | Some media_type -> let data = match p.url, p.data with | Some url, _ -> Some (Ai_provider.Prompt.Url url) | _, Some d -> Some (Ai_provider.Prompt.Base64 d) | None, None -> None in Option.map (fun data -> data, media_type, p.filename) data | None -> None let parse_user_part (p : parsed_part) : Ai_provider.Prompt.user_part option = match part_type_of_string p.type_ with | Text -> Option.map (fun text : Ai_provider.Prompt.user_part -> Text { text; provider_options = empty_opts }) p.text | File -> Option.map (fun (data, media_type, filename) : Ai_provider.Prompt.user_part -> File { data; media_type; filename; provider_options = empty_opts }) (parse_file_data p) | Reasoning | Step_start | Source | Tool_invocation _ -> None let parse_assistant_part (p : parsed_part) : Ai_provider.Prompt.assistant_part option = match part_type_of_string p.type_ with | Text -> Option.map (fun text -> Ai_provider.Prompt.Text { text; provider_options = empty_opts }) p.text | Reasoning -> Option.map (fun text -> Ai_provider.Prompt.Reasoning { text; provider_options = provider_options_of_part p }) p.text | File -> Option.map (fun (data, media_type, filename) -> Ai_provider.Prompt.File { data; media_type; filename; provider_options = empty_opts }) (parse_file_data p) | Step_start | Source | Tool_invocation _ -> None let parse_tool_call (p : parsed_part) : Ai_provider.Prompt.assistant_part option = match part_type_of_string p.type_ with | Tool_invocation _ -> (match p.tool_call_id, resolve_tool_name p, p.input with | Some id, Some name, Some args -> let provider_options = match p.call_provider_metadata with | Some json -> Ai_provider.Provider_options.of_provider_metadata json | None -> empty_opts in Some (Ai_provider.Prompt.Tool_call { id; name; args; provider_options }) | _ -> None) | _ -> None let parse_tool_result (p : parsed_part) : Ai_provider.Prompt.tool_result option = match part_type_of_string p.type_ with | Tool_invocation _ -> let state = Option.map tool_state_of_string p.state in let tool_name = resolve_tool_name p in let provider_options = provider_options_of_part p in (match state, p.tool_call_id, tool_name with | Some Output_available, Some tool_call_id, Some tool_name -> let result = Option.value ~default:`Null p.output in Some { Ai_provider.Prompt.tool_call_id; tool_name; result; is_error = false; content = []; provider_options } | Some Output_error, Some tool_call_id, Some tool_name -> let result = match p.error_text with | Some e -> `String e | None -> `String "Tool execution failed" in Some { Ai_provider.Prompt.tool_call_id; tool_name; result; is_error = true; content = []; provider_options } | Some Output_denied, Some tool_call_id, Some tool_name -> Some { Ai_provider.Prompt.tool_call_id; tool_name; result = `String "Tool execution denied"; is_error = true; content = []; provider_options; } | Some Approval_responded, _, _ -> (* Both approved and denied are handled by the initial tool execution step in stream_text/generate_text — no tool result in the parsed messages *) None | _ -> None) | _ -> None let parse_messages_from_body body_json = try let { messages } = chat_request_of_json body_json in List.concat_map (fun (msg : chat_message) -> match role_of_string msg.role with | Some System -> let text = msg.parts |> List.filter_map (fun (p : parsed_part) -> match part_type_of_string p.type_ with | Text -> p.text | _ -> None) |> String.concat "" in [ Ai_provider.Prompt.System { content = text; provider_options = Ai_provider.Provider_options.empty } ] | Some User -> let content = List.filter_map parse_user_part msg.parts in (match content with | [] -> [] | content -> [ Ai_provider.Prompt.User { content } ]) | Some Assistant -> (* Split parts into steps at step-start boundaries *) let steps = ref [] in let current_step = ref [] in List.iter (fun p -> match part_type_of_string p.type_ with | Step_start -> (match !current_step with | [] -> () | parts -> steps := List.rev parts :: !steps); current_step := [] | _ -> current_step := p :: !current_step) msg.parts; (match !current_step with | [] -> () | parts -> steps := List.rev parts :: !steps); (* For each step, emit Assistant + Tool messages *) List.concat_map (fun step_parts -> let assistant_parts = List.filter_map (fun p -> match part_type_of_string p.type_ with | Tool_invocation _ -> parse_tool_call p | _ -> parse_assistant_part p) step_parts in let tool_results = List.filter_map parse_tool_result step_parts in let msgs = match assistant_parts with | [] -> [] | content -> [ Ai_provider.Prompt.Assistant { content } ] in match tool_results with | [] -> msgs | content -> msgs @ [ Ai_provider.Prompt.Tool { content } ]) (List.rev !steps) | None -> []) messages with Melange_json.Of_json_error _ -> [] let collect_pending_tool_approvals body_json = try let { messages } = chat_request_of_json body_json in List.concat_map (fun (msg : chat_message) -> List.filter_map (fun (p : parsed_part) -> match part_type_of_string p.type_, Option.map tool_state_of_string p.state with | Tool_invocation _, Some Approval_responded -> (match p.tool_call_id, resolve_tool_name p, resolve_approved p with | Some tool_call_id, Some tool_name, Some approved -> let args = match p.input with | Some json -> (json : Melange_json.t :> Yojson.Basic.t) | None -> `Null in Some { Generate_text_result.tool_call_id; tool_name; args; approved } | _ -> None) | _ -> None) msg.parts) messages with Melange_json.Of_json_error _ -> [] let cors_headers = [ "access-control-allow-origin", "*"; "access-control-allow-methods", "POST, OPTIONS"; "access-control-allow-headers", "content-type"; "access-control-expose-headers", "x-vercel-ai-ui-message-stream"; ] let make_sse_response ?(status = `OK) ?(extra_headers = []) sse_stream = let headers = Ui_message_stream.headers @ extra_headers |> Cohttp.Header.of_list in let body = Cohttp_lwt.Body.of_stream sse_stream in let response = Cohttp.Response.make ~status ~headers () in Lwt.return (response, body) let handle_cors_preflight _conn _req _body = let headers = Cohttp.Header.of_list cors_headers in let response = Cohttp.Response.make ~status:`No_content ~headers () in Lwt.return (response, Cohttp_lwt.Body.empty) let handle_chat ~model ?tools ?max_steps ?max_retries ?max_retry_delay_ms ?stop_when ?system ?system_provider_options ?output ?send_reasoning ?max_output_tokens ?(cors = true) ?provider_options ?transform ?telemetry _conn _req body = let%lwt body_str = Cohttp_lwt.Body.to_string body in let body_json = try Ok (Yojson.Basic.from_string body_str) with Yojson.Json_error msg -> Printf.eprintf "[ai_core] handle_chat: invalid JSON in request body: %s\n%!" msg; Error msg in match body_json with | Error msg -> let status = `Bad_request in let headers = (if cors then cors_headers else []) |> Cohttp.Header.of_list in let body = Cohttp_lwt.Body.of_string (Printf.sprintf {|{"error":"Invalid JSON: %s"}|} msg) in Lwt.return (Cohttp.Response.make ~status ~headers (), body) | Ok body_json -> let messages = parse_messages_from_body body_json in let messages = match system with | Some s -> let provider_options = Option.value ~default:Ai_provider.Provider_options.empty system_provider_options in Ai_provider.Prompt.System { content = s; provider_options } :: messages | None -> messages in let pending_tool_approvals = collect_pending_tool_approvals body_json in let result = Stream_text.stream_text ~model ~messages ?tools ?max_steps ?max_retries ?max_retry_delay_ms ?stop_when ?output ?provider_options ?transform ?telemetry ~pending_tool_approvals () in let sse_stream = Stream_text_result.to_ui_message_sse_stream ?send_reasoning result in let extra_headers = if cors then cors_headers else [] in make_sse_response ~extra_headers sse_stream
sectionYPositions = computeSectionYPositions($el), 10)"
x-init="setTimeout(() => sectionYPositions = computeSectionYPositions($el), 10)"
>