History

Merge branch 'router-for-me:main' into main

Luis Pater committed 7 months ago

Commit 0e4f6692 parents 1dc4ecb 76c064c13 files · +671 −112Browse files

Unified · Split

13 files
  1. README_JA.md +3 −3
  2. config.example.yaml +3 −1
  3. internal/api/handlers/management/auth_files.go +257 −51
  4. internal/api/handlers/management/auth_files_batch_test.go +197 −0
  5. internal/config/config.go +5 −0
  6. internal/registry/model_registry.go +5 −5
  7. internal/runtime/executor/codex_executor.go +10 −4
  8. internal/runtime/executor/codex_websockets_executor.go +7 −2
  9. internal/runtime/executor/codex_websockets_executor_test.go +88 −0
  10. internal/translator/antigravity/claude/antigravity_claude_request.go +44 −44
  11. internal/translator/codex/openai/chat-completions/codex_openai_response.go +1 −1
  12. internal/translator/codex/openai/chat-completions/codex_openai_response_test.go +45 −0
  13. sdk/cliproxy/service.go +6 −1
README_JA.md+3−3
@@ -58,11 +58,11 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB · 57 unmodified lines
58 58
59## はじめに59## はじめに
60 60
61CLIProxyAPIガイド:[https://help.router-for.me/ja/](https://help.router-for.me/ja/)61CLIProxyAPIガイド:[https://help.router-for.me/](https://help.router-for.me/)
62 62
63## 管理API63## 管理API
64 64
65[MANAGEMENT_API.md](https://help.router-for.me/ja/management/api)を参照65[MANAGEMENT_API.md](https://help.router-for.me/management/api)を参照
66 66
67## Amp CLIサポート67## Amp CLIサポート
68 68
@@ -74,7 +74,7 @@ CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統 · 5 unmodified lines
74- 利用できないモデルを代替モデルにルーティングする**モデルマッピング**(例:`claude-opus-4.5` → `claude-sonnet-4`)74- 利用できないモデルを代替モデルにルーティングする**モデルマッピング**(例:`claude-opus-4.5` → `claude-sonnet-4`)
75- localhostのみの管理エンドポイントによるセキュリティファーストの設計75- localhostのみの管理エンドポイントによるセキュリティファーストの設計
76 76
77**→ [Amp CLI統合ガイドの完全版](https://help.router-for.me/ja/agent-client/amp-cli.html)**77**→ [Amp CLI統合ガイドの完全版](https://help.router-for.me/agent-client/amp-cli.html)**
78 78
79## SDKドキュメント79## SDKドキュメント
80 80
config.example.yaml+3−1
@@ -238,7 +238,9 @@ nonstream-keepalive-interval: 0 · 237 unmodified lines
238# - api-key: "sk-or-v1-...b781" # without proxy-url238# - api-key: "sk-or-v1-...b781" # without proxy-url
239# models: # The models supported by the provider.239# models: # The models supported by the provider.
240# - name: "moonshotai/kimi-k2:free" # The actual model name.240# - name: "moonshotai/kimi-k2:free" # The actual model name.
241# alias: "kimi-k2" # The alias used in the API.241# alias: "kimi-k2" # The alias used in the API.
242# thinking: # optional: omit to default to levels ["low","medium","high"]
243# levels: ["low", "medium", "high"]
242# # You may repeat the same alias to build an internal model pool.244# # You may repeat the same alias to build an internal model pool.
243# # The client still sees only one alias in the model list.245# # The client still sees only one alias in the model list.
244# # Requests to that alias will round-robin across the upstream names below,246# # Requests to that alias will round-robin across the upstream names below,
internal/api/handlers/management/auth_files.go+257−51
@@ -11,6 +11,7 @@ import ( · 10 unmodified lines
11 "errors"11 "errors"
12 "fmt"12 "fmt"
13 "io"13 "io"
14 "mime/multipart"
14 "net"15 "net"
15 "net/http"16 "net/http"
16 "net/url"17 "net/url"
@@ -66,8 +67,10 @@ type callbackForwarder struct { · 49 unmodified lines
66}67}
67 68
68var (69var (
69 callbackForwardersMu sync.Mutex70 callbackForwardersMu sync.Mutex
70 callbackForwarders = make(map[int]*callbackForwarder)71 callbackForwarders = make(map[int]*callbackForwarder)
72 errAuthFileMustBeJSON = errors.New("auth file must be .json")
73 errAuthFileNotFound = errors.New("auth file not found")
71)74)
72 75
73func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {76func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
@@ -579,32 +582,57 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { · 505 unmodified lines
579 return582 return
580 }583 }
581 ctx := c.Request.Context()584 ctx := c.Request.Context()
582 if file, err := c.FormFile("file"); err == nil && file != nil {585
583 name := filepath.Base(file.Filename)586 fileHeaders, errMultipart := h.multipartAuthFileHeaders(c)
584 if !strings.HasSuffix(strings.ToLower(name), ".json") {587 if errMultipart != nil {
585 c.JSON(400, gin.H{"error": "file must be .json"})588 c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid multipart form: %v", errMultipart)})
586 return589 return
587 }590 }
588 dst := filepath.Join(h.cfg.AuthDir, name)591 if len(fileHeaders) == 1 {
589 if !filepath.IsAbs(dst) {592 if _, errUpload := h.storeUploadedAuthFile(ctx, fileHeaders[0]); errUpload != nil {
590 if abs, errAbs := filepath.Abs(dst); errAbs == nil {593 if errors.Is(errUpload, errAuthFileMustBeJSON) {
591 dst = abs594 c.JSON(http.StatusBadRequest, gin.H{"error": "file must be .json"})
595 return
592 }596 }
593 }597 c.JSON(http.StatusInternalServerError, gin.H{"error": errUpload.Error()})
594 if errSave := c.SaveUploadedFile(file, dst); errSave != nil {
595 c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
596 return598 return
597 }599 }
598 data, errRead := os.ReadFile(dst)600 c.JSON(http.StatusOK, gin.H{"status": "ok"})
599 if errRead != nil {601 return
600 c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})602 }
601 return603 if len(fileHeaders) > 1 {
604 uploaded := make([]string, 0, len(fileHeaders))
605 failed := make([]gin.H, 0)
606 for _, file := range fileHeaders {
607 name, errUpload := h.storeUploadedAuthFile(ctx, file)
608 if errUpload != nil {
609 failureName := ""
610 if file != nil {
611 failureName = filepath.Base(file.Filename)
612 }
613 msg := errUpload.Error()
614 if errors.Is(errUpload, errAuthFileMustBeJSON) {
615 msg = "file must be .json"
616 }
617 failed = append(failed, gin.H{"name": failureName, "error": msg})
618 continue
619 }
620 uploaded = append(uploaded, name)
602 }621 }
603 if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {622 if len(failed) > 0 {
604 c.JSON(500, gin.H{"error": errReg.Error()})623 c.JSON(http.StatusMultiStatus, gin.H{
624 "status": "partial",
625 "uploaded": len(uploaded),
626 "files": uploaded,
627 "failed": failed,
628 })
605 return629 return
606 }630 }
607 c.JSON(200, gin.H{"status": "ok"})631 c.JSON(http.StatusOK, gin.H{"status": "ok", "uploaded": len(uploaded), "files": uploaded})
632 return
633 }
634 if c.ContentType() == "multipart/form-data" {
635 c.JSON(http.StatusBadRequest, gin.H{"error": "no files uploaded"})
608 return636 return
609 }637 }
610 name := c.Query("name")638 name := c.Query("name")
@@ -621,17 +649,7 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { · 10 unmodified lines
621 c.JSON(400, gin.H{"error": "failed to read body"})649 c.JSON(400, gin.H{"error": "failed to read body"})
622 return650 return
623 }651 }
624 dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))652 if err = h.writeAuthFile(ctx, filepath.Base(name), data); err != nil {
625 if !filepath.IsAbs(dst) {
626 if abs, errAbs := filepath.Abs(dst); errAbs == nil {
627 dst = abs
628 }
629 }
630 if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
631 c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)})
632 return
633 }
634 if err = h.registerAuthFromFile(ctx, dst, data); err != nil {
635 c.JSON(500, gin.H{"error": err.Error()})653 c.JSON(500, gin.H{"error": err.Error()})
636 return654 return
637 }655 }
@@ -678,11 +696,182 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { · 40 unmodified lines
678 c.JSON(200, gin.H{"status": "ok", "deleted": deleted})696 c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
679 return697 return
680 }698 }
681 name := c.Query("name")699
682 if name == "" || strings.Contains(name, string(os.PathSeparator)) {700 names, errNames := requestedAuthFileNamesForDelete(c)
701 if errNames != nil {
702 c.JSON(http.StatusBadRequest, gin.H{"error": errNames.Error()})
703 return
704 }
705 if len(names) == 0 {
683 c.JSON(400, gin.H{"error": "invalid name"})706 c.JSON(400, gin.H{"error": "invalid name"})
684 return707 return
685 }708 }
709 if len(names) == 1 {
710 if _, status, errDelete := h.deleteAuthFileByName(ctx, names[0]); errDelete != nil {
711 c.JSON(status, gin.H{"error": errDelete.Error()})
712 return
713 }
714 c.JSON(http.StatusOK, gin.H{"status": "ok"})
715 return
716 }
717
718 deletedFiles := make([]string, 0, len(names))
719 failed := make([]gin.H, 0)
720 for _, name := range names {
721 deletedName, _, errDelete := h.deleteAuthFileByName(ctx, name)
722 if errDelete != nil {
723 failed = append(failed, gin.H{"name": name, "error": errDelete.Error()})
724 continue
725 }
726 deletedFiles = append(deletedFiles, deletedName)
727 }
728 if len(failed) > 0 {
729 c.JSON(http.StatusMultiStatus, gin.H{
730 "status": "partial",
731 "deleted": len(deletedFiles),
732 "files": deletedFiles,
733 "failed": failed,
734 })
735 return
736 }
737 c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted": len(deletedFiles), "files": deletedFiles})
738}
739
740func (h *Handler) multipartAuthFileHeaders(c *gin.Context) ([]*multipart.FileHeader, error) {
741 if h == nil || c == nil || c.ContentType() != "multipart/form-data" {
742 return nil, nil
743 }
744 form, err := c.MultipartForm()
745 if err != nil {
746 return nil, err
747 }
748 if form == nil || len(form.File) == 0 {
749 return nil, nil
750 }
751
752 keys := make([]string, 0, len(form.File))
753 for key := range form.File {
754 keys = append(keys, key)
755 }
756 sort.Strings(keys)
757
758 headers := make([]*multipart.FileHeader, 0)
759 for _, key := range keys {
760 headers = append(headers, form.File[key]...)
761 }
762 return headers, nil
763}
764
765func (h *Handler) storeUploadedAuthFile(ctx context.Context, file *multipart.FileHeader) (string, error) {
766 if file == nil {
767 return "", fmt.Errorf("no file uploaded")
768 }
769 name := filepath.Base(strings.TrimSpace(file.Filename))
770 if !strings.HasSuffix(strings.ToLower(name), ".json") {
771 return "", errAuthFileMustBeJSON
772 }
773 src, err := file.Open()
774 if err != nil {
775 return "", fmt.Errorf("failed to open uploaded file: %w", err)
776 }
777 defer src.Close()
778
779 data, err := io.ReadAll(src)
780 if err != nil {
781 return "", fmt.Errorf("failed to read uploaded file: %w", err)
782 }
783 if err := h.writeAuthFile(ctx, name, data); err != nil {
784 return "", err
785 }
786 return name, nil
787}
788
789func (h *Handler) writeAuthFile(ctx context.Context, name string, data []byte) error {
790 dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
791 if !filepath.IsAbs(dst) {
792 if abs, errAbs := filepath.Abs(dst); errAbs == nil {
793 dst = abs
794 }
795 }
796 auth, err := h.buildAuthFromFileData(dst, data)
797 if err != nil {
798 return err
799 }
800 if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
801 return fmt.Errorf("failed to write file: %w", errWrite)
802 }
803 if err := h.upsertAuthRecord(ctx, auth); err != nil {
804 return err
805 }
806 return nil
807}
808
809func requestedAuthFileNamesForDelete(c *gin.Context) ([]string, error) {
810 if c == nil {
811 return nil, nil
812 }
813 names := uniqueAuthFileNames(c.QueryArray("name"))
814 if len(names) > 0 {
815 return names, nil
816 }
817
818 body, err := io.ReadAll(c.Request.Body)
819 if err != nil {
820 return nil, fmt.Errorf("failed to read body")
821 }
822 body = bytes.TrimSpace(body)
823 if len(body) == 0 {
824 return nil, nil
825 }
826
827 var objectBody struct {
828 Name string `json:"name"`
829 Names []string `json:"names"`
830 }
831 if body[0] == '[' {
832 var arrayBody []string
833 if err := json.Unmarshal(body, &arrayBody); err != nil {
834 return nil, fmt.Errorf("invalid request body")
835 }
836 return uniqueAuthFileNames(arrayBody), nil
837 }
838 if err := json.Unmarshal(body, &objectBody); err != nil {
839 return nil, fmt.Errorf("invalid request body")
840 }
841
842 out := make([]string, 0, len(objectBody.Names)+1)
843 if strings.TrimSpace(objectBody.Name) != "" {
844 out = append(out, objectBody.Name)
845 }
846 out = append(out, objectBody.Names...)
847 return uniqueAuthFileNames(out), nil
848}
849
850func uniqueAuthFileNames(names []string) []string {
851 if len(names) == 0 {
852 return nil
853 }
854 seen := make(map[string]struct{}, len(names))
855 out := make([]string, 0, len(names))
856 for _, name := range names {
857 name = strings.TrimSpace(name)
858 if name == "" {
859 continue
860 }
861 if _, ok := seen[name]; ok {
862 continue
863 }
864 seen[name] = struct{}{}
865 out = append(out, name)
866 }
867 return out
868}
869
870func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string, int, error) {
871 name = strings.TrimSpace(name)
872 if name == "" || strings.Contains(name, string(os.PathSeparator)) {
873 return "", http.StatusBadRequest, fmt.Errorf("invalid name")
874 }
686 875
687 targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))876 targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
688 targetID := ""877 targetID := ""
@@ -699,22 +888,19 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { · 10 unmodified lines
699 }888 }
700 if errRemove := os.Remove(targetPath); errRemove != nil {889 if errRemove := os.Remove(targetPath); errRemove != nil {
701 if os.IsNotExist(errRemove) {890 if os.IsNotExist(errRemove) {
702 c.JSON(404, gin.H{"error": "file not found"})891 return filepath.Base(name), http.StatusNotFound, errAuthFileNotFound
703 } else {
704 c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", errRemove)})
705 }892 }
706 return893 return filepath.Base(name), http.StatusInternalServerError, fmt.Errorf("failed to remove file: %w", errRemove)
707 }894 }
708 if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {895 if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {
709 c.JSON(500, gin.H{"error": errDeleteRecord.Error()})896 return filepath.Base(name), http.StatusInternalServerError, errDeleteRecord
710 return
711 }897 }
712 if targetID != "" {898 if targetID != "" {
713 h.disableAuth(ctx, targetID)899 h.disableAuth(ctx, targetID)
714 } else {900 } else {
715 h.disableAuth(ctx, targetPath)901 h.disableAuth(ctx, targetPath)
716 }902 }
717 c.JSON(200, gin.H{"status": "ok"})903 return filepath.Base(name), http.StatusOK, nil
718}904}
719 905
720func (h *Handler) findAuthForDelete(name string) *coreauth.Auth {906func (h *Handler) findAuthForDelete(name string) *coreauth.Auth {
@@ -783,19 +969,27 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data [] · 62 unmodified lines
783 if h.authManager == nil {969 if h.authManager == nil {
784 return nil970 return nil
785 }971 }
972 auth, err := h.buildAuthFromFileData(path, data)
973 if err != nil {
974 return err
975 }
976 return h.upsertAuthRecord(ctx, auth)
977}
978
979func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Auth, error) {
786 if path == "" {980 if path == "" {
787 return fmt.Errorf("auth path is empty")981 return nil, fmt.Errorf("auth path is empty")
788 }982 }
789 if data == nil {983 if data == nil {
790 var err error984 var err error
791 data, err = os.ReadFile(path)985 data, err = os.ReadFile(path)
792 if err != nil {986 if err != nil {
793 return fmt.Errorf("failed to read auth file: %w", err)987 return nil, fmt.Errorf("failed to read auth file: %w", err)
794 }988 }
795 }989 }
796 metadata := make(map[string]any)990 metadata := make(map[string]any)
797 if err := json.Unmarshal(data, &metadata); err != nil {991 if err := json.Unmarshal(data, &metadata); err != nil {
798 return fmt.Errorf("invalid auth file: %w", err)992 return nil, fmt.Errorf("invalid auth file: %w", err)
799 }993 }
800 provider, _ := metadata["type"].(string)994 provider, _ := metadata["type"].(string)
801 if provider == "" {995 if provider == "" {
@@ -829,13 +1023,25 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data [] · 27 unmodified lines
829 if hasLastRefresh {1023 if hasLastRefresh {
830 auth.LastRefreshedAt = lastRefresh1024 auth.LastRefreshedAt = lastRefresh
831 }1025 }
832 if existing, ok := h.authManager.GetByID(authID); ok {1026 if h != nil && h.authManager != nil {
833 auth.CreatedAt = existing.CreatedAt1027 if existing, ok := h.authManager.GetByID(authID); ok {
834 if !hasLastRefresh {1028 auth.CreatedAt = existing.CreatedAt
835 auth.LastRefreshedAt = existing.LastRefreshedAt1029 if !hasLastRefresh {
1030 auth.LastRefreshedAt = existing.LastRefreshedAt
1031 }
1032 auth.NextRefreshAfter = existing.NextRefreshAfter
1033 auth.Runtime = existing.Runtime
836 }1034 }
837 auth.NextRefreshAfter = existing.NextRefreshAfter1035 }
838 auth.Runtime = existing.Runtime1036 return auth, nil
1037}
1038
1039func (h *Handler) upsertAuthRecord(ctx context.Context, auth *coreauth.Auth) error {
1040 if h == nil || h.authManager == nil || auth == nil {
1041 return nil
1042 }
1043 if existing, ok := h.authManager.GetByID(auth.ID); ok {
1044 auth.CreatedAt = existing.CreatedAt
839 _, err := h.authManager.Update(ctx, auth)1045 _, err := h.authManager.Update(ctx, auth)
840 return err1046 return err
841 }1047 }
internal/api/handlers/management/auth_files_batch_test.go+197−0
@@ -0,0 +1,197 @@
1package management
2
3import (
4 "bytes"
5 "encoding/json"
6 "mime/multipart"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "os"
11 "path/filepath"
12 "testing"
13
14 "github.com/gin-gonic/gin"
15 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
16 coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
17)
18
19func TestUploadAuthFile_BatchMultipart(t *testing.T) {
20 t.Setenv("MANAGEMENT_PASSWORD", "")
21 gin.SetMode(gin.TestMode)
22
23 authDir := t.TempDir()
24 manager := coreauth.NewManager(nil, nil, nil)
25 h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
26
27 files := []struct {
28 name string
29 content string
30 }{
31 {name: "alpha.json", content: `{"type":"codex","email":"alpha@example.com"}`},
32 {name: "beta.json", content: `{"type":"claude","email":"beta@example.com"}`},
33 }
34
35 var body bytes.Buffer
36 writer := multipart.NewWriter(&body)
37 for _, file := range files {
38 part, err := writer.CreateFormFile("file", file.name)
39 if err != nil {
40 t.Fatalf("failed to create multipart file: %v", err)
41 }
42 if _, err = part.Write([]byte(file.content)); err != nil {
43 t.Fatalf("failed to write multipart content: %v", err)
44 }
45 }
46 if err := writer.Close(); err != nil {
47 t.Fatalf("failed to close multipart writer: %v", err)
48 }
49
50 rec := httptest.NewRecorder()
51 ctx, _ := gin.CreateTestContext(rec)
52 req := httptest.NewRequest(http.MethodPost, "/v0/management/auth-files", &body)
53 req.Header.Set("Content-Type", writer.FormDataContentType())
54 ctx.Request = req
55
56 h.UploadAuthFile(ctx)
57
58 if rec.Code != http.StatusOK {
59 t.Fatalf("expected upload status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
60 }
61
62 var payload map[string]any
63 if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
64 t.Fatalf("failed to decode response: %v", err)
65 }
66 if got, ok := payload["uploaded"].(float64); !ok || int(got) != len(files) {
67 t.Fatalf("expected uploaded=%d, got %#v", len(files), payload["uploaded"])
68 }
69
70 for _, file := range files {
71 fullPath := filepath.Join(authDir, file.name)
72 data, err := os.ReadFile(fullPath)
73 if err != nil {
74 t.Fatalf("expected uploaded file %s to exist: %v", file.name, err)
75 }
76 if string(data) != file.content {
77 t.Fatalf("expected file %s content %q, got %q", file.name, file.content, string(data))
78 }
79 }
80
81 auths := manager.List()
82 if len(auths) != len(files) {
83 t.Fatalf("expected %d auth entries, got %d", len(files), len(auths))
84 }
85}
86
87func TestUploadAuthFile_BatchMultipart_InvalidJSONDoesNotOverwriteExistingFile(t *testing.T) {
88 t.Setenv("MANAGEMENT_PASSWORD", "")
89 gin.SetMode(gin.TestMode)
90
91 authDir := t.TempDir()
92 manager := coreauth.NewManager(nil, nil, nil)
93 h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
94
95 existingName := "alpha.json"
96 existingContent := `{"type":"codex","email":"alpha@example.com"}`
97 if err := os.WriteFile(filepath.Join(authDir, existingName), []byte(existingContent), 0o600); err != nil {
98 t.Fatalf("failed to seed existing auth file: %v", err)
99 }
100
101 files := []struct {
102 name string
103 content string
104 }{
105 {name: existingName, content: `{"type":"codex"`},
106 {name: "beta.json", content: `{"type":"claude","email":"beta@example.com"}`},
107 }
108
109 var body bytes.Buffer
110 writer := multipart.NewWriter(&body)
111 for _, file := range files {
112 part, err := writer.CreateFormFile("file", file.name)
113 if err != nil {
114 t.Fatalf("failed to create multipart file: %v", err)
115 }
116 if _, err = part.Write([]byte(file.content)); err != nil {
117 t.Fatalf("failed to write multipart content: %v", err)
118 }
119 }
120 if err := writer.Close(); err != nil {
121 t.Fatalf("failed to close multipart writer: %v", err)
122 }
123
124 rec := httptest.NewRecorder()
125 ctx, _ := gin.CreateTestContext(rec)
126 req := httptest.NewRequest(http.MethodPost, "/v0/management/auth-files", &body)
127 req.Header.Set("Content-Type", writer.FormDataContentType())
128 ctx.Request = req
129
130 h.UploadAuthFile(ctx)
131
132 if rec.Code != http.StatusMultiStatus {
133 t.Fatalf("expected upload status %d, got %d with body %s", http.StatusMultiStatus, rec.Code, rec.Body.String())
134 }
135
136 data, err := os.ReadFile(filepath.Join(authDir, existingName))
137 if err != nil {
138 t.Fatalf("expected existing auth file to remain readable: %v", err)
139 }
140 if string(data) != existingContent {
141 t.Fatalf("expected existing auth file to remain %q, got %q", existingContent, string(data))
142 }
143
144 betaData, err := os.ReadFile(filepath.Join(authDir, "beta.json"))
145 if err != nil {
146 t.Fatalf("expected valid auth file to be created: %v", err)
147 }
148 if string(betaData) != files[1].content {
149 t.Fatalf("expected beta auth file content %q, got %q", files[1].content, string(betaData))
150 }
151}
152
153func TestDeleteAuthFile_BatchQuery(t *testing.T) {
154 t.Setenv("MANAGEMENT_PASSWORD", "")
155 gin.SetMode(gin.TestMode)
156
157 authDir := t.TempDir()
158 files := []string{"alpha.json", "beta.json"}
159 for _, name := range files {
160 if err := os.WriteFile(filepath.Join(authDir, name), []byte(`{"type":"codex"}`), 0o600); err != nil {
161 t.Fatalf("failed to write auth file %s: %v", name, err)
162 }
163 }
164
165 manager := coreauth.NewManager(nil, nil, nil)
166 h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
167 h.tokenStore = &memoryAuthStore{}
168
169 rec := httptest.NewRecorder()
170 ctx, _ := gin.CreateTestContext(rec)
171 req := httptest.NewRequest(
172 http.MethodDelete,
173 "/v0/management/auth-files?name="+url.QueryEscape(files[0])+"&name="+url.QueryEscape(files[1]),
174 nil,
175 )
176 ctx.Request = req
177
178 h.DeleteAuthFile(ctx)
179
180 if rec.Code != http.StatusOK {
181 t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
182 }
183
184 var payload map[string]any
185 if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
186 t.Fatalf("failed to decode response: %v", err)
187 }
188 if got, ok := payload["deleted"].(float64); !ok || int(got) != len(files) {
189 t.Fatalf("expected deleted=%d, got %#v", len(files), payload["deleted"])
190 }
191
192 for _, name := range files {
193 if _, err := os.Stat(filepath.Join(authDir, name)); !os.IsNotExist(err) {
194 t.Fatalf("expected auth file %s to be removed, stat err: %v", name, err)
195 }
196 }
197}
internal/config/config.go+5−0
@@ -13,6 +13,7 @@ import ( · 12 unmodified lines
13 "strings"13 "strings"
14 "syscall"14 "syscall"
15 15
16 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
16 log "github.com/sirupsen/logrus"17 log "github.com/sirupsen/logrus"
17 "golang.org/x/crypto/bcrypt"18 "golang.org/x/crypto/bcrypt"
18 "gopkg.in/yaml.v3"19 "gopkg.in/yaml.v3"
@@ -574,6 +575,10 @@ type OpenAICompatibilityModel struct { · 555 unmodified lines
574 575
575 // Alias is the model name alias that clients will use to reference this model.576 // Alias is the model name alias that clients will use to reference this model.
576 Alias string `yaml:"alias" json:"alias"`577 Alias string `yaml:"alias" json:"alias"`
578
579 // Thinking configures the thinking/reasoning capability for this model.
580 // If nil, the model defaults to level-based reasoning with levels ["low", "medium", "high"].
581 Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"`
577}582}
578 583
579func (m OpenAICompatibilityModel) GetName() string { return m.Name }584func (m OpenAICompatibilityModel) GetName() string { return m.Name }
internal/registry/model_registry.go+5−5
@@ -73,16 +73,16 @@ type availableModelsCacheEntry struct { · 72 unmodified lines
73// Values are interpreted in provider-native token units.73// Values are interpreted in provider-native token units.
74type ThinkingSupport struct {74type ThinkingSupport struct {
75 // Min is the minimum allowed thinking budget (inclusive).75 // Min is the minimum allowed thinking budget (inclusive).
76 Min int `json:"min,omitempty"`76 Min int `json:"min,omitempty" yaml:"min,omitempty"`
77 // Max is the maximum allowed thinking budget (inclusive).77 // Max is the maximum allowed thinking budget (inclusive).
78 Max int `json:"max,omitempty"`78 Max int `json:"max,omitempty" yaml:"max,omitempty"`
79 // ZeroAllowed indicates whether 0 is a valid value (to disable thinking).79 // ZeroAllowed indicates whether 0 is a valid value (to disable thinking).
80 ZeroAllowed bool `json:"zero_allowed,omitempty"`80 ZeroAllowed bool `json:"zero_allowed,omitempty" yaml:"zero-allowed,omitempty"`
81 // DynamicAllowed indicates whether -1 is a valid value (dynamic thinking budget).81 // DynamicAllowed indicates whether -1 is a valid value (dynamic thinking budget).
82 DynamicAllowed bool `json:"dynamic_allowed,omitempty"`82 DynamicAllowed bool `json:"dynamic_allowed,omitempty" yaml:"dynamic-allowed,omitempty"`
83 // Levels defines discrete reasoning effort levels (e.g., "low", "medium", "high").83 // Levels defines discrete reasoning effort levels (e.g., "low", "medium", "high").
84 // When set, the model uses level-based reasoning instead of token budgets.84 // When set, the model uses level-based reasoning instead of token budgets.
85 Levels []string `json:"levels,omitempty"`85 Levels []string `json:"levels,omitempty" yaml:"levels,omitempty"`
86}86}
87 87
88// ModelRegistration tracks a model's availability88// ModelRegistration tracks a model's availability
internal/runtime/executor/codex_executor.go+10−4
@@ -28,8 +28,8 @@ import ( · 27 unmodified lines
28)28)
29 29
30const (30const (
31 codexClientVersion = "0.101.0"31 codexUserAgent = "codex_cli_rs/0.116.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"
32 codexUserAgent = "codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"32 codexOriginator = "codex_cli_rs"
33)33)
34 34
35var dataTag = []byte("data:")35var dataTag = []byte("data:")
@@ -645,8 +645,10 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s · 609 unmodified lines
645 ginHeaders = ginCtx.Request.Header645 ginHeaders = ginCtx.Request.Header
646 }646 }
647 647
648 misc.EnsureHeader(r.Header, ginHeaders, "Version", codexClientVersion)648 misc.EnsureHeader(r.Header, ginHeaders, "Version", "")
649 misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())649 misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
650 misc.EnsureHeader(r.Header, ginHeaders, "X-Codex-Turn-Metadata", "")
651 misc.EnsureHeader(r.Header, ginHeaders, "X-Client-Request-Id", "")
650 cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)652 cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
651 ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)653 ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
652 654
@@ -663,8 +665,12 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s · 10 unmodified lines
663 isAPIKey = true665 isAPIKey = true
664 }666 }
665 }667 }
668 if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
669 r.Header.Set("Originator", originator)
670 } else if !isAPIKey {
671 r.Header.Set("Originator", codexOriginator)
672 }
666 if !isAPIKey {673 if !isAPIKey {
667 r.Header.Set("Originator", "codex_cli_rs")
668 if auth != nil && auth.Metadata != nil {674 if auth != nil && auth.Metadata != nil {
669 if accountID, ok := auth.Metadata["account_id"].(string); ok {675 if accountID, ok := auth.Metadata["account_id"].(string); ok {
670 r.Header.Set("Chatgpt-Account-Id", accountID)676 r.Header.Set("Chatgpt-Account-Id", accountID)
internal/runtime/executor/codex_websockets_executor.go+7−2
@@ -814,9 +814,10 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * · 813 unmodified lines
814 ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "")814 ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "")
815 misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "")815 misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "")
816 misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "")816 misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "")
817 misc.EnsureHeader(headers, ginHeaders, "x-client-request-id", "")
817 misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "")818 misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "")
819 misc.EnsureHeader(headers, ginHeaders, "Version", "")
818 820
819 misc.EnsureHeader(headers, ginHeaders, "Version", codexClientVersion)
820 betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta"))821 betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta"))
821 if betaHeader == "" && ginHeaders != nil {822 if betaHeader == "" && ginHeaders != nil {
822 betaHeader = strings.TrimSpace(ginHeaders.Get("OpenAI-Beta"))823 betaHeader = strings.TrimSpace(ginHeaders.Get("OpenAI-Beta"))
@@ -834,8 +835,12 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * · 11 unmodified lines
834 isAPIKey = true835 isAPIKey = true
835 }836 }
836 }837 }
838 if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
839 headers.Set("Originator", originator)
840 } else if !isAPIKey {
841 headers.Set("Originator", codexOriginator)
842 }
837 if !isAPIKey {843 if !isAPIKey {
838 headers.Set("Originator", "codex_cli_rs")
839 if auth != nil && auth.Metadata != nil {844 if auth != nil && auth.Metadata != nil {
840 if accountID, ok := auth.Metadata["account_id"].(string); ok {845 if accountID, ok := auth.Metadata["account_id"].(string); ok {
841 if trimmed := strings.TrimSpace(accountID); trimmed != "" {846 if trimmed := strings.TrimSpace(accountID); trimmed != "" {
internal/runtime/executor/codex_websockets_executor_test.go+88−0
@@ -41,9 +41,46 @@ func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) · 40 unmodified lines
41 if got := headers.Get("User-Agent"); got != codexUserAgent {41 if got := headers.Get("User-Agent"); got != codexUserAgent {
42 t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)42 t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
43 }43 }
44 if got := headers.Get("Version"); got != "" {
45 t.Fatalf("Version = %q, want empty", got)
46 }
44 if got := headers.Get("x-codex-beta-features"); got != "" {47 if got := headers.Get("x-codex-beta-features"); got != "" {
45 t.Fatalf("x-codex-beta-features = %q, want empty", got)48 t.Fatalf("x-codex-beta-features = %q, want empty", got)
46 }49 }
50 if got := headers.Get("X-Codex-Turn-Metadata"); got != "" {
51 t.Fatalf("X-Codex-Turn-Metadata = %q, want empty", got)
52 }
53 if got := headers.Get("X-Client-Request-Id"); got != "" {
54 t.Fatalf("X-Client-Request-Id = %q, want empty", got)
55 }
56}
57
58func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing.T) {
59 auth := &cliproxyauth.Auth{
60 Provider: "codex",
61 Metadata: map[string]any{"email": "user@example.com"},
62 }
63 ctx := contextWithGinHeaders(map[string]string{
64 "Originator": "Codex Desktop",
65 "Version": "0.115.0-alpha.27",
66 "X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
67 "X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
68 })
69
70 headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil)
71
72 if got := headers.Get("Originator"); got != "Codex Desktop" {
73 t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
74 }
75 if got := headers.Get("Version"); got != "0.115.0-alpha.27" {
76 t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
77 }
78 if got := headers.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"turn-1"}` {
79 t.Fatalf("X-Codex-Turn-Metadata = %s, want %s", got, `{"turn_id":"turn-1"}`)
80 }
81 if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
82 t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
83 }
47}84}
48 85
49func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {86func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {
@@ -177,6 +214,57 @@ func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) { · 127 unmodified lines
177 }214 }
178}215}
179 216
217func TestApplyCodexHeadersPassesThroughClientIdentityHeaders(t *testing.T) {
218 req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
219 if err != nil {
220 t.Fatalf("NewRequest() error = %v", err)
221 }
222 auth := &cliproxyauth.Auth{
223 Provider: "codex",
224 Metadata: map[string]any{"email": "user@example.com"},
225 }
226 req = req.WithContext(contextWithGinHeaders(map[string]string{
227 "Originator": "Codex Desktop",
228 "Version": "0.115.0-alpha.27",
229 "X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
230 "X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
231 }))
232
233 applyCodexHeaders(req, auth, "oauth-token", true, nil)
234
235 if got := req.Header.Get("Originator"); got != "Codex Desktop" {
236 t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
237 }
238 if got := req.Header.Get("Version"); got != "0.115.0-alpha.27" {
239 t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
240 }
241 if got := req.Header.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"turn-1"}` {
242 t.Fatalf("X-Codex-Turn-Metadata = %s, want %s", got, `{"turn_id":"turn-1"}`)
243 }
244 if got := req.Header.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
245 t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
246 }
247}
248
249func TestApplyCodexHeadersDoesNotInjectClientOnlyHeadersByDefault(t *testing.T) {
250 req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
251 if err != nil {
252 t.Fatalf("NewRequest() error = %v", err)
253 }
254
255 applyCodexHeaders(req, nil, "oauth-token", true, nil)
256
257 if got := req.Header.Get("Version"); got != "" {
258 t.Fatalf("Version = %q, want empty", got)
259 }
260 if got := req.Header.Get("X-Codex-Turn-Metadata"); got != "" {
261 t.Fatalf("X-Codex-Turn-Metadata = %q, want empty", got)
262 }
263 if got := req.Header.Get("X-Client-Request-Id"); got != "" {
264 t.Fatalf("X-Client-Request-Id = %q, want empty", got)
265 }
266}
267
180func contextWithGinHeaders(headers map[string]string) context.Context {268func contextWithGinHeaders(headers map[string]string) context.Context {
181 gin.SetMode(gin.TestMode)269 gin.SetMode(gin.TestMode)
182 recorder := httptest.NewRecorder()270 recorder := httptest.NewRecorder()
internal/translator/antigravity/claude/antigravity_claude_request.go+44−44
@@ -104,59 +104,59 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 103 unmodified lines
104 104
105 // Always try cached signature first (more reliable than client-provided)105 // Always try cached signature first (more reliable than client-provided)
106 // Client may send stale or invalid signatures from different sessions106 // Client may send stale or invalid signatures from different sessions
107 signature := ""107 signature := ""
108 if thinkingText != "" {108 if thinkingText != "" {
109 if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {109 if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
110 signature = cachedSig110 signature = cachedSig
111 // log.Debugf("Using cached signature for thinking block")111 // log.Debugf("Using cached signature for thinking block")
112 }
112 }113 }
113 }
114 114
115 // Fallback to client signature only if cache miss and client signature is valid115 // Fallback to client signature only if cache miss and client signature is valid
116 if signature == "" {116 if signature == "" {
117 signatureResult := contentResult.Get("signature")117 signatureResult := contentResult.Get("signature")
118 clientSignature := ""118 clientSignature := ""
119 if signatureResult.Exists() && signatureResult.String() != "" {119 if signatureResult.Exists() && signatureResult.String() != "" {
120 arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)120 arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
121 if len(arrayClientSignatures) == 2 {121 if len(arrayClientSignatures) == 2 {
122 if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {122 if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
123 clientSignature = arrayClientSignatures[1]123 clientSignature = arrayClientSignatures[1]
124 }
124 }125 }
125 }126 }
127 if cache.HasValidSignature(modelName, clientSignature) {
128 signature = clientSignature
129 }
130 // log.Debugf("Using client-provided signature for thinking block")
126 }131 }
127 if cache.HasValidSignature(modelName, clientSignature) {
128 signature = clientSignature
129 }
130 // log.Debugf("Using client-provided signature for thinking block")
131 }
132 132
133 // Store for subsequent tool_use in the same message133 // Store for subsequent tool_use in the same message
134 if cache.HasValidSignature(modelName, signature) {134 if cache.HasValidSignature(modelName, signature) {
135 currentMessageThinkingSignature = signature135 currentMessageThinkingSignature = signature
136 }136 }
137 137
138 // Skip trailing unsigned thinking blocks on last assistant message138 // Skip trailing unsigned thinking blocks on last assistant message
139 isUnsigned := !cache.HasValidSignature(modelName, signature)139 isUnsigned := !cache.HasValidSignature(modelName, signature)
140 140
141 // If unsigned, skip entirely (don't convert to text)141 // If unsigned, skip entirely (don't convert to text)
142 // Claude requires assistant messages to start with thinking blocks when thinking is enabled142 // Claude requires assistant messages to start with thinking blocks when thinking is enabled
143 // Converting to text would break this requirement143 // Converting to text would break this requirement
144 if isUnsigned {144 if isUnsigned {
145 // log.Debugf("Dropping unsigned thinking block (no valid signature)")145 // log.Debugf("Dropping unsigned thinking block (no valid signature)")
146 enableThoughtTranslate = false146 enableThoughtTranslate = false
147 continue147 continue
148 }148 }
149 149
150 // Valid signature, send as thought block150 // Valid signature, send as thought block
151 // Always include "text" field — Google Antigravity API requires it151 // Always include "text" field — Google Antigravity API requires it
152 // even for redacted thinking where the text is empty.152 // even for redacted thinking where the text is empty.
153 partJSON := []byte(`{}`)153 partJSON := []byte(`{}`)
154 partJSON, _ = sjson.SetBytes(partJSON, "thought", true)154 partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
155 partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText)155 partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText)
156 if signature != "" {156 if signature != "" {
157 partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)157 partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
158 }158 }
159 clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts.-1", partJSON)159 clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts.-1", partJSON)
160 } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {160 } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
161 prompt := contentResult.Get("text").String()161 prompt := contentResult.Get("text").String()
162 // Skip empty text parts to avoid Gemini API error:162 // Skip empty text parts to avoid Gemini API error:
internal/translator/codex/openai/chat-completions/codex_openai_response.go+1−1
@@ -60,7 +60,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR · 59 unmodified lines
60 rawJSON = bytes.TrimSpace(rawJSON[5:])60 rawJSON = bytes.TrimSpace(rawJSON[5:])
61 61
62 // Initialize the OpenAI SSE template.62 // Initialize the OpenAI SSE template.
63 template := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)63 template := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{},"finish_reason":null,"native_finish_reason":null}]}`)
64 64
65 rootResult := gjson.ParseBytes(rawJSON)65 rootResult := gjson.ParseBytes(rawJSON)
66 66
internal/translator/codex/openai/chat-completions/codex_openai_response_test.go+45−0
@@ -45,3 +45,48 @@ func TestConvertCodexResponseToOpenAI_FirstChunkUsesRequestModelName(t *testing. · 44 unmodified lines
45 t.Fatalf("expected model %q, got %q", modelName, gotModel)45 t.Fatalf("expected model %q, got %q", modelName, gotModel)
46 }46 }
47}47}
48
49func TestConvertCodexResponseToOpenAI_ToolCallChunkOmitsNullContentFields(t *testing.T) {
50 ctx := context.Background()
51 var param any
52
53 out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_123","name":"websearch"}}`), &param)
54 if len(out) != 1 {
55 t.Fatalf("expected 1 chunk, got %d", len(out))
56 }
57
58 if gjson.GetBytes(out[0], "choices.0.delta.content").Exists() {
59 t.Fatalf("expected content to be omitted, got %s", string(out[0]))
60 }
61 if gjson.GetBytes(out[0], "choices.0.delta.reasoning_content").Exists() {
62 t.Fatalf("expected reasoning_content to be omitted, got %s", string(out[0]))
63 }
64 if !gjson.GetBytes(out[0], "choices.0.delta.tool_calls").Exists() {
65 t.Fatalf("expected tool_calls to exist, got %s", string(out[0]))
66 }
67}
68
69func TestConvertCodexResponseToOpenAI_ToolCallArgumentsDeltaOmitsNullContentFields(t *testing.T) {
70 ctx := context.Background()
71 var param any
72
73 out := ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_123","name":"websearch"}}`), &param)
74 if len(out) != 1 {
75 t.Fatalf("expected tool call announcement chunk, got %d", len(out))
76 }
77
78 out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"query\":\"OpenAI\"}"}`), &param)
79 if len(out) != 1 {
80 t.Fatalf("expected 1 chunk, got %d", len(out))
81 }
82
83 if gjson.GetBytes(out[0], "choices.0.delta.content").Exists() {
84 t.Fatalf("expected content to be omitted, got %s", string(out[0]))
85 }
86 if gjson.GetBytes(out[0], "choices.0.delta.reasoning_content").Exists() {
87 t.Fatalf("expected reasoning_content to be omitted, got %s", string(out[0]))
88 }
89 if !gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").Exists() {
90 t.Fatalf("expected tool call arguments delta to exist, got %s", string(out[0]))
91 }
92}
sdk/cliproxy/service.go+6−1
@@ -1006,6 +1006,10 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 1005 unmodified lines
1006 if modelID == "" {1006 if modelID == "" {
1007 modelID = m.Name1007 modelID = m.Name
1008 }1008 }
1009 thinking := m.Thinking
1010 if thinking == nil {
1011 thinking = &registry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}
1012 }
1009 ms = append(ms, &ModelInfo{1013 ms = append(ms, &ModelInfo{
1010 ID: modelID,1014 ID: modelID,
1011 Object: "model",1015 Object: "model",
@@ -1013,7 +1017,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 1 unmodified line
1013 OwnedBy: compat.Name,1017 OwnedBy: compat.Name,
1014 Type: "openai-compatibility",1018 Type: "openai-compatibility",
1015 DisplayName: modelID,1019 DisplayName: modelID,
1016 UserDefined: true,1020 UserDefined: false,
1021 Thinking: thinking,
1017 })1022 })
1018 }1023 }
1019 // Register and return1024 // Register and return