refactor(openai-compat): use starlet native module

Amolith created

Replace custom openai_compat HTTP client with starlet's native module.

Changes:
- Remove src/openai_compat.gleam
- Add send_openai_compat() to clients.gleam
- Update garble.gleam dispatch
- Point starlet to GitHub fork (openai-completions branch)

The Generic dialect is currently hardcoded.

Change summary

gleam.toml              |  2 
manifest.toml           | 18 +++----
src/clients.gleam       | 32 ++++++++++++++
src/garble.gleam        |  3 
src/openai_compat.gleam | 95 -------------------------------------------
5 files changed, 41 insertions(+), 109 deletions(-)

Detailed changes

gleam.toml 🔗

@@ -28,7 +28,7 @@ glint = ">= 1.2.1 and < 2.0.0"
 gleam_httpc = ">= 5.0.0 and < 6.0.0"
 gleam_http = ">= 4.3.0 and < 5.0.0"
 gleam_json = ">= 3.1.0 and < 4.0.0"
-starlet = ">= 1.0.1 and < 2.0.0"
+starlet = { git = "https://github.com/amolith/starlet", ref = "openai-completions" }
 envoy = ">= 1.1.0 and < 2.0.0"
 tom = ">= 2.0.0 and < 3.0.0"
 simplifile = ">= 2.3.2 and < 3.0.0"

manifest.toml 🔗

@@ -1,7 +1,3 @@
-# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-#
-# SPDX-License-Identifier: CC0-1.0
-
 # This file was generated by Gleam
 # You typically do not need to edit this file
 
@@ -9,15 +5,15 @@ packages = [
   { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
   { name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" },
   { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
-  { name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" },
-  { name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" },
+  { name = "gleam_community_ansi", version = "1.4.4", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "1B3AEA6074AB34D5F0674744F36DDC7290303A03295507E2DEC61EDD6F5777FE" },
+  { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" },
   { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
   { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
   { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" },
   { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
   { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
-  { name = "gleam_stdlib", version = "0.68.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "EEC7E7A18B8A53B7A28B7F0A2198CE53BAFF05D45479E4806C387EDF26DA842D" },
-  { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" },
+  { name = "gleam_stdlib", version = "0.68.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F7FAEBD8EF260664E86A46C8DBA23508D1D11BB3BCC6EE1B89B3BC3E5C83FF1E" },
+  { name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" },
   { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
   { name = "gleescript", version = "1.5.2", build_tools = ["gleam"], requirements = ["argv", "filepath", "gleam_erlang", "gleam_stdlib", "simplifile", "snag", "tom"], otp_app = "gleescript", source = "hex", outer_checksum = "27AC58481742ED29D9B37C506F78958A8AD798750A79ED08C8F8AFBA8F23563B" },
   { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
@@ -26,9 +22,9 @@ packages = [
   { name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" },
   { name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" },
   { name = "snag", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "274F41D6C3ECF99F7686FDCE54183333E41D2C1CA5A3A673F9A8B2C7A4401077" },
-  { name = "starlet", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_httpc", "gleam_json", "gleam_stdlib", "jscheam"], otp_app = "starlet", source = "hex", outer_checksum = "4E288CB970EFF9BE11EB476A0449A7A9BFAA993BE25CAA7B7E8A0A5B3A76AE5C" },
+  { name = "starlet", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_httpc", "gleam_json", "gleam_stdlib", "jscheam"], source = "git", repo = "https://github.com/amolith/starlet", commit = "581594be3c51e5134abe7e0ac462b3f48bd0eb6e" },
   { name = "stdin", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "stdin", source = "hex", outer_checksum = "437084939CE094E06D32D07EB19B74473D08895AB817C03550CEDFDBF0DFDC19" },
-  { name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" },
+  { name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" },
 ]
 
 [requirements]
@@ -45,6 +41,6 @@ gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
 glint = { version = ">= 1.2.1 and < 2.0.0" }
 shellout = { version = ">= 1.7.0 and < 2.0.0" }
 simplifile = { version = ">= 2.3.2 and < 3.0.0" }
-starlet = { version = ">= 1.0.1 and < 2.0.0" }
+starlet = { git = "https://github.com/amolith/starlet", ref = "openai-completions" }
 stdin = { version = ">= 2.0.2 and < 3.0.0" }
 tom = { version = ">= 2.0.0 and < 3.0.0" }

src/clients.gleam 🔗

@@ -12,6 +12,8 @@ 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,
@@ -128,6 +130,36 @@ pub fn send_gemini(
   |> result.map_error(format_starlet_error)
 }
 
+pub fn send_openai_compat(
+  api_key: String,
+  base_url: String,
+  model: String,
+  system_prompt: String,
+  user_prompt: String,
+  reasoning: Option(config.ReasoningEffort),
+) -> Result(String, String) {
+  // Default to Generic dialect - this is the most widely compatible option
+  // Provider-specific dialects can be configured via garble's config file
+  let client = openai_compat.new(base_url, api_key, thinking.Generic)
+
+  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,

src/garble.gleam 🔗

@@ -12,7 +12,6 @@ import gleam/result
 import gleam/string
 import gleam/yielder
 import glint
-import openai_compat
 import prompts
 import providers.{type Provider}
 import stdin
@@ -160,7 +159,7 @@ fn send_request(
     "openai-compat" -> {
       case provider.api_endpoint {
         Some(endpoint) ->
-          openai_compat.send(endpoint, key, cfg.model, system, user_prompt, reasoning)
+          clients.send_openai_compat(key, endpoint, cfg.model, system, user_prompt, reasoning)
         None -> Error("No endpoint configured for " <> provider.id)
       }
     }

src/openai_compat.gleam 🔗

@@ -1,95 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-import config.{type ReasoningEffort}
-import gleam/dynamic/decode
-import gleam/http
-import gleam/http/request
-import gleam/httpc
-import gleam/json
-import gleam/list
-import gleam/option.{type Option, None, Some}
-
-pub fn send(
-  endpoint: String,
-  api_key: String,
-  model: String,
-  system_prompt: String,
-  user_prompt: String,
-  reasoning: Option(ReasoningEffort),
-) -> Result(String, String) {
-  let messages = build_messages(system_prompt, user_prompt)
-  let base_fields = [
-    #("model", json.string(model)),
-    #("messages", json.array(messages, fn(m) { m })),
-  ]
-  let fields = case reasoning {
-    Some(effort) ->
-      list.append(base_fields, [
-        #("reasoning_effort", json.string(config.reasoning_to_string(effort))),
-      ])
-    None -> base_fields
-  }
-  let body = json.object(fields) |> json.to_string
-
-  let url = endpoint <> "/chat/completions"
-
-  case request.to(url) {
-    Error(_) -> Error("Invalid endpoint URL: " <> endpoint)
-    Ok(req) -> {
-      let req =
-        req
-        |> request.set_method(http.Post)
-        |> request.set_header("content-type", "application/json")
-        |> request.set_header("authorization", "Bearer " <> api_key)
-        |> request.set_body(body)
-
-      case httpc.send(req) {
-        Error(_) -> Error("Network error")
-        Ok(resp) if resp.status >= 200 && resp.status < 300 ->
-          parse_response(resp.body)
-        Ok(resp) -> Error("HTTP " <> resp.body)
-      }
-    }
-  }
-}
-
-fn build_messages(system_prompt: String, user_prompt: String) -> List(json.Json) {
-  case system_prompt {
-    "" -> [user_message(user_prompt)]
-    sys -> [system_message(sys), user_message(user_prompt)]
-  }
-}
-
-fn system_message(content: String) -> json.Json {
-  json.object([
-    #("role", json.string("system")),
-    #("content", json.string(content)),
-  ])
-}
-
-fn user_message(content: String) -> json.Json {
-  json.object([
-    #("role", json.string("user")),
-    #("content", json.string(content)),
-  ])
-}
-
-fn parse_response(body: String) -> Result(String, String) {
-  let choice_decoder = decode.at(["message", "content"], decode.string)
-
-  let response_decoder = {
-    use choices <- decode.field("choices", decode.list(choice_decoder))
-    decode.success(choices)
-  }
-
-  case json.parse(body, response_decoder) {
-    Error(_) -> Error("Failed to parse response")
-    Ok(choices) ->
-      case list.first(choices) {
-        Ok(content) -> Ok(content)
-        Error(_) -> Error("No response content")
-      }
-  }
-}