返回 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 "strings"
8
9 "reasonix/internal/provider/openai"
10 )
11
12 var knownModelFetchCompatSuffixes = []string{
13 "/api/claudecode",
14 "/api/anthropic",
15 "/apps/anthropic",
16 "/api/coding",
17 "/claudecode",
18 "/anthropic",
19 "/step_plan",
20 "/coding",
21 "/claude",
22 }
23
24 // FetchModels queries the provider's OpenAI-compatible GET /models endpoint and
25 // returns the available model IDs, sorted alphabetically.
26 func (e *ProviderEntry) FetchModels(ctx context.Context) ([]string, error) {
27 if e.BaseURL == "" {
28 return nil, fmt.Errorf("fetch models: provider %q has no base_url", e.Name)
29 }
30 key := e.APIKey()
31 if e.RequiresAPIKey() && key == "" {
32 return nil, fmt.Errorf("fetch models: provider %q has no API key (set %s in .env)", e.Name, e.APIKeyEnv)
33 }
34 candidates, err := BuildModelFetchURLs(e.BaseURL, e.ModelsURL)
35 if err != nil {
36 return nil, err
37 }
38 var lastErr error
39 var firstHardErr error
40 authMode := modelFetchAuthMode(e)
41 for _, u := range candidates {
42 models, err := openai.FetchModelsWithOptions(ctx, u, key, openai.FetchModelsOptions{
43 Headers: e.Headers,
44 AuthMode: authMode,
45 })
46 if err == nil {
47 return models, nil
48 }
49 lastErr = err
50 if !openai.IsModelFetchEndpointMiss(err) && firstHardErr == nil {
51 firstHardErr = err
52 }
53 }
54 if firstHardErr != nil {
55 return nil, firstHardErr
56 }
57 return nil, lastErr
58 }
59
60 func modelFetchAuthMode(e *ProviderEntry) openai.ModelFetchAuthMode {
61 if e == nil || !strings.EqualFold(strings.TrimSpace(e.Kind), "anthropic") {
62 return openai.ModelFetchAuthAuto
63 }
64 if e.AuthHeader {
65 return openai.ModelFetchAuthBearer
66 }
67 return openai.ModelFetchAuthXAPIKey
68 }
69
70 // BuildModelFetchURLs derives likely OpenAI-compatible model-list endpoints.
71 // It keeps Reasonix's historical {base}/models path first, then tries the common
72 // {base}/v1/models shape used by many aggregators.
73 func BuildModelFetchURLs(baseURL, override string) ([]string, error) {
74 if trimmed := strings.TrimSpace(override); trimmed != "" {
75 return []string{trimmed}, nil
76 }
77 base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
78 if base == "" {
79 return nil, fmt.Errorf("fetch models: base_url is required")
80 }
81 var candidates []string
82 if endsWithVersionSegment(base) {
83 candidates = append(candidates, base+"/models")
84 if !strings.HasSuffix(base, "/v1") {
85 candidates = append(candidates, base+"/v1/models")
86 }
87 } else {
88 candidates = append(candidates, base+"/models", base+"/v1/models")
89 }
90 if stripped := stripModelFetchCompatSuffix(base); stripped != "" {
91 root := strings.TrimRight(stripped, "/")
92 candidates = append(candidates, root+"/models", root+"/v1/models")
93 }
94 return uniqueStrings(candidates), nil
95 }
96
97 func endsWithVersionSegment(raw string) bool {
98 last := raw
99 if i := strings.LastIndex(raw, "/"); i >= 0 {
100 last = raw[i+1:]
101 }
102 if len(last) < 2 || last[0] != 'v' {
103 return false
104 }
105 for _, r := range last[1:] {
106 if r < '0' || r > '9' {
107 return false
108 }
109 }
110 return true
111 }
112
113 func stripModelFetchCompatSuffix(base string) string {
114 for _, suffix := range knownModelFetchCompatSuffixes {
115 if strings.HasSuffix(base, suffix) {
116 return base[:len(base)-len(suffix)]
117 }
118 }
119 return ""
120 }
121
122 func uniqueStrings(in []string) []string {
123 out := make([]string, 0, len(in))
124 for _, s := range in {
125 if s == "" {
126 continue
127 }
128 seen := false
129 for _, existing := range out {
130 if existing == s {
131 seen = true
132 break
133 }
134 }
135 if !seen {
136 out = append(out, s)
137 }
138 }
139 return out
140 }
141
141 lines GO