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}