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(id:, can_reason:, reasoning_levels:, default_reasoning_effort:))
 80  }
 81
 82  let provider_decoder = {
 83    use id <- decode.field("id", decode.string)
 84    use provider_type <- decode.field("type", decode.string)
 85    use api_key_env <- decode.optional_field(
 86      "api_key",
 87      None,
 88      decode.string |> decode.map(Some),
 89    )
 90    use api_endpoint <- decode.optional_field(
 91      "api_endpoint",
 92      None,
 93      decode.string |> decode.map(Some),
 94    )
 95    use models <- decode.field("models", decode.list(model_decoder))
 96    decode.success(Provider(
 97      id:,
 98      provider_type:,
 99      api_key_env:,
100      api_endpoint:,
101      models:,
102    ))
103  }
104
105  json.parse(body, decode.list(provider_decoder))
106  |> result.map_error(fn(_) { FetchError("Invalid JSON") })
107}
108
109fn find_provider(
110  providers: List(Provider),
111  provider_id: String,
112) -> Result(Provider, ValidationError) {
113  providers
114  |> list.find(fn(p) { p.id == provider_id })
115  |> result.map_error(fn(_) { ProviderNotFound(provider_id) })
116}
117
118fn find_model(
119  provider: Provider,
120  model_id: String,
121) -> Result(Nil, ValidationError) {
122  provider.models
123  |> list.find(fn(m) { m.id == model_id })
124  |> result.map(fn(_) { Nil })
125  |> result.map_error(fn(_) { ModelNotFound(provider.id, model_id) })
126}
127
128/// Get a model by ID from a provider
129pub fn get_model(provider: Provider, model_id: String) -> Result(Model, ValidationError) {
130  provider.models
131  |> list.find(fn(m) { m.id == model_id })
132  |> result.map_error(fn(_) { ModelNotFound(provider.id, model_id) })
133}
134
135/// Resolve an environment variable reference like "$OPENAI_API_KEY" to just "OPENAI_API_KEY"
136pub fn resolve_env_var_name(value: String) -> Option(String) {
137  case value {
138    "$" <> rest -> Some(rest)
139    _ -> None
140  }
141}