providers.gleam

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5import gleam/dynamic/decode
  6import gleam/http/request
  7import gleam/httpc
  8import gleam/int
  9import gleam/json
 10import gleam/list
 11import gleam/option.{type Option, None, Some}
 12import gleam/result
 13
 14const providers_url = "https://catwalk.charm.sh/v2/providers"
 15
 16pub type Provider {
 17  Provider(
 18    id: String,
 19    provider_type: String,
 20    api_key_env: Option(String),
 21    api_endpoint: Option(String),
 22    models: List(Model),
 23  )
 24}
 25
 26pub type Model {
 27  Model(
 28    id: String,
 29    can_reason: Bool,
 30    reasoning_levels: List(String),
 31    default_reasoning_effort: Option(String),
 32  )
 33}
 34
 35pub type ValidationError {
 36  FetchError(String)
 37  ProviderNotFound(String)
 38  ModelNotFound(provider: String, model: String)
 39}
 40
 41pub fn validate(
 42  provider_id: String,
 43  model_id: String,
 44) -> Result(Nil, ValidationError) {
 45  use providers <- result.try(fetch_providers())
 46  use provider <- result.try(find_provider(providers, provider_id))
 47  find_model(provider, model_id)
 48}
 49
 50pub fn get_provider(provider_id: String) -> Result(Provider, ValidationError) {
 51  use providers <- result.try(fetch_providers())
 52  find_provider(providers, provider_id)
 53}
 54
 55fn fetch_providers() -> Result(List(Provider), ValidationError) {
 56  let assert Ok(req) = request.to(providers_url)
 57
 58  case httpc.send(req) {
 59    Ok(resp) if resp.status == 200 -> parse_providers(resp.body)
 60    Ok(resp) -> Error(FetchError("HTTP " <> int.to_string(resp.status)))
 61    Error(_) -> Error(FetchError("Network error"))
 62  }
 63}
 64
 65fn parse_providers(body: String) -> Result(List(Provider), ValidationError) {
 66  let model_decoder = {
 67    use id <- decode.field("id", decode.string)
 68    use can_reason <- decode.field("can_reason", decode.bool)
 69    use reasoning_levels <- decode.optional_field(
 70      "reasoning_levels",
 71      [],
 72      decode.list(decode.string),
 73    )
 74    use default_reasoning_effort <- decode.optional_field(
 75      "default_reasoning_effort",
 76      None,
 77      decode.string |> decode.map(Some),
 78    )
 79    decode.success(Model(
 80      id:,
 81      can_reason:,
 82      reasoning_levels:,
 83      default_reasoning_effort:,
 84    ))
 85  }
 86
 87  let provider_decoder = {
 88    use id <- decode.field("id", decode.string)
 89    use provider_type <- decode.field("type", decode.string)
 90    use api_key_env <- decode.optional_field(
 91      "api_key",
 92      None,
 93      decode.string |> decode.map(Some),
 94    )
 95    use api_endpoint <- decode.optional_field(
 96      "api_endpoint",
 97      None,
 98      decode.string |> decode.map(Some),
 99    )
100    use models <- decode.field("models", decode.list(model_decoder))
101    decode.success(Provider(
102      id:,
103      provider_type:,
104      api_key_env:,
105      api_endpoint:,
106      models:,
107    ))
108  }
109
110  json.parse(body, decode.list(provider_decoder))
111  |> result.map_error(fn(_) { FetchError("Invalid JSON") })
112}
113
114fn find_provider(
115  providers: List(Provider),
116  provider_id: String,
117) -> Result(Provider, ValidationError) {
118  providers
119  |> list.find(fn(p) { p.id == provider_id })
120  |> result.map_error(fn(_) { ProviderNotFound(provider_id) })
121}
122
123fn find_model(
124  provider: Provider,
125  model_id: String,
126) -> Result(Nil, ValidationError) {
127  provider.models
128  |> list.find(fn(m) { m.id == model_id })
129  |> result.map(fn(_) { Nil })
130  |> result.map_error(fn(_) { ModelNotFound(provider.id, model_id) })
131}
132
133/// Get a model by ID from a provider
134pub fn get_model(
135  provider: Provider,
136  model_id: String,
137) -> Result(Model, ValidationError) {
138  provider.models
139  |> list.find(fn(m) { m.id == model_id })
140  |> result.map_error(fn(_) { ModelNotFound(provider.id, model_id) })
141}
142
143/// Resolve an environment variable reference like "$OPENAI_API_KEY" to just "OPENAI_API_KEY"
144pub fn resolve_env_var_name(value: String) -> Option(String) {
145  case value {
146    "$" <> rest -> Some(rest)
147    _ -> None
148  }
149}