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
5858
5959 ## はじめに
6060
61−CLIProxyAPIガイド:[https://help.router-for.me/ja/](https://help.router-for.me/ja/)
61+CLIProxyAPIガイド:[https://help.router-for.me/](https://help.router-for.me/)
6262
6363 ## 管理API
6464
65−[MANAGEMENT_API.md](https://help.router-for.me/ja/management/api)を参照
65+[MANAGEMENT_API.md](https://help.router-for.me/management/api)を参照
6666
6767 ## Amp CLIサポート
6868
@@ -74,7 +74,7 @@ CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統 · 5 unmodified lines
7474 - 利用できないモデルを代替モデルにルーティングする**モデルマッピング**(例:`claude-opus-4.5` → `claude-sonnet-4`)
7575 - localhostのみの管理エンドポイントによるセキュリティファーストの設計
7676
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)**
7878
7979 ## SDKドキュメント
8080
config.example.yaml+3−1
@@ -238,7 +238,9 @@ nonstream-keepalive-interval: 0 · 237 unmodified lines
238238 # - api-key: "sk-or-v1-...b781" # without proxy-url
239239 # models: # The models supported by the provider.
240240 # - 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"]
242244 # # You may repeat the same alias to build an internal model pool.
243245 # # The client still sees only one alias in the model list.
244246 # # 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
1111 "errors"
1212 "fmt"
1313 "io"
14+ "mime/multipart"
1415 "net"
1516 "net/http"
1617 "net/url"
@@ -66,8 +67,10 @@ type callbackForwarder struct { · 49 unmodified lines
6667 }
6768
6869 var (
69− callbackForwardersMu sync.Mutex
70− callbackForwarders = make(map[int]*callbackForwarder)
70+ callbackForwardersMu sync.Mutex
71+ callbackForwarders = make(map[int]*callbackForwarder)
72+ errAuthFileMustBeJSON = errors.New("auth file must be .json")
73+ errAuthFileNotFound = errors.New("auth file not found")
7174 )
7275
7376 func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
@@ -579,32 +582,57 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { · 505 unmodified lines
579582 return
580583 }
581584 ctx := c.Request.Context()
582− if file, err := c.FormFile("file"); err == nil && file != nil {
583− name := filepath.Base(file.Filename)
584− if !strings.HasSuffix(strings.ToLower(name), ".json") {
585− c.JSON(400, gin.H{"error": "file must be .json"})
586− return
587− }
588− dst := filepath.Join(h.cfg.AuthDir, name)
589− if !filepath.IsAbs(dst) {
590− if abs, errAbs := filepath.Abs(dst); errAbs == nil {
591− dst = abs
585+
586+ fileHeaders, errMultipart := h.multipartAuthFileHeaders(c)
587+ if errMultipart != nil {
588+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid multipart form: %v", errMultipart)})
589+ return
590+ }
591+ if len(fileHeaders) == 1 {
592+ if _, errUpload := h.storeUploadedAuthFile(ctx, fileHeaders[0]); errUpload != nil {
593+ if errors.Is(errUpload, errAuthFileMustBeJSON) {
594+ c.JSON(http.StatusBadRequest, gin.H{"error": "file must be .json"})
595+ return
592596 }
593− }
594− if errSave := c.SaveUploadedFile(file, dst); errSave != nil {
595− c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
597+ c.JSON(http.StatusInternalServerError, gin.H{"error": errUpload.Error()})
596598 return
597599 }
598− data, errRead := os.ReadFile(dst)
599− if errRead != nil {
600− c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})
601− return
600+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
601+ return
602+ }
603+ 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)
602621 }
603− if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {
604− c.JSON(500, gin.H{"error": errReg.Error()})
622+ if len(failed) > 0 {
623+ c.JSON(http.StatusMultiStatus, gin.H{
624+ "status": "partial",
625+ "uploaded": len(uploaded),
626+ "files": uploaded,
627+ "failed": failed,
628+ })
605629 return
606630 }
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"})
608636 return
609637 }
610638 name := c.Query("name")
@@ -621,17 +649,7 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { · 10 unmodified lines
621649 c.JSON(400, gin.H{"error": "failed to read body"})
622650 return
623651 }
624− dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
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 {
652+ if err = h.writeAuthFile(ctx, filepath.Base(name), data); err != nil {
635653 c.JSON(500, gin.H{"error": err.Error()})
636654 return
637655 }
@@ -678,11 +696,182 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { · 40 unmodified lines
678696 c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
679697 return
680698 }
681− name := c.Query("name")
682− if name == "" || strings.Contains(name, string(os.PathSeparator)) {
699+
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 {
683706 c.JSON(400, gin.H{"error": "invalid name"})
684707 return
685708 }
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+
740+func (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+
765+func (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+
789+func (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+
809+func 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+
850+func 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+
870+func (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+ }
686875
687876 targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
688877 targetID := ""
@@ -699,22 +888,19 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { · 10 unmodified lines
699888 }
700889 if errRemove := os.Remove(targetPath); errRemove != nil {
701890 if os.IsNotExist(errRemove) {
702− c.JSON(404, gin.H{"error": "file not found"})
703− } else {
704− c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", errRemove)})
891+ return filepath.Base(name), http.StatusNotFound, errAuthFileNotFound
705892 }
706− return
893+ return filepath.Base(name), http.StatusInternalServerError, fmt.Errorf("failed to remove file: %w", errRemove)
707894 }
708895 if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {
709− c.JSON(500, gin.H{"error": errDeleteRecord.Error()})
710− return
896+ return filepath.Base(name), http.StatusInternalServerError, errDeleteRecord
711897 }
712898 if targetID != "" {
713899 h.disableAuth(ctx, targetID)
714900 } else {
715901 h.disableAuth(ctx, targetPath)
716902 }
717− c.JSON(200, gin.H{"status": "ok"})
903+ return filepath.Base(name), http.StatusOK, nil
718904 }
719905
720906 func (h *Handler) findAuthForDelete(name string) *coreauth.Auth {
@@ -783,19 +969,27 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data [] · 62 unmodified lines
783969 if h.authManager == nil {
784970 return nil
785971 }
972+ auth, err := h.buildAuthFromFileData(path, data)
973+ if err != nil {
974+ return err
975+ }
976+ return h.upsertAuthRecord(ctx, auth)
977+}
978+
979+func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Auth, error) {
786980 if path == "" {
787− return fmt.Errorf("auth path is empty")
981+ return nil, fmt.Errorf("auth path is empty")
788982 }
789983 if data == nil {
790984 var err error
791985 data, err = os.ReadFile(path)
792986 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)
794988 }
795989 }
796990 metadata := make(map[string]any)
797991 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)
799993 }
800994 provider, _ := metadata["type"].(string)
801995 if provider == "" {
@@ -829,13 +1023,25 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data [] · 27 unmodified lines
8291023 if hasLastRefresh {
8301024 auth.LastRefreshedAt = lastRefresh
8311025 }
832− if existing, ok := h.authManager.GetByID(authID); ok {
833− auth.CreatedAt = existing.CreatedAt
834− if !hasLastRefresh {
835− auth.LastRefreshedAt = existing.LastRefreshedAt
1026+ if h != nil && h.authManager != nil {
1027+ if existing, ok := h.authManager.GetByID(authID); ok {
1028+ auth.CreatedAt = existing.CreatedAt
1029+ if !hasLastRefresh {
1030+ auth.LastRefreshedAt = existing.LastRefreshedAt
1031+ }
1032+ auth.NextRefreshAfter = existing.NextRefreshAfter
1033+ auth.Runtime = existing.Runtime
8361034 }
837− auth.NextRefreshAfter = existing.NextRefreshAfter
838− auth.Runtime = existing.Runtime
1035+ }
1036+ return auth, nil
1037+}
1038+
1039+func (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
8391045 _, err := h.authManager.Update(ctx, auth)
8401046 return err
8411047 }
internal/api/handlers/management/auth_files_batch_test.go+197−0
@@ -0,0 +1,197 @@
1+package management
2+
3+import (
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+
19+func 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+
87+func 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+
153+func 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
1313 "strings"
1414 "syscall"
1515
16+ "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
1617 log "github.com/sirupsen/logrus"
1718 "golang.org/x/crypto/bcrypt"
1819 "gopkg.in/yaml.v3"
@@ -574,6 +575,10 @@ type OpenAICompatibilityModel struct { · 555 unmodified lines
574575
575576 // Alias is the model name alias that clients will use to reference this model.
576577 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"`
577582 }
578583
579584 func (m OpenAICompatibilityModel) GetName() string { return m.Name }
internal/registry/model_registry.go+5−5
@@ -73,16 +73,16 @@ type availableModelsCacheEntry struct { · 72 unmodified lines
7373 // Values are interpreted in provider-native token units.
7474 type ThinkingSupport struct {
7575 // Min is the minimum allowed thinking budget (inclusive).
76− Min int `json:"min,omitempty"`
76+ Min int `json:"min,omitempty" yaml:"min,omitempty"`
7777 // Max is the maximum allowed thinking budget (inclusive).
78− Max int `json:"max,omitempty"`
78+ Max int `json:"max,omitempty" yaml:"max,omitempty"`
7979 // 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"`
8181 // 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"`
8383 // Levels defines discrete reasoning effort levels (e.g., "low", "medium", "high").
8484 // 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"`
8686 }
8787
8888 // ModelRegistration tracks a model's availability
internal/runtime/executor/codex_executor.go+10−4
@@ -28,8 +28,8 @@ import ( · 27 unmodified lines
2828 )
2929
3030 const (
31− codexClientVersion = "0.101.0"
32− codexUserAgent = "codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"
31+ codexUserAgent = "codex_cli_rs/0.116.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"
32+ codexOriginator = "codex_cli_rs"
3333 )
3434
3535 var dataTag = []byte("data:")
@@ -645,8 +645,10 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s · 609 unmodified lines
645645 ginHeaders = ginCtx.Request.Header
646646 }
647647
648− misc.EnsureHeader(r.Header, ginHeaders, "Version", codexClientVersion)
648+ misc.EnsureHeader(r.Header, ginHeaders, "Version", "")
649649 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", "")
650652 cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
651653 ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
652654
@@ -663,8 +665,12 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s · 10 unmodified lines
663665 isAPIKey = true
664666 }
665667 }
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+ }
666673 if !isAPIKey {
667− r.Header.Set("Originator", "codex_cli_rs")
668674 if auth != nil && auth.Metadata != nil {
669675 if accountID, ok := auth.Metadata["account_id"].(string); ok {
670676 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
814814 ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "")
815815 misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "")
816816 misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "")
817+ misc.EnsureHeader(headers, ginHeaders, "x-client-request-id", "")
817818 misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "")
819+ misc.EnsureHeader(headers, ginHeaders, "Version", "")
818820
819− misc.EnsureHeader(headers, ginHeaders, "Version", codexClientVersion)
820821 betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta"))
821822 if betaHeader == "" && ginHeaders != nil {
822823 betaHeader = strings.TrimSpace(ginHeaders.Get("OpenAI-Beta"))
@@ -834,8 +835,12 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * · 11 unmodified lines
834835 isAPIKey = true
835836 }
836837 }
838+ if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
839+ headers.Set("Originator", originator)
840+ } else if !isAPIKey {
841+ headers.Set("Originator", codexOriginator)
842+ }
837843 if !isAPIKey {
838− headers.Set("Originator", "codex_cli_rs")
839844 if auth != nil && auth.Metadata != nil {
840845 if accountID, ok := auth.Metadata["account_id"].(string); ok {
841846 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
4141 if got := headers.Get("User-Agent"); got != codexUserAgent {
4242 t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
4343 }
44+ if got := headers.Get("Version"); got != "" {
45+ t.Fatalf("Version = %q, want empty", got)
46+ }
4447 if got := headers.Get("x-codex-beta-features"); got != "" {
4548 t.Fatalf("x-codex-beta-features = %q, want empty", got)
4649 }
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+
58+func 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+ }
4784 }
4885
4986 func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {
@@ -177,6 +214,57 @@ func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) { · 127 unmodified lines
177214 }
178215 }
179216
217+func 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+
249+func 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+
180268 func contextWithGinHeaders(headers map[string]string) context.Context {
181269 gin.SetMode(gin.TestMode)
182270 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
104104
105105 // Always try cached signature first (more reliable than client-provided)
106106 // Client may send stale or invalid signatures from different sessions
107− signature := ""
108− if thinkingText != "" {
109− if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
110− signature = cachedSig
111− // log.Debugf("Using cached signature for thinking block")
107+ signature := ""
108+ if thinkingText != "" {
109+ if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
110+ signature = cachedSig
111+ // log.Debugf("Using cached signature for thinking block")
112+ }
112113 }
113− }
114114
115− // Fallback to client signature only if cache miss and client signature is valid
116− if signature == "" {
117− signatureResult := contentResult.Get("signature")
118− clientSignature := ""
119− if signatureResult.Exists() && signatureResult.String() != "" {
120− arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
121− if len(arrayClientSignatures) == 2 {
122− if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
123− clientSignature = arrayClientSignatures[1]
115+ // Fallback to client signature only if cache miss and client signature is valid
116+ if signature == "" {
117+ signatureResult := contentResult.Get("signature")
118+ clientSignature := ""
119+ if signatureResult.Exists() && signatureResult.String() != "" {
120+ arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
121+ if len(arrayClientSignatures) == 2 {
122+ if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
123+ clientSignature = arrayClientSignatures[1]
124+ }
124125 }
125126 }
127+ if cache.HasValidSignature(modelName, clientSignature) {
128+ signature = clientSignature
129+ }
130+ // log.Debugf("Using client-provided signature for thinking block")
126131 }
127− if cache.HasValidSignature(modelName, clientSignature) {
128− signature = clientSignature
129− }
130− // log.Debugf("Using client-provided signature for thinking block")
131− }
132132
133− // Store for subsequent tool_use in the same message
134− if cache.HasValidSignature(modelName, signature) {
135− currentMessageThinkingSignature = signature
136− }
133+ // Store for subsequent tool_use in the same message
134+ if cache.HasValidSignature(modelName, signature) {
135+ currentMessageThinkingSignature = signature
136+ }
137137
138− // Skip trailing unsigned thinking blocks on last assistant message
139− isUnsigned := !cache.HasValidSignature(modelName, signature)
138+ // Skip trailing unsigned thinking blocks on last assistant message
139+ isUnsigned := !cache.HasValidSignature(modelName, signature)
140140
141− // If unsigned, skip entirely (don't convert to text)
142− // Claude requires assistant messages to start with thinking blocks when thinking is enabled
143− // Converting to text would break this requirement
144− if isUnsigned {
145− // log.Debugf("Dropping unsigned thinking block (no valid signature)")
146− enableThoughtTranslate = false
147− continue
148− }
141+ // If unsigned, skip entirely (don't convert to text)
142+ // Claude requires assistant messages to start with thinking blocks when thinking is enabled
143+ // Converting to text would break this requirement
144+ if isUnsigned {
145+ // log.Debugf("Dropping unsigned thinking block (no valid signature)")
146+ enableThoughtTranslate = false
147+ continue
148+ }
149149
150− // Valid signature, send as thought block
151− // Always include "text" field — Google Antigravity API requires it
152− // even for redacted thinking where the text is empty.
153− partJSON := []byte(`{}`)
154− partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
155− partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText)
156− if signature != "" {
157− partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
158− }
159− clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts.-1", partJSON)
150+ // Valid signature, send as thought block
151+ // Always include "text" field — Google Antigravity API requires it
152+ // even for redacted thinking where the text is empty.
153+ partJSON := []byte(`{}`)
154+ partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
155+ partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText)
156+ if signature != "" {
157+ partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
158+ }
159+ clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts.-1", partJSON)
160160 } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
161161 prompt := contentResult.Get("text").String()
162162 // 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
6060 rawJSON = bytes.TrimSpace(rawJSON[5:])
6161
6262 // 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}]}`)
6464
6565 rootResult := gjson.ParseBytes(rawJSON)
6666
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
4545 t.Fatalf("expected model %q, got %q", modelName, gotModel)
4646 }
4747 }
48+
49+func 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+
69+func 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
10061006 if modelID == "" {
10071007 modelID = m.Name
10081008 }
1009+ thinking := m.Thinking
1010+ if thinking == nil {
1011+ thinking = &registry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}
1012+ }
10091013 ms = append(ms, &ModelInfo{
10101014 ID: modelID,
10111015 Object: "model",
@@ -1013,7 +1017,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 1 unmodified line
10131017 OwnedBy: compat.Name,
10141018 Type: "openai-compatibility",
10151019 DisplayName: modelID,
1016− UserDefined: true,
1020+ UserDefined: false,
1021+ Thinking: thinking,
10171022 })
10181023 }
10191024 // Register and return