detached 9ee726b

Merge pull request #33 from xpepper/fix/zai-v4-chat-completions-path

fix(provider): keep Z.AI /v4 base from getting a spurious /v1 chat path
commit
9ee726bb209e2ac07bbb01e33634ddc8d83260fd
author
Patric Eckhart · · 2026-06-15 06:04 UTC
committer
GitHub · · 2026-06-15 06:04 UTC
merge of
85a3c3b + 9bb884e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.32
2 files · +62 -5
M packages/provider/openai.go +22 -5
@@ -8,12 +8,32 @@ import (
88 "fmt"
99 "io"
1010 "net/http"
11+ "regexp"
1112 "strings"
1213 "time"
1314 )
1415
1516 const openaiDefaultBaseURL = "https://api.openai.com"
1617
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+
1737 type openaiClient struct {
1838 apiKey string
1939 baseURL string
@@ -412,10 +432,7 @@ func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
412432 // ---- streaming ----
413433
414434 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)
419436 wire, err := c.buildRequest(req)
420437 if err != nil {
421438 return nil, err
@@ -425,7 +442,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
425442 return nil, err
426443 }
427444 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))
429446 if err != nil {
430447 return nil, err
431448 }
@@ -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 string38 apiKey string
19 baseURL string39 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, err438 return nil, err
@@ -425,7 +442,7 @@ func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, e
425 return nil, err442 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, err447 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close