1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5import config
6import gleam/float
7import gleam/int
8import gleam/option.{type Option, None, Some}
9import gleam/result
10import starlet
11import starlet/anthropic
12import starlet/gemini
13import starlet/ollama
14import starlet/openai
15import starlet/openai_compat
16import starlet/openai_compat/thinking
17
18pub fn send_openai(
19 api_key: String,
20 base_url: Option(String),
21 model: String,
22 system_prompt: String,
23 user_prompt: String,
24 reasoning: Option(config.ReasoningEffort),
25) -> Result(String, String) {
26 let client = case base_url {
27 Some(url) -> openai.new_with_base_url(api_key, url)
28 None -> openai.new(api_key)
29 }
30
31 let chat =
32 starlet.chat(client, model)
33 |> starlet.system(system_prompt)
34 |> starlet.user(user_prompt)
35
36 let chat = case reasoning {
37 Some(config.ReasoningLow) ->
38 openai.with_reasoning(chat, openai.ReasoningLow)
39 Some(config.ReasoningMedium) ->
40 openai.with_reasoning(chat, openai.ReasoningMedium)
41 Some(config.ReasoningHigh) ->
42 openai.with_reasoning(chat, openai.ReasoningHigh)
43 None -> chat
44 }
45
46 chat
47 |> starlet.send()
48 |> result.map(fn(resp) { starlet.text(resp.1) })
49 |> result.map_error(format_starlet_error)
50}
51
52pub fn send_anthropic(
53 api_key: String,
54 base_url: Option(String),
55 model: String,
56 system_prompt: String,
57 user_prompt: String,
58 reasoning: Option(config.ReasoningEffort),
59) -> Result(String, String) {
60 let client = case base_url {
61 Some(url) -> anthropic.new_with_base_url(api_key, url)
62 None -> anthropic.new(api_key)
63 }
64
65 let chat =
66 starlet.chat(client, model)
67 |> starlet.system(system_prompt)
68 |> starlet.user(user_prompt)
69
70 let chat = case reasoning {
71 Some(effort) -> {
72 let #(budget, max_tokens) = reasoning_budget(effort)
73 case anthropic.with_thinking(chat, budget) {
74 Ok(c) -> c |> starlet.max_tokens(max_tokens)
75 Error(_) -> chat
76 }
77 }
78 None -> chat
79 }
80
81 chat
82 |> starlet.send()
83 |> result.map(fn(resp) { starlet.text(resp.1) })
84 |> result.map_error(format_starlet_error)
85}
86
87/// Calculate reasoning budget using OpenRouter's formula:
88/// budget_tokens = max(min(max_tokens * effort_ratio, 128000), 1024)
89/// Returns (budget, max_tokens) where max_tokens > budget
90pub fn reasoning_budget(effort: config.ReasoningEffort) -> #(Int, Int) {
91 let base_max = 64_000
92 let ratio = case effort {
93 config.ReasoningLow -> 0.2
94 config.ReasoningMedium -> 0.5
95 config.ReasoningHigh -> 0.8
96 }
97 let budget =
98 int.max(
99 int.min(float.truncate(int.to_float(base_max) *. ratio), 128_000),
100 1024,
101 )
102 let max_tokens = budget + 16_384
103 #(budget, max_tokens)
104}
105
106pub fn send_gemini(
107 api_key: String,
108 model: String,
109 system_prompt: String,
110 user_prompt: String,
111 reasoning: Option(config.ReasoningEffort),
112) -> Result(String, String) {
113 let client = gemini.new(api_key)
114
115 let chat =
116 starlet.chat(client, model)
117 |> starlet.system(system_prompt)
118 |> starlet.user(user_prompt)
119
120 let chat = case reasoning {
121 Some(effort) -> {
122 let #(budget, _) = reasoning_budget(effort)
123 case gemini.with_thinking(chat, gemini.ThinkingFixed(budget)) {
124 Ok(c) -> c
125 Error(_) -> chat
126 }
127 }
128 None -> chat
129 }
130
131 chat
132 |> starlet.send()
133 |> result.map(fn(resp) { starlet.text(resp.1) })
134 |> result.map_error(format_starlet_error)
135}
136
137/// Map config Dialect to starlet's thinking.Dialect.
138fn map_dialect(dialect: config.Dialect) -> thinking.Dialect {
139 case dialect {
140 config.DialectGeneric -> thinking.Generic
141 config.DialectTogether -> thinking.Together
142 config.DialectGroq -> thinking.Groq
143 config.DialectCerebras -> thinking.Cerebras
144 config.DialectLlamaCpp -> thinking.LlamaCpp
145 config.DialectTags -> thinking.Tags
146 }
147}
148
149pub fn send_openai_compat(
150 api_key: String,
151 base_url: String,
152 model: String,
153 system_prompt: String,
154 user_prompt: String,
155 reasoning: Option(config.ReasoningEffort),
156 dialect: config.Dialect,
157) -> Result(String, String) {
158 let client = openai_compat.new(base_url, api_key, map_dialect(dialect))
159
160 let chat =
161 starlet.chat(client, model)
162 |> starlet.system(system_prompt)
163 |> starlet.user(user_prompt)
164
165 let chat = case reasoning {
166 Some(config.ReasoningLow) ->
167 openai_compat.with_reasoning(chat, thinking.EffortLow)
168 Some(config.ReasoningMedium) ->
169 openai_compat.with_reasoning(chat, thinking.EffortMedium)
170 Some(config.ReasoningHigh) ->
171 openai_compat.with_reasoning(chat, thinking.EffortHigh)
172 None -> chat
173 }
174
175 chat
176 |> starlet.send()
177 |> result.map(fn(resp) { starlet.text(resp.1) })
178 |> result.map_error(format_starlet_error)
179}
180
181pub fn send_ollama(
182 endpoint: String,
183 model: String,
184 system_prompt: String,
185 user_prompt: String,
186 reasoning: Option(config.ReasoningEffort),
187) -> Result(String, String) {
188 case endpoint {
189 "" -> Error("Ollama requires --endpoint (e.g. http://localhost:11434)")
190 base_url -> {
191 let client = ollama.new(base_url)
192
193 let chat =
194 starlet.chat(client, model)
195 |> starlet.system(system_prompt)
196 |> starlet.user(user_prompt)
197
198 let chat = case reasoning {
199 Some(config.ReasoningLow) ->
200 ollama.with_thinking(chat, ollama.ThinkingLow)
201 Some(config.ReasoningMedium) ->
202 ollama.with_thinking(chat, ollama.ThinkingMedium)
203 Some(config.ReasoningHigh) ->
204 ollama.with_thinking(chat, ollama.ThinkingHigh)
205 None -> chat
206 }
207
208 chat
209 |> starlet.send()
210 |> result.map(fn(resp) { starlet.text(resp.1) })
211 |> result.map_error(format_starlet_error)
212 }
213 }
214}
215
216pub fn format_starlet_error(err: starlet.StarletError) -> String {
217 case err {
218 starlet.Transport(msg) -> "Network error: " <> msg
219 starlet.Http(status, body) ->
220 "HTTP " <> int.to_string(status) <> ": " <> body
221 starlet.Decode(msg) -> "Parse error: " <> msg
222 starlet.Provider(name, msg, _) -> name <> " error: " <> msg
223 starlet.Tool(_error) -> "Tool error"
224 starlet.RateLimited(retry_after) -> {
225 case retry_after {
226 Some(secs) -> "Rate limited, retry after " <> int.to_string(secs) <> "s"
227 None -> "Rate limited"
228 }
229 }
230 }
231}