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}