Skip to content

Commit 3eca6e6

Browse files
fix: align BigModel provider endpoints
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d6a45b8 commit 3eca6e6

7 files changed

Lines changed: 170 additions & 32 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Supported providers include:
104104
| DeepSeek | - | `https://api.deepseek.com/v1` |
105105
| MiMo | Xiaomi | `https://api.xiaomimimo.com/v1` |
106106
| Kimi | Moonshot | `https://api.moonshot.cn/v1` |
107-
| BigModel | Zhipu/智谱AI | `https://open.bigmodel.cn/api/coding/paas/v4` |
107+
| BigModel | Zhipu/智谱AI | `https://open.bigmodel.cn/api/paas/v4` |
108108
| Qianfan | Baidu | `https://qianfan.baidubce.com/v2` |
109109
| MiniMax | - | `https://api.minimax.chat/v1` |
110110
| OpenRouter | - | `https://openrouter.ai/api/v1` |

internal/cli/launch_prepare_test.go

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -281,25 +281,34 @@ func TestPrepareCodexLaunchIncludesModelCatalog(t *testing.T) {
281281
t.Fatalf("prepareCodexLaunch returned error: %v", err)
282282
}
283283

284+
if len(prepared.Args) != 2 || prepared.Args[0] != "--model" || prepared.Args[1] != "Qwen/Qwen3.5-2B" {
285+
t.Fatalf("prepared args = %#v, want only --model override", prepared.Args)
286+
}
287+
data, err := os.ReadFile(filepath.Join(home, ".codex", "config.toml"))
288+
if err != nil {
289+
t.Fatalf("read codex config: %v", err)
290+
}
291+
configText := string(data)
284292
for _, want := range []string{
285-
`model_provider="csghub_lite"`,
286-
`model_providers.csghub_lite.name="OpenCSG"`,
287-
`model_providers.csghub_lite.base_url=` + strconv.Quote(server.URL+"/v1"),
288-
`model_providers.csghub_lite.supports_websockets=false`,
293+
`model_provider = "csghub_lite"`,
294+
`[model_providers.csghub_lite]`,
295+
`name = "OpenCSG"`,
296+
`base_url = ` + strconv.Quote(server.URL+"/v1"),
297+
`supports_websockets = false`,
289298
} {
290-
if !hasConfigOverride(prepared.Args, want) {
291-
t.Fatalf("missing Codex config override %q in args %#v", want, prepared.Args)
299+
if !strings.Contains(configText, want) {
300+
t.Fatalf("codex config missing %q:\n%s", want, configText)
292301
}
293302
}
294-
catalogValue := configValue(prepared.Args, "model_catalog_json=")
303+
catalogValue := configValueFromConfig(configText, "model_catalog_json")
295304
if catalogValue == "" {
296-
t.Fatalf("missing model_catalog_json config in args %#v", prepared.Args)
305+
t.Fatalf("missing model_catalog_json config:\n%s", configText)
297306
}
298307
catalogPath, err := strconv.Unquote(catalogValue)
299308
if err != nil {
300309
t.Fatalf("unquote model_catalog_json %q: %v", catalogValue, err)
301310
}
302-
data, err := os.ReadFile(catalogPath)
311+
data, err = os.ReadFile(catalogPath)
303312
if err != nil {
304313
t.Fatalf("read model catalog: %v", err)
305314
}
@@ -319,10 +328,10 @@ func TestPrepareCodexLaunchIncludesModelCatalog(t *testing.T) {
319328
if len(payload.Models) != 2 {
320329
t.Fatalf("model catalog count = %d, want 2", len(payload.Models))
321330
}
322-
if payload.Models[0].Slug != "Qwen/Qwen3.5-2B" || payload.Models[0].DisplayName != "Qwen 3.5 2B" {
331+
if payload.Models[0].Slug != "Qwen/Qwen3.5-2B" {
323332
t.Fatalf("unexpected first model entry: %#v", payload.Models[0])
324333
}
325-
if payload.Models[1].Slug != "afrideva/Qwen2-0.5B-Instruct-GGUF:fh23aijhzx8g" || payload.Models[1].DisplayName != "Qwen2-0.5B-Instruct-GGUF" {
334+
if payload.Models[1].Slug != "afrideva/Qwen2-0.5B-Instruct-GGUF:fh23aijhzx8g" {
326335
t.Fatalf("unexpected second model entry: %#v", payload.Models[1])
327336
}
328337
if payload.Models[0].Visibility != "list" || payload.Models[1].Visibility != "list" {
@@ -772,6 +781,21 @@ func configValue(args []string, prefix string) string {
772781
return ""
773782
}
774783

784+
func configValueFromConfig(configText, key string) string {
785+
for _, line := range strings.Split(configText, "\n") {
786+
line = strings.TrimSpace(line)
787+
if line == "" || strings.HasPrefix(line, "#") {
788+
continue
789+
}
790+
before, after, ok := strings.Cut(line, "=")
791+
if !ok || strings.TrimSpace(before) != key {
792+
continue
793+
}
794+
return strings.TrimSpace(after)
795+
}
796+
return ""
797+
}
798+
775799
func argValue(args []string, flag string) string {
776800
for i := 0; i+1 < len(args); i++ {
777801
if args[i] == flag {

internal/inference/openai.go

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,58 @@ import (
1414
)
1515

1616
type openAIEngine struct {
17-
baseURL string
18-
modelName string
19-
token string
20-
client *http.Client
17+
baseURL string
18+
chatCompletionsURL string
19+
modelName string
20+
token string
21+
client *http.Client
2122
}
2223

2324
func NewOpenAIEngine(baseURL, modelName, token string) Engine {
25+
baseURL = strings.TrimRight(baseURL, "/")
2426
return &openAIEngine{
25-
baseURL: strings.TrimRight(baseURL, "/"),
26-
modelName: modelName,
27-
token: strings.TrimSpace(token),
28-
client: &http.Client{Timeout: 0},
27+
baseURL: baseURL,
28+
chatCompletionsURL: openAIChatCompletionsURL(baseURL),
29+
modelName: modelName,
30+
token: strings.TrimSpace(token),
31+
client: &http.Client{Timeout: 0},
2932
}
3033
}
3134

35+
func NewOpenAICompatibleEngine(baseURL, modelName, token string) Engine {
36+
baseURL = strings.TrimRight(baseURL, "/")
37+
return &openAIEngine{
38+
baseURL: baseURL,
39+
chatCompletionsURL: openAICompatibleChatCompletionsURL(baseURL),
40+
modelName: modelName,
41+
token: strings.TrimSpace(token),
42+
client: &http.Client{Timeout: 0},
43+
}
44+
}
45+
46+
func openAIChatCompletionsURL(baseURL string) string {
47+
return strings.TrimRight(baseURL, "/") + "/v1/chat/completions"
48+
}
49+
50+
func openAICompatibleChatCompletionsURL(baseURL string) string {
51+
return strings.TrimRight(baseURL, "/") + "/chat/completions"
52+
}
53+
54+
func (e *openAIEngine) chatCompletionsEndpoint() string {
55+
if strings.TrimSpace(e.chatCompletionsURL) != "" {
56+
return e.chatCompletionsURL
57+
}
58+
return openAIChatCompletionsURL(e.baseURL)
59+
}
60+
3261
func (e *openAIEngine) ChatCompletion(ctx context.Context, reqBody map[string]interface{}) (*http.Response, error) {
3362
reqBody = sanitizeOpenAIRequestBody(e.modelName, reqBody)
3463
body, err := json.Marshal(reqBody)
3564
if err != nil {
3665
return nil, fmt.Errorf("marshaling request: %w", err)
3766
}
3867

39-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/v1/chat/completions", bytes.NewReader(body))
68+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.chatCompletionsEndpoint(), bytes.NewReader(body))
4069
if err != nil {
4170
return nil, fmt.Errorf("creating request: %w", err)
4271
}
@@ -95,7 +124,7 @@ func (e *openAIEngine) Chat(ctx context.Context, messages []Message, opts Option
95124
return "", fmt.Errorf("marshaling request: %w", err)
96125
}
97126

98-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/v1/chat/completions", bytes.NewReader(body))
127+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.chatCompletionsEndpoint(), bytes.NewReader(body))
99128
if err != nil {
100129
return "", fmt.Errorf("creating request: %w", err)
101130
}

internal/server/handlers_providers.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func (s *Server) handleProvidersList(w http.ResponseWriter, r *http.Request) {
1919
resp.Providers[i] = api.ThirdPartyProvider{
2020
ID: p.ID,
2121
Name: p.Name,
22-
BaseURL: p.BaseURL,
22+
BaseURL: normalizeThirdPartyProviderBaseURL(p),
2323
Provider: p.Provider,
2424
Enabled: p.Enabled,
2525
// APIKey is intentionally not returned for security
@@ -96,6 +96,7 @@ func (s *Server) handleProviderCreate(w http.ResponseWriter, r *http.Request) {
9696
Provider: provider,
9797
Enabled: req.Enabled,
9898
}
99+
newProvider.BaseURL = normalizeThirdPartyProviderBaseURL(newProvider)
99100
if _, err := validateThirdPartyProvider(r.Context(), newProvider); err != nil {
100101
writeError(w, http.StatusBadRequest, "provider configuration is invalid: "+err.Error())
101102
return
@@ -151,6 +152,7 @@ func (s *Server) handleProviderUpdate(w http.ResponseWriter, r *http.Request) {
151152
if req.Enabled != nil {
152153
candidate.Enabled = *req.Enabled
153154
}
155+
candidate.BaseURL = normalizeThirdPartyProviderBaseURL(candidate)
154156
if _, err := validateThirdPartyProvider(r.Context(), candidate); err != nil {
155157
writeError(w, http.StatusBadRequest, "provider configuration is invalid: "+err.Error())
156158
return

internal/server/handlers_providers_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,80 @@ func TestThirdPartyProviderEngineTrimsV1BaseURL(t *testing.T) {
228228
}
229229
}
230230

231+
func TestThirdPartyProviderEngineUsesCompatibleBaseURLPath(t *testing.T) {
232+
home := t.TempDir()
233+
t.Setenv("HOME", home)
234+
t.Setenv("USERPROFILE", home)
235+
config.ResetProviders()
236+
t.Cleanup(config.ResetProviders)
237+
238+
var modelPath, chatPath string
239+
apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
240+
switch r.URL.Path {
241+
case "/api/paas/v4/models":
242+
modelPath = r.URL.Path
243+
w.Header().Set("Content-Type", "application/json")
244+
_, _ = fmt.Fprint(w, `{"data":[{"id":"glm-5.1"}]}`)
245+
case "/api/paas/v4/chat/completions":
246+
chatPath = r.URL.Path
247+
w.Header().Set("Content-Type", "application/json")
248+
_, _ = fmt.Fprint(w, `{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`)
249+
default:
250+
http.NotFound(w, r)
251+
}
252+
}))
253+
defer apiServer.Close()
254+
255+
if err := config.SaveProviders([]config.ThirdPartyProvider{{
256+
ID: "provider1",
257+
Name: "BigModel",
258+
BaseURL: apiServer.URL + "/api/paas/v4",
259+
APIKey: "secret",
260+
Provider: "bigmodel",
261+
Enabled: true,
262+
}}); err != nil {
263+
t.Fatalf("save providers: %v", err)
264+
}
265+
266+
models, err := listOpenAICompatibleProviderModels(context.Background(), config.GetProviders()[0])
267+
if err != nil {
268+
t.Fatalf("list models returned error: %v", err)
269+
}
270+
if len(models) != 1 || models[0].Model != "glm-5.1" {
271+
t.Fatalf("models = %#v", models)
272+
}
273+
274+
eng, err := newThirdPartyProviderEngine("provider:provider1", "glm-5.1")
275+
if err != nil {
276+
t.Fatalf("new engine: %v", err)
277+
}
278+
got, err := eng.Chat(context.Background(), nil, inference.DefaultOptions(), nil)
279+
if err != nil {
280+
t.Fatalf("chat returned error: %v", err)
281+
}
282+
if got != "ok" {
283+
t.Fatalf("chat = %q, want ok", got)
284+
}
285+
if modelPath != "/api/paas/v4/models" {
286+
t.Fatalf("model path = %q", modelPath)
287+
}
288+
if chatPath != "/api/paas/v4/chat/completions" {
289+
t.Fatalf("chat path = %q", chatPath)
290+
}
291+
}
292+
293+
func TestBigModelLegacyPresetBaseURLNormalizesToOfficialPath(t *testing.T) {
294+
provider := config.ThirdPartyProvider{
295+
Name: "BigModel",
296+
BaseURL: "https://open.bigmodel.cn/api/coding/paas/v4/",
297+
Provider: "bigmodel",
298+
}
299+
300+
if got := normalizeThirdPartyProviderBaseURL(provider); got != bigModelOfficialBaseURL {
301+
t.Fatalf("base URL = %q, want %q", got, bigModelOfficialBaseURL)
302+
}
303+
}
304+
231305
func TestGetChatEnginePrefersThirdPartyWhenCloudLoginMissing(t *testing.T) {
232306
home := t.TempDir()
233307
t.Setenv("HOME", home)

internal/server/third_party_providers.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import (
1717
)
1818

1919
const thirdPartyProviderSourcePrefix = "provider:"
20+
const (
21+
bigModelProviderType = "bigmodel"
22+
bigModelOfficialBaseURL = "https://open.bigmodel.cn/api/paas/v4"
23+
bigModelLegacyCodingBaseURL = "https://open.bigmodel.cn/api/coding/paas/v4"
24+
)
2025

2126
func providerSource(id string) string {
2227
return thirdPartyProviderSourcePrefix + strings.TrimSpace(id)
@@ -51,13 +56,13 @@ func (s *Server) listThirdPartyProviderModels(ctx context.Context) []api.ModelIn
5156

5257
// Use cached data if available and fresh (within 30 seconds).
5358
// This avoids repeated API calls to third-party providers.
54-
s.thirdPartyModelsCacheMu.Lock()
59+
s.thirdPartyModelsCacheMu.Lock()
5560
if s.thirdPartyModelsCache != nil && time.Since(s.thirdPartyModelsCacheAt) < 30*time.Second {
5661
cache := s.thirdPartyModelsCache
5762
s.thirdPartyModelsCacheMu.Unlock()
5863
return cache
5964
}
60-
s.thirdPartyModelsCacheMu.Unlock()
65+
s.thirdPartyModelsCacheMu.Unlock()
6166

6267
// Query all providers in parallel to reduce latency.
6368
var mu sync.Mutex
@@ -95,7 +100,7 @@ func (s *Server) listThirdPartyProviderModels(ctx context.Context) []api.ModelIn
95100
}
96101

97102
func listOpenAICompatibleProviderModels(ctx context.Context, provider config.ThirdPartyProvider) ([]api.ModelInfo, error) {
98-
baseURL := strings.TrimRight(strings.TrimSpace(provider.BaseURL), "/")
103+
baseURL := normalizeThirdPartyProviderBaseURL(provider)
99104
if baseURL == "" || strings.TrimSpace(provider.APIKey) == "" {
100105
return nil, nil
101106
}
@@ -146,9 +151,13 @@ func listOpenAICompatibleProviderModels(ctx context.Context, provider config.Thi
146151
return models, nil
147152
}
148153

149-
func providerEngineBaseURL(baseURL string) string {
150-
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
151-
return strings.TrimSuffix(baseURL, "/v1")
154+
func normalizeThirdPartyProviderBaseURL(provider config.ThirdPartyProvider) string {
155+
baseURL := strings.TrimRight(strings.TrimSpace(provider.BaseURL), "/")
156+
providerType := strings.TrimSpace(strings.ToLower(provider.Provider))
157+
if providerType == bigModelProviderType && strings.EqualFold(baseURL, bigModelLegacyCodingBaseURL) {
158+
return bigModelOfficialBaseURL
159+
}
160+
return baseURL
152161
}
153162

154163
func validateThirdPartyProvider(ctx context.Context, provider config.ThirdPartyProvider) (int, error) {
@@ -177,10 +186,10 @@ func newThirdPartyProviderEngine(source, modelID string) (inference.Engine, erro
177186
if !provider.Enabled {
178187
return nil, inference.NewHTTPStatusError(http.StatusForbidden, "third-party provider is disabled")
179188
}
180-
baseURL := strings.TrimSpace(provider.BaseURL)
189+
baseURL := normalizeThirdPartyProviderBaseURL(provider)
181190
apiKey := strings.TrimSpace(provider.APIKey)
182191
if baseURL == "" || apiKey == "" {
183192
return nil, inference.NewHTTPStatusError(http.StatusBadRequest, "third-party provider is missing base URL or API key")
184193
}
185-
return inference.NewOpenAIEngine(providerEngineBaseURL(baseURL), modelID, apiKey), nil
194+
return inference.NewOpenAICompatibleEngine(baseURL, modelID, apiKey), nil
186195
}

web/src/pages/Settings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const providerTypes = [
7676
{ value: "deepseek", label: "DeepSeek", name: "DeepSeek", baseURL: "https://api.deepseek.com/v1" },
7777
{ value: "mimo", label: "MiMo (Xiaomi)", name: "MiMo", baseURL: "https://api.xiaomimimo.com/v1" },
7878
{ value: "kimi", label: "Kimi (Moonshot)", name: "Kimi", baseURL: "https://api.moonshot.cn/v1" },
79-
{ value: "bigmodel", label: "BigModel (Zhipu)", name: "BigModel", baseURL: "https://open.bigmodel.cn/api/coding/paas/v4" },
79+
{ value: "bigmodel", label: "BigModel (Zhipu)", name: "BigModel", baseURL: "https://open.bigmodel.cn/api/paas/v4" },
8080
{ value: "qianfan", label: "Qianfan (Baidu)", name: "Qianfan", baseURL: "https://qianfan.baidubce.com/v2" },
8181
{ value: "openrouter", label: "OpenRouter", name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1" },
8282
{ value: "other", label: "Other", name: "", baseURL: "" },

0 commit comments

Comments
 (0)