fix(provider): keep /v4 base from getting a spurious /v1 chat path
The OpenAI-compatible client only treated a base URL ending in "/v1" as already-versioned; any other base got "/v1/chat/completions" appended. Z.AI's coding-plan base ends in "/paas/v4", so requests were sent to ".../paas/v4/v1/chat/completions" — a path that does not exist — and every GLM model returned 404. Match any trailing "/vN" version segment instead. This is behaviour- identical for all existing providers (their versioned bases all end in "/v1") and only changes Z.AI, which now hits ".../paas/v4/chat/completions".
- commit
- 9bb884ebbc8901df981d7d74087091ec0a20f952
- author
- Pietro Di Bello · · 2026-06-14 19:45 UTC
- parent
- 85a3c3b
- signature
- GPG Unverified
4F1F41D0C6F3B477The key is unknown
2 files · +62 -5
M packages/provider/openai.go +22 -5
| @@ -8,12 +8,32 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "io" | |
| 10 | 10 | "net/http" | |
| 11 | + | "regexp" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "time" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | const openaiDefaultBaseURL = "https://api.openai.com" | |
| 16 | 17 | ||
| 18 | + | // versionSegmentSuffix matches a trailing API version segment such as | |
| 19 | + | // "/v1" or Z.AI's "/v4". | |
| 20 | + | var versionSegmentSuffix = regexp.MustCompile(`/v\d+$`) | |
| 21 | + | ||
| 22 | + | // chatCompletionsURL builds the chat-completions endpoint for an | |
| 23 | + | // OpenAI-compatible base URL. A base that already carries an API | |
| 24 | + | // version segment gets "/chat/completions" appended directly; a bare | |
| 25 | + | // host (e.g. api.openai.com) gets the conventional "/v1/chat/completions". | |
| 26 | + | // | |
| 27 | + | // Matching any "/vN" segment (not just "/v1") keeps Z.AI's coding-plan | |
| 28 | + | // base, which ends in "/paas/v4", from getting a spurious "/v1" that | |
| 29 | + | // yields ".../paas/v4/v1/chat/completions" and a 404. | |
| 30 | + | func chatCompletionsURL(baseURL string) string { | |
| 31 | + | if versionSegmentSuffix.MatchString(baseURL) { | |
| 32 | + | return baseURL + "/chat/completions" | |
| 33 | + | } | |
| 34 | + | return baseURL + "/v1/chat/completions" | |
| 35 | + | } | |
| 36 | + | ||
| 17 | 37 | type openaiClient struct { | |
| 18 | 38 | apiKey string | |
| 19 | 39 | baseURL string | |
| @@ -412,10 +432,7 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} { | |||
| 412 | 432 | // ---- streaming ---- | |
| 413 | 433 | ||
| 414 | 434 | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 415 | - | apiPath := "/v1/chat/completions" | |
| 416 | - | if strings.HasSuffix(c.baseURL, "/v1") { | |
| 417 | - | apiPath = "/chat/completions" | |
| 418 | - | } | |
| 435 | + | endpoint := chatCompletionsURL(c.baseURL) | |
| 419 | 436 | wire, err := c.buildRequest(req) | |
| 420 | 437 | if err != nil { | |
| 421 | 438 | return nil, err | |
| @@ -425,7 +442,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||
| 425 | 442 | return nil, err | |
| 426 | 443 | } | |
| 427 | 444 | newReq := func() (*http.Request, error) { | |
| 428 | - | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body)) | |
| 445 | + | httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) | |
| 429 | 446 | if err != nil { | |
| 430 | 447 | return nil, err | |
| 431 | 448 | } | |
| @@ -8,12 +8,32 @@ import ( | |||||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | "net/http" | 10 | "net/http" | ||
| 11 | + | "regexp" | |||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "time" | 13 | "time" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | const openaiDefaultBaseURL = "https://api.openai.com" | 16 | const openaiDefaultBaseURL = "https://api.openai.com" | ||
| 16 | 17 | ||||
| 18 | + | // versionSegmentSuffix matches a trailing API version segment such as | |||
| 19 | + | // "/v1" or Z.AI's "/v4". | |||
| 20 | + | var versionSegmentSuffix = regexp.MustCompile(`/v\d+$`) | |||
| 21 | + | ||||
| 22 | + | // chatCompletionsURL builds the chat-completions endpoint for an | |||
| 23 | + | // OpenAI-compatible base URL. A base that already carries an API | |||
| 24 | + | // version segment gets "/chat/completions" appended directly; a bare | |||
| 25 | + | // host (e.g. api.openai.com) gets the conventional "/v1/chat/completions". | |||
| 26 | + | // | |||
| 27 | + | // Matching any "/vN" segment (not just "/v1") keeps Z.AI's coding-plan | |||
| 28 | + | // base, which ends in "/paas/v4", from getting a spurious "/v1" that | |||
| 29 | + | // yields ".../paas/v4/v1/chat/completions" and a 404. | |||
| 30 | + | func chatCompletionsURL(baseURL string) string { | |||
| 31 | + | if versionSegmentSuffix.MatchString(baseURL) { | |||
| 32 | + | return baseURL + "/chat/completions" | |||
| 33 | + | } | |||
| 34 | + | return baseURL + "/v1/chat/completions" | |||
| 35 | + | } | |||
| 36 | + | ||||
| 17 | type openaiClient struct { | 37 | type openaiClient struct { | ||
| 18 | apiKey string | 38 | apiKey string | ||
| 19 | baseURL string | 39 | baseURL string | ||
| @@ -412,10 +432,7 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} { | |||||
| 412 | // ---- streaming ---- | 432 | // ---- streaming ---- | ||
| 413 | 433 | ||||
| 414 | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 434 | func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 415 | - | apiPath := "/v1/chat/completions" | 435 | + | endpoint := chatCompletionsURL(c.baseURL) |
| 416 | - | if strings.HasSuffix(c.baseURL, "/v1") { | |||
| 417 | - | apiPath = "/chat/completions" | |||
| 418 | - | } | |||
| 419 | wire, err := c.buildRequest(req) | 436 | wire, err := c.buildRequest(req) | ||
| 420 | if err != nil { | 437 | if err != nil { | ||
| 421 | return nil, err | 438 | return nil, err | ||
| @@ -425,7 +442,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e | |||||
| 425 | return nil, err | 442 | return nil, err | ||
| 426 | } | 443 | } | ||
| 427 | newReq := func() (*http.Request, error) { | 444 | newReq := func() (*http.Request, error) { | ||
| 428 | - | httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+apiPath, bytes.NewReader(body)) | 445 | + | httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) |
| 429 | if err != nil { | 446 | if err != nil { | ||
| 430 | return nil, err | 447 | return nil, err | ||
| 431 | } | 448 | } | ||
A packages/provider/openai_url_test.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | // TestChatCompletionsURL pins the endpoint built for OpenAI-compatible | |
| 6 | + | // providers. The regression that motivated it: Z.AI's coding-plan base | |
| 7 | + | // carries a "/v4" version segment, so blindly appending | |
| 8 | + | // "/v1/chat/completions" produced ".../paas/v4/v1/chat/completions" | |
| 9 | + | // and a 404. Any base that already ends in a version segment must get | |
| 10 | + | // "/chat/completions" appended directly. | |
| 11 | + | func TestChatCompletionsURL(t *testing.T) { | |
| 12 | + | cases := []struct { | |
| 13 | + | name string | |
| 14 | + | baseURL string | |
| 15 | + | want string | |
| 16 | + | }{ | |
| 17 | + | { | |
| 18 | + | name: "bare host gets conventional /v1 path", | |
| 19 | + | baseURL: "https://api.openai.com", | |
| 20 | + | want: "https://api.openai.com/v1/chat/completions", | |
| 21 | + | }, | |
| 22 | + | { | |
| 23 | + | name: "v1 base is not doubled", | |
| 24 | + | baseURL: "https://api.moonshot.ai/v1", | |
| 25 | + | want: "https://api.moonshot.ai/v1/chat/completions", | |
| 26 | + | }, | |
| 27 | + | { | |
| 28 | + | name: "zai coding plan v4 base is not given a spurious /v1", | |
| 29 | + | baseURL: "https://api.z.ai/api/coding/paas/v4", | |
| 30 | + | want: "https://api.z.ai/api/coding/paas/v4/chat/completions", | |
| 31 | + | }, | |
| 32 | + | } | |
| 33 | + | for _, tc := range cases { | |
| 34 | + | t.Run(tc.name, func(t *testing.T) { | |
| 35 | + | if got := chatCompletionsURL(tc.baseURL); got != tc.want { | |
| 36 | + | t.Errorf("chatCompletionsURL(%q) = %q, want %q", tc.baseURL, got, tc.want) | |
| 37 | + | } | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | // TestChatCompletionsURL pins the endpoint built for OpenAI-compatible | |||
| 6 | + | // providers. The regression that motivated it: Z.AI's coding-plan base | |||
| 7 | + | // carries a "/v4" version segment, so blindly appending | |||
| 8 | + | // "/v1/chat/completions" produced ".../paas/v4/v1/chat/completions" | |||
| 9 | + | // and a 404. Any base that already ends in a version segment must get | |||
| 10 | + | // "/chat/completions" appended directly. | |||
| 11 | + | func TestChatCompletionsURL(t *testing.T) { | |||
| 12 | + | cases := []struct { | |||
| 13 | + | name string | |||
| 14 | + | baseURL string | |||
| 15 | + | want string | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "bare host gets conventional /v1 path", | |||
| 19 | + | baseURL: "https://api.openai.com", | |||
| 20 | + | want: "https://api.openai.com/v1/chat/completions", | |||
| 21 | + | }, | |||
| 22 | + | { | |||
| 23 | + | name: "v1 base is not doubled", | |||
| 24 | + | baseURL: "https://api.moonshot.ai/v1", | |||
| 25 | + | want: "https://api.moonshot.ai/v1/chat/completions", | |||
| 26 | + | }, | |||
| 27 | + | { | |||
| 28 | + | name: "zai coding plan v4 base is not given a spurious /v1", | |||
| 29 | + | baseURL: "https://api.z.ai/api/coding/paas/v4", | |||
| 30 | + | want: "https://api.z.ai/api/coding/paas/v4/chat/completions", | |||
| 31 | + | }, | |||
| 32 | + | } | |||
| 33 | + | for _, tc := range cases { | |||
| 34 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 35 | + | if got := chatCompletionsURL(tc.baseURL); got != tc.want { | |||
| 36 | + | t.Errorf("chatCompletionsURL(%q) = %q, want %q", tc.baseURL, got, tc.want) | |||
| 37 | + | } | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||