返回 DeepSeek-Reasonix
fetch.go
根目录 / internal / config / fetch.go
1 // fetch.go — model auto-discovery via the OpenAI-compatible GET /models API.
2 package config
3
4 import (
5 "context"
6 "fmt"
7 "slices"
8 "strings"
9
10 "reasonix/internal/netclient"
11 "reasonix/internal/provider"
12 "reasonix/internal/provider/openai"
13 )
14
15 var knownModelFetchCompatSuffixes = []string{
16 "/api/claudecode",
17 "/api/anthropic",
18 "/apps/anthropic",
19 "/api/coding",
20 "/claudecode",
21 "/anthropic",
22 "/step_plan",
23 "/coding",
24 "/claude",
25 }
26
27 // FetchModels queries the provider's OpenAI-compatible GET /models endpoint and
28 // returns the available model IDs, sorted alphabetically.
29 func (e *ProviderEntry) FetchModels(ctx context.Context) ([]string, error) {
30 return e.FetchModelsWithProxy(ctx, netclient.ProxySpec{})
31 }
32
33 // FetchModelCatalog discovers model IDs together with adapter-owned input
34 // modality metadata. FetchModelsWithProxy remains the compatibility wrapper
35 // used by older callers.
36 func (e *ProviderEntry) FetchModelCatalog(ctx context.Context) ([]provider.ModelInfo, error) {
37 return e.FetchModelCatalogWithProxy(ctx, netclient.ProxySpec{})
38 }
39
40 // FetchModelsWithProxy is FetchModels routed through the same network policy as
41 // chat requests. Passing cfg.NetworkProxySpec() makes model discovery fail at
42 // setup time when the proxy path is broken, instead of succeeding here and
43 // stalling the first chat turn later (#9560).
44 func (e *ProviderEntry) FetchModelsWithProxy(ctx context.Context, proxy netclient.ProxySpec) ([]string, error) {
45 catalog, err := e.FetchModelCatalogWithProxy(ctx, proxy)
46 if err != nil {
47 return nil, err
48 }
49 models := make([]string, 0, len(catalog))
50 for _, model := range catalog {
51 models = append(models, model.ID)
52 }
53 return models, nil
54 }
55
56 // FetchModelCatalogWithProxy is FetchModelsWithProxy with model capability
57 // metadata preserved through the config/provider boundary.
58 func (e *ProviderEntry) FetchModelCatalogWithProxy(ctx context.Context, proxy netclient.ProxySpec) ([]provider.ModelInfo, error) {
59 if e.BaseURL == "" {
60 return nil, fmt.Errorf("fetch models: provider %q has no base_url", e.Name)
61 }
62 key := e.APIKey()
63 if e.RequiresAPIKey() && key == "" {
64 return nil, fmt.Errorf("fetch models: provider %q has no API key (set %s in .env)", e.Name, e.APIKeyEnv)
65 }
66 candidates, err := BuildModelFetchURLs(e.BaseURL, e.ModelsURL)
67 if err != nil {
68 return nil, err
69 }
70 var lastErr error
71 var firstHardErr error
72 authMode := modelFetchAuthMode(e)
73 for _, u := range candidates {
74 models, err := openai.FetchModelCatalogWithOptions(ctx, u, key, openai.FetchModelsOptions{
75 Headers: e.Headers,
76 AuthMode: authMode,
77 Proxy: proxy,
78 })
79 if err == nil {
80 allowed := provider.FilterOpenCodeGoRequestModels(e.Kind, e.BaseURL, e.RequestURL, e.ChatURL, modelInfoIDs(models))
81 keep := make(map[string]bool, len(allowed))
82 for _, id := range allowed {
83 keep[id] = true
84 }
85 filtered := make([]provider.ModelInfo, 0, len(models))
86 for _, model := range models {
87 if keep[model.ID] {
88 filtered = append(filtered, model)
89 }
90 }
91 return filtered, nil
92 }
93 lastErr = err
94 if !openai.IsModelFetchEndpointMiss(err) && firstHardErr == nil {
95 firstHardErr = err
96 }
97 }
98 if firstHardErr != nil {
99 return nil, firstHardErr
100 }
101 return nil, lastErr
102 }
103
104 func modelInfoIDs(models []provider.ModelInfo) []string {
105 ids := make([]string, 0, len(models))
106 for _, model := range models {
107 ids = append(ids, model.ID)
108 }
109 return ids
110 }
111
112 func modelFetchAuthMode(e *ProviderEntry) openai.ModelFetchAuthMode {
113 if e == nil || !strings.EqualFold(strings.TrimSpace(e.Kind), "anthropic") {
114 return openai.ModelFetchAuthAuto
115 }
116 if e.AuthHeader {
117 return openai.ModelFetchAuthBearer
118 }
119 return openai.ModelFetchAuthXAPIKey
120 }
121
122 // BuildModelFetchURLs derives likely OpenAI-compatible model-list endpoints.
123 // It keeps Reasonix's historical {base}/models path first, then tries the common
124 // {base}/v1/models shape used by many aggregators. Known official Token Rhythm
125 // URLs collapse to a single /v1/models candidate because that /v1 route is complete.
126 func BuildModelFetchURLs(baseURL, override string) ([]string, error) {
127 if trimmed := strings.TrimSpace(override); trimmed != "" {
128 if canonical, ok := canonicalVendorModelsURL(trimmed); ok {
129 return []string{canonical}, nil
130 }
131 return []string{trimmed}, nil
132 }
133 if canonical, ok := canonicalVendorModelsURL(baseURL); ok {
134 return []string{canonical}, nil
135 }
136 base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
137 if base == "" {
138 return nil, fmt.Errorf("fetch models: base_url is required")
139 }
140 var candidates []string
141 if endsWithVersionSegment(base) {
142 candidates = append(candidates, base+"/models")
143 if !strings.HasSuffix(base, "/v1") {
144 candidates = append(candidates, base+"/v1/models")
145 }
146 } else {
147 candidates = append(candidates, base+"/models", base+"/v1/models")
148 }
149 if stripped := stripModelFetchCompatSuffix(base); stripped != "" {
150 root := strings.TrimRight(stripped, "/")
151 candidates = append(candidates, root+"/models", root+"/v1/models")
152 }
153 return uniqueStrings(candidates), nil
154 }
155
156 // canonicalVendorModelsURL rewrites official vendor bases whose documented
157 // form differs from the OpenAI-compatible shape (Token Rhythm, StepFun step_plan).
158 func canonicalVendorModelsURL(raw string) (string, bool) {
159 if canonical, ok := openai.CanonicalTokenRhythmModelsURL(raw); ok {
160 return canonical, true
161 }
162 return openai.CanonicalStepFunPlanModelsURL(raw)
163 }
164
165 func endsWithVersionSegment(raw string) bool {
166 last := raw
167 if i := strings.LastIndex(raw, "/"); i >= 0 {
168 last = raw[i+1:]
169 }
170 if len(last) < 2 || last[0] != 'v' {
171 return false
172 }
173 for _, r := range last[1:] {
174 if r < '0' || r > '9' {
175 return false
176 }
177 }
178 return true
179 }
180
181 func stripModelFetchCompatSuffix(base string) string {
182 for _, suffix := range knownModelFetchCompatSuffixes {
183 if strings.HasSuffix(base, suffix) {
184 return base[:len(base)-len(suffix)]
185 }
186 }
187 return ""
188 }
189
190 func uniqueStrings(in []string) []string {
191 out := make([]string, 0, len(in))
192 for _, s := range in {
193 if s == "" {
194 continue
195 }
196 seen := slices.Contains(out, s)
197 if !seen {
198 out = append(out, s)
199 }
200 }
201 return out
202 }
203
203 lines GO