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}