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}