1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "strings"
11 "time"
12)
13
14// Conservative metadata for models that a list endpoint describes by ID
15// only. Explicit models.json entries override these values.
16const (
17 customDiscoveryContextWindow = 32768
18 customDiscoveryMaxOutput = 4096
19 customDiscoveryTimeout = 3 * time.Second
20 customDiscoveryMaxBody = 8 << 20
21)
22
23// customModelsURL derives the list endpoint from a custom provider base URL.
24// A base that already ends in an API version segment such as "/v1" gets
25// "/models" appended; a bare host gets "/v1/models".
26func customModelsURL(baseURL string) (string, error) {
27 u, err := url.Parse(strings.TrimSpace(baseURL))
28 if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
29 return "", fmt.Errorf("custom provider base URL must be an http or https URL")
30 }
31 u.Path = strings.TrimRight(u.Path, "/")
32 u.RawPath = ""
33 u.RawQuery = ""
34 u.Fragment = ""
35 u.User = nil
36 base := u.String()
37 if versionSegmentSuffix.MatchString(base) {
38 return base + "/models", nil
39 }
40 return base + "/v1/models", nil
41}
42
43// DiscoverCustomProvider lists models from a user-defined provider's
44// OpenAI-style /models endpoint using the process default HTTP transport, so
45// configured proxy settings apply. Pass an empty apiKey for keyless endpoints:
46// no Authorization header is sent.
47func DiscoverCustomProvider(ctx context.Context, providerID, baseURL, apiKey string) ([]Model, error) {
48 return DiscoverCustomProviderWithClient(ctx, nil, providerID, baseURL, apiKey)
49}
50
51// DiscoverCustomProviderWithClient is DiscoverCustomProvider with a caller
52// supplied HTTP client, for example one from NewHTTPClient when the user scoped
53// insecure TLS to custom endpoints. A nil client uses the default transport.
54// The request is bounded by a short timeout and an 8 MiB response cap
55// regardless of the client's own settings.
56//
57// Only IDs are trusted from the response; context and output limits use
58// conservative defaults because self-hosted gateways rarely report them
59// consistently. Entries whose ID mentions embeddings are skipped. Discovered
60// models carry the provider's base URL so they route like models.json entries.
61func DiscoverCustomProviderWithClient(ctx context.Context, client *http.Client, providerID, baseURL, apiKey string) ([]Model, error) {
62 providerID = strings.TrimSpace(providerID)
63 if providerID == "" {
64 return nil, fmt.Errorf("custom provider discovery requires a provider id")
65 }
66 endpoint, err := customModelsURL(baseURL)
67 if err != nil {
68 return nil, err
69 }
70 ctx, cancel := context.WithTimeout(ctx, customDiscoveryTimeout)
71 defer cancel()
72 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
73 if err != nil {
74 return nil, err
75 }
76 req.Header.Set("Accept", "application/json")
77 if apiKey != "" {
78 req.Header.Set("Authorization", "Bearer "+apiKey)
79 }
80 if client == nil {
81 client = &http.Client{}
82 }
83 resp, err := client.Do(req)
84 if err != nil {
85 if ctx.Err() != nil {
86 return nil, ctx.Err()
87 }
88 // The transport error can echo the URL; keep the message generic.
89 return nil, fmt.Errorf("%s discovery request failed", providerID)
90 }
91 defer resp.Body.Close()
92 if resp.StatusCode != http.StatusOK {
93 return nil, fmt.Errorf("%s discovery HTTP %d", providerID, resp.StatusCode)
94 }
95 body, err := io.ReadAll(io.LimitReader(resp.Body, customDiscoveryMaxBody+1))
96 if err != nil {
97 return nil, fmt.Errorf("%s discovery response could not be read", providerID)
98 }
99 if len(body) > customDiscoveryMaxBody {
100 return nil, fmt.Errorf("%s discovery response too large", providerID)
101 }
102 var page struct {
103 Data []struct {
104 ID string `json:"id"`
105 } `json:"data"`
106 }
107 if err := json.Unmarshal(body, &page); err != nil {
108 return nil, fmt.Errorf("%s discovery returned invalid model data", providerID)
109 }
110 if page.Data == nil {
111 return nil, fmt.Errorf("%s discovery response is missing data", providerID)
112 }
113 models := make([]Model, 0, len(page.Data))
114 seen := map[string]bool{}
115 for _, d := range page.Data {
116 id := strings.TrimSpace(d.ID)
117 if id == "" || seen[id] || strings.Contains(strings.ToLower(id), "embed") {
118 continue
119 }
120 seen[id] = true
121 models = append(models, Model{
122 Provider: providerID,
123 ID: id,
124 DisplayName: id,
125 ContextWindow: customDiscoveryContextWindow,
126 MaxOutput: customDiscoveryMaxOutput,
127 BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
128 Source: "live",
129 })
130 }
131 return models, nil
132}