config.gleam

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5import envoy
  6import filepath
  7import gleam/dict.{type Dict}
  8
  9import gleam/result
 10import gleam/string
 11import simplifile
 12import tom.{type Toml}
 13
 14/// Reasoning effort level for thinking models.
 15pub type ReasoningEffort {
 16  ReasoningLow
 17  ReasoningMedium
 18  ReasoningHigh
 19}
 20
 21/// User's reasoning preference: not specified, explicitly disabled, or a specific level.
 22pub type ReasoningSetting {
 23  ReasoningNotSet
 24  ReasoningDisabled
 25  ReasoningEnabled(ReasoningEffort)
 26}
 27
 28/// Parse a reasoning effort string into a ReasoningSetting.
 29pub fn parse_reasoning(value: String) -> ReasoningSetting {
 30  case string.lowercase(value) {
 31    "" -> ReasoningNotSet
 32    "none" | "off" | "disabled" -> ReasoningDisabled
 33    "low" -> ReasoningEnabled(ReasoningLow)
 34    "medium" -> ReasoningEnabled(ReasoningMedium)
 35    "high" -> ReasoningEnabled(ReasoningHigh)
 36    _ -> ReasoningNotSet
 37  }
 38}
 39
 40/// Convert ReasoningEffort to the API string representation.
 41pub fn reasoning_to_string(effort: ReasoningEffort) -> String {
 42  case effort {
 43    ReasoningLow -> "low"
 44    ReasoningMedium -> "medium"
 45    ReasoningHigh -> "high"
 46  }
 47}
 48
 49/// OpenAI-compatible provider dialect for reasoning support.
 50/// Each dialect determines how reasoning requests are encoded and responses decoded.
 51pub type Dialect {
 52  DialectGeneric
 53  DialectTogether
 54  DialectGroq
 55  DialectCerebras
 56  DialectLlamaCpp
 57  DialectTags
 58}
 59
 60/// Parse a dialect string into a Dialect type.
 61pub fn parse_dialect(value: String) -> Dialect {
 62  case string.lowercase(value) {
 63    "together" -> DialectTogether
 64    "groq" -> DialectGroq
 65    "cerebras" -> DialectCerebras
 66    "llamacpp" | "llama.cpp" -> DialectLlamaCpp
 67    "tags" -> DialectTags
 68    _ -> DialectGeneric
 69  }
 70}
 71
 72/// Convert Dialect to the API string representation.
 73pub fn dialect_to_string(dialect: Dialect) -> String {
 74  case dialect {
 75    DialectGeneric -> "generic"
 76    DialectTogether -> "together"
 77    DialectGroq -> "groq"
 78    DialectCerebras -> "cerebras"
 79    DialectLlamaCpp -> "llamacpp"
 80    DialectTags -> "tags"
 81  }
 82}
 83
 84pub type Config {
 85  Config(
 86    provider: String,
 87    model: String,
 88    api_key: String,
 89    api_key_cmd: String,
 90    directions: String,
 91    reasoning: ReasoningSetting,
 92    endpoint: String,
 93    dialect: Dialect,
 94  )
 95}
 96
 97pub fn default() -> Config {
 98  Config(
 99    provider: "",
100    model: "",
101    api_key: "",
102    api_key_cmd: "",
103    directions: "",
104    reasoning: ReasoningNotSet,
105    endpoint: "",
106    dialect: DialectGeneric,
107  )
108}
109
110/// Load config from XDG_CONFIG_HOME/garble/config.toml or ~/.config/garble/config.toml
111pub fn load() -> Config {
112  case config_path() {
113    Error(_) -> default()
114    Ok(path) ->
115      case simplifile.read(path) {
116        Error(_) -> default()
117        Ok(content) ->
118          case tom.parse(content) {
119            Error(_) -> default()
120            Ok(parsed) -> parse_config(parsed)
121          }
122      }
123  }
124}
125
126fn config_path() -> Result(String, Nil) {
127  let config_dir = case envoy.get("XDG_CONFIG_HOME") {
128    Ok(xdg) -> xdg
129    Error(_) ->
130      case envoy.get("HOME") {
131        Ok(home) -> filepath.join(home, ".config")
132        Error(_) -> ""
133      }
134  }
135
136  case config_dir {
137    "" -> Error(Nil)
138    dir -> {
139      let path = filepath.join(dir, "garble/config.toml")
140      case simplifile.is_file(path) {
141        Ok(True) -> Ok(path)
142        _ -> Error(Nil)
143      }
144    }
145  }
146}
147
148fn parse_config(parsed: Dict(String, Toml)) -> Config {
149  Config(
150    provider: get_string(parsed, "provider"),
151    model: get_string(parsed, "model"),
152    api_key: get_string(parsed, "api_key"),
153    api_key_cmd: get_string(parsed, "api_key_cmd"),
154    directions: get_string(parsed, "directions"),
155    reasoning: parse_reasoning(get_string(parsed, "reasoning")),
156    endpoint: get_string(parsed, "endpoint"),
157    dialect: parse_dialect(get_string(parsed, "dialect")),
158  )
159}
160
161fn get_string(parsed: Dict(String, Toml), key: String) -> String {
162  tom.get_string(parsed, [key])
163  |> result.unwrap("")
164}
165
166/// Merge CLI flags over config values. CLI takes precedence when non-empty.
167pub fn merge(
168  cfg: Config,
169  cli_provider cli_provider: String,
170  cli_model cli_model: String,
171  cli_directions cli_directions: String,
172  cli_reasoning cli_reasoning: String,
173  cli_endpoint cli_endpoint: String,
174) -> Config {
175  Config(
176    provider: prefer_nonempty(cli_provider, cfg.provider),
177    model: prefer_nonempty(cli_model, cfg.model),
178    api_key: cfg.api_key,
179    api_key_cmd: cfg.api_key_cmd,
180    directions: prefer_nonempty(cli_directions, cfg.directions),
181    reasoning: prefer_reasoning(cli_reasoning, cfg.reasoning),
182    endpoint: prefer_nonempty(cli_endpoint, cfg.endpoint),
183    dialect: cfg.dialect,
184  )
185}
186
187fn prefer_reasoning(cli: String, fallback: ReasoningSetting) -> ReasoningSetting {
188  case parse_reasoning(cli) {
189    ReasoningNotSet -> fallback
190    setting -> setting
191  }
192}
193
194fn prefer_nonempty(cli: String, fallback: String) -> String {
195  case cli {
196    "" -> fallback
197    val -> val
198  }
199}