clients.gleam

  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}