openai_compat.gleam

 1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
 2//
 3// SPDX-License-Identifier: AGPL-3.0-or-later
 4
 5import config.{type ReasoningEffort}
 6import gleam/dynamic/decode
 7import gleam/http
 8import gleam/http/request
 9import gleam/httpc
10import gleam/json
11import gleam/list
12import gleam/option.{type Option, None, Some}
13
14pub fn send(
15  endpoint: String,
16  api_key: String,
17  model: String,
18  system_prompt: String,
19  user_prompt: String,
20  reasoning: Option(ReasoningEffort),
21) -> Result(String, String) {
22  let messages = build_messages(system_prompt, user_prompt)
23  let base_fields = [
24    #("model", json.string(model)),
25    #("messages", json.array(messages, fn(m) { m })),
26  ]
27  let fields = case reasoning {
28    Some(effort) ->
29      list.append(base_fields, [
30        #("reasoning_effort", json.string(config.reasoning_to_string(effort))),
31      ])
32    None -> base_fields
33  }
34  let body = json.object(fields) |> json.to_string
35
36  let url = endpoint <> "/chat/completions"
37
38  case request.to(url) {
39    Error(_) -> Error("Invalid endpoint URL: " <> endpoint)
40    Ok(req) -> {
41      let req =
42        req
43        |> request.set_method(http.Post)
44        |> request.set_header("content-type", "application/json")
45        |> request.set_header("authorization", "Bearer " <> api_key)
46        |> request.set_body(body)
47
48      case httpc.send(req) {
49        Error(_) -> Error("Network error")
50        Ok(resp) if resp.status >= 200 && resp.status < 300 ->
51          parse_response(resp.body)
52        Ok(resp) -> Error("HTTP " <> resp.body)
53      }
54    }
55  }
56}
57
58fn build_messages(system_prompt: String, user_prompt: String) -> List(json.Json) {
59  case system_prompt {
60    "" -> [user_message(user_prompt)]
61    sys -> [system_message(sys), user_message(user_prompt)]
62  }
63}
64
65fn system_message(content: String) -> json.Json {
66  json.object([
67    #("role", json.string("system")),
68    #("content", json.string(content)),
69  ])
70}
71
72fn user_message(content: String) -> json.Json {
73  json.object([
74    #("role", json.string("user")),
75    #("content", json.string(content)),
76  ])
77}
78
79fn parse_response(body: String) -> Result(String, String) {
80  let choice_decoder = decode.at(["message", "content"], decode.string)
81
82  let response_decoder = {
83    use choices <- decode.field("choices", decode.list(choice_decoder))
84    decode.success(choices)
85  }
86
87  case json.parse(body, response_decoder) {
88    Error(_) -> Error("Failed to parse response")
89    Ok(choices) ->
90      case list.first(choices) {
91        Ok(content) -> Ok(content)
92        Error(_) -> Error("No response content")
93      }
94  }
95}