// SPDX-FileCopyrightText: Amolith // // SPDX-License-Identifier: AGPL-3.0-or-later import config import gleam/float import gleam/int import gleam/option.{type Option, None, Some} import gleam/result import starlet import starlet/anthropic import starlet/gemini import starlet/ollama import starlet/openai import starlet/openai_compat import starlet/openai_compat/thinking pub fn send_openai( api_key: String, base_url: Option(String), model: String, system_prompt: String, user_prompt: String, reasoning: Option(config.ReasoningEffort), ) -> Result(String, String) { let client = case base_url { Some(url) -> openai.new_with_base_url(api_key, url) None -> openai.new(api_key) } let chat = starlet.chat(client, model) |> starlet.system(system_prompt) |> starlet.user(user_prompt) let chat = case reasoning { Some(config.ReasoningLow) -> openai.with_reasoning(chat, openai.ReasoningLow) Some(config.ReasoningMedium) -> openai.with_reasoning(chat, openai.ReasoningMedium) Some(config.ReasoningHigh) -> openai.with_reasoning(chat, openai.ReasoningHigh) None -> chat } chat |> starlet.send() |> result.map(fn(resp) { starlet.text(resp.1) }) |> result.map_error(format_starlet_error) } pub fn send_anthropic( api_key: String, base_url: Option(String), model: String, system_prompt: String, user_prompt: String, reasoning: Option(config.ReasoningEffort), ) -> Result(String, String) { let client = case base_url { Some(url) -> anthropic.new_with_base_url(api_key, url) None -> anthropic.new(api_key) } let chat = starlet.chat(client, model) |> starlet.system(system_prompt) |> starlet.user(user_prompt) let chat = case reasoning { Some(effort) -> { let #(budget, max_tokens) = reasoning_budget(effort) case anthropic.with_thinking(chat, budget) { Ok(c) -> c |> starlet.max_tokens(max_tokens) Error(_) -> chat } } None -> chat } chat |> starlet.send() |> result.map(fn(resp) { starlet.text(resp.1) }) |> result.map_error(format_starlet_error) } /// Calculate reasoning budget using OpenRouter's formula: /// budget_tokens = max(min(max_tokens * effort_ratio, 128000), 1024) /// Returns (budget, max_tokens) where max_tokens > budget pub fn reasoning_budget(effort: config.ReasoningEffort) -> #(Int, Int) { let base_max = 64_000 let ratio = case effort { config.ReasoningLow -> 0.2 config.ReasoningMedium -> 0.5 config.ReasoningHigh -> 0.8 } let budget = int.max( int.min(float.truncate(int.to_float(base_max) *. ratio), 128_000), 1024, ) let max_tokens = budget + 16_384 #(budget, max_tokens) } pub fn send_gemini( api_key: String, model: String, system_prompt: String, user_prompt: String, reasoning: Option(config.ReasoningEffort), ) -> Result(String, String) { let client = gemini.new(api_key) let chat = starlet.chat(client, model) |> starlet.system(system_prompt) |> starlet.user(user_prompt) let chat = case reasoning { Some(effort) -> { let #(budget, _) = reasoning_budget(effort) case gemini.with_thinking(chat, gemini.ThinkingFixed(budget)) { Ok(c) -> c Error(_) -> chat } } None -> chat } chat |> starlet.send() |> result.map(fn(resp) { starlet.text(resp.1) }) |> result.map_error(format_starlet_error) } /// Map config Dialect to starlet's thinking.Dialect. fn map_dialect(dialect: config.Dialect) -> thinking.Dialect { case dialect { config.DialectGeneric -> thinking.Generic config.DialectTogether -> thinking.Together config.DialectGroq -> thinking.Groq config.DialectCerebras -> thinking.Cerebras config.DialectLlamaCpp -> thinking.LlamaCpp config.DialectTags -> thinking.Tags } } pub fn send_openai_compat( api_key: String, base_url: String, model: String, system_prompt: String, user_prompt: String, reasoning: Option(config.ReasoningEffort), dialect: config.Dialect, ) -> Result(String, String) { let client = openai_compat.new(base_url, api_key, map_dialect(dialect)) let chat = starlet.chat(client, model) |> starlet.system(system_prompt) |> starlet.user(user_prompt) let chat = case reasoning { Some(config.ReasoningLow) -> openai_compat.with_reasoning(chat, thinking.EffortLow) Some(config.ReasoningMedium) -> openai_compat.with_reasoning(chat, thinking.EffortMedium) Some(config.ReasoningHigh) -> openai_compat.with_reasoning(chat, thinking.EffortHigh) None -> chat } chat |> starlet.send() |> result.map(fn(resp) { starlet.text(resp.1) }) |> result.map_error(format_starlet_error) } pub fn send_ollama( endpoint: String, model: String, system_prompt: String, user_prompt: String, reasoning: Option(config.ReasoningEffort), ) -> Result(String, String) { case endpoint { "" -> Error("Ollama requires --endpoint (e.g. http://localhost:11434)") base_url -> { let client = ollama.new(base_url) let chat = starlet.chat(client, model) |> starlet.system(system_prompt) |> starlet.user(user_prompt) let chat = case reasoning { Some(config.ReasoningLow) -> ollama.with_thinking(chat, ollama.ThinkingLow) Some(config.ReasoningMedium) -> ollama.with_thinking(chat, ollama.ThinkingMedium) Some(config.ReasoningHigh) -> ollama.with_thinking(chat, ollama.ThinkingHigh) None -> chat } chat |> starlet.send() |> result.map(fn(resp) { starlet.text(resp.1) }) |> result.map_error(format_starlet_error) } } } pub fn format_starlet_error(err: starlet.StarletError) -> String { case err { starlet.Transport(msg) -> "Network error: " <> msg starlet.Http(status, body) -> "HTTP " <> int.to_string(status) <> ": " <> body starlet.Decode(msg) -> "Parse error: " <> msg starlet.Provider(name, msg, _) -> name <> " error: " <> msg starlet.Tool(_error) -> "Tool error" starlet.RateLimited(retry_after) -> { case retry_after { Some(secs) -> "Rate limited, retry after " <> int.to_string(secs) <> "s" None -> "Rate limited" } } } }