Unified · Split
| @@ -58,11 +58,11 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB · 57 unmodified lines | ||
| 58 | 58 | |
| 59 | 59 | ## はじめに |
| 60 | 60 | |
| 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/) | |
| 62 | 62 | |
| 63 | 63 | ## 管理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 | 67 | ## Amp CLIサポート |
| 68 | 68 | |
| @@ -74,7 +74,7 @@ CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統 · 5 unmodified lines | ||
| 74 | 74 | - 利用できないモデルを代替モデルにルーティングする**モデルマッピング**(例:`claude-opus-4.5` → `claude-sonnet-4`) |
| 75 | 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 | 79 | ## SDKドキュメント |
| 80 | 80 | |
| @@ -238,7 +238,9 @@ nonstream-keepalive-interval: 0 · 237 unmodified lines | ||
| 238 | 238 | # - api-key: "sk-or-v1-...b781" # without proxy-url |
| 239 | 239 | # models: # The models supported by the provider. |
| 240 | 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 | 244 | # # You may repeat the same alias to build an internal model pool. |
| 243 | 245 | # # The client still sees only one alias in the model list. |
| 244 | 246 | # # Requests to that alias will round-robin across the upstream names below, |
| @@ -11,6 +11,7 @@ import ( · 10 unmodified lines | ||
| 11 | 11 | "errors" |
| 12 | 12 | "fmt" |
| 13 | 13 | "io" |
| 14 | + "mime/multipart" | |
| 14 | 15 | "net" |
| 15 | 16 | "net/http" |
| 16 | 17 | "net/url" |
| @@ -66,8 +67,10 @@ type callbackForwarder struct { · 49 unmodified lines | ||
| 66 | 67 | } |
| 67 | 68 | |
| 68 | 69 | 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") | |
| 71 | 74 | ) |
| 72 | 75 | |
| 73 | 76 | func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) { |
| @@ -579,32 +582,57 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { · 505 unmodified lines | ||
| 579 | 582 | return |
| 580 | 583 | } |
| 581 | 584 | 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 | |
| 592 | 596 | } |
| 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()}) | |
| 596 | 598 | return |
| 597 | 599 | } |
| 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) | |
| 602 | 621 | } |
| 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 | + }) | |
| 605 | 629 | 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 | 636 | return |
| 609 | 637 | } |
| 610 | 638 | name := c.Query("name") |
| @@ -621,17 +649,7 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { · 10 unmodified lines | ||
| 621 | 649 | c.JSON(400, gin.H{"error": "failed to read body"}) |
| 622 | 650 | return |
| 623 | 651 | } |
| 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 { | |
| 635 | 653 | c.JSON(500, gin.H{"error": err.Error()}) |
| 636 | 654 | return |
| 637 | 655 | } |
| @@ -678,11 +696,182 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { · 40 unmodified lines | ||
| 678 | 696 | c.JSON(200, gin.H{"status": "ok", "deleted": deleted}) |
| 679 | 697 | return |
| 680 | 698 | } |
| 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 { | |
| 683 | 706 | c.JSON(400, gin.H{"error": "invalid name"}) |
| 684 | 707 | 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 | + | |
| 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 | + } | |
| 686 | 875 | |
| 687 | 876 | targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name)) |
| 688 | 877 | targetID := "" |
| @@ -699,22 +888,19 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { · 10 unmodified lines | ||
| 699 | 888 | } |
| 700 | 889 | if errRemove := os.Remove(targetPath); errRemove != nil { |
| 701 | 890 | 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 | |
| 705 | 892 | } |
| 706 | − return | |
| 893 | + return filepath.Base(name), http.StatusInternalServerError, fmt.Errorf("failed to remove file: %w", errRemove) | |
| 707 | 894 | } |
| 708 | 895 | 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 | |
| 711 | 897 | } |
| 712 | 898 | if targetID != "" { |
| 713 | 899 | h.disableAuth(ctx, targetID) |
| 714 | 900 | } else { |
| 715 | 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 | |
| 720 | 906 | 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 | ||
| 783 | 969 | if h.authManager == nil { |
| 784 | 970 | 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 | + | |
| 979 | +func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Auth, error) { | |
| 786 | 980 | if path == "" { |
| 787 | − return fmt.Errorf("auth path is empty") | |
| 981 | + return nil, fmt.Errorf("auth path is empty") | |
| 788 | 982 | } |
| 789 | 983 | if data == nil { |
| 790 | 984 | var err error |
| 791 | 985 | data, err = os.ReadFile(path) |
| 792 | 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 | 990 | metadata := make(map[string]any) |
| 797 | 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 | 994 | provider, _ := metadata["type"].(string) |
| 801 | 995 | if provider == "" { |
| @@ -829,13 +1023,25 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data [] · 27 unmodified lines | ||
| 829 | 1023 | if hasLastRefresh { |
| 830 | 1024 | auth.LastRefreshedAt = lastRefresh |
| 831 | 1025 | } |
| 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 | |
| 836 | 1034 | } |
| 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 | |
| 839 | 1045 | _, err := h.authManager.Update(ctx, auth) |
| 840 | 1046 | return err |
| 841 | 1047 | } |
| @@ -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 | +} | |
| @@ -13,6 +13,7 @@ import ( · 12 unmodified lines | ||
| 13 | 13 | "strings" |
| 14 | 14 | "syscall" |
| 15 | 15 | |
| 16 | + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" | |
| 16 | 17 | log "github.com/sirupsen/logrus" |
| 17 | 18 | "golang.org/x/crypto/bcrypt" |
| 18 | 19 | "gopkg.in/yaml.v3" |
| @@ -574,6 +575,10 @@ type OpenAICompatibilityModel struct { · 555 unmodified lines | ||
| 574 | 575 | |
| 575 | 576 | // Alias is the model name alias that clients will use to reference this model. |
| 576 | 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 | |
| 579 | 584 | func (m OpenAICompatibilityModel) GetName() string { return m.Name } |
| @@ -73,16 +73,16 @@ type availableModelsCacheEntry struct { · 72 unmodified lines | ||
| 73 | 73 | // Values are interpreted in provider-native token units. |
| 74 | 74 | type ThinkingSupport struct { |
| 75 | 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 | 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 | 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 | 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 | 83 | // Levels defines discrete reasoning effort levels (e.g., "low", "medium", "high"). |
| 84 | 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 | 88 | // ModelRegistration tracks a model's availability |
| @@ -28,8 +28,8 @@ import ( · 27 unmodified lines | ||
| 28 | 28 | ) |
| 29 | 29 | |
| 30 | 30 | 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" | |
| 33 | 33 | ) |
| 34 | 34 | |
| 35 | 35 | var dataTag = []byte("data:") |
| @@ -645,8 +645,10 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s · 609 unmodified lines | ||
| 645 | 645 | ginHeaders = ginCtx.Request.Header |
| 646 | 646 | } |
| 647 | 647 | |
| 648 | − misc.EnsureHeader(r.Header, ginHeaders, "Version", codexClientVersion) | |
| 648 | + misc.EnsureHeader(r.Header, ginHeaders, "Version", "") | |
| 649 | 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 | 652 | cfgUserAgent, _ := codexHeaderDefaults(cfg, auth) |
| 651 | 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 | 665 | 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 | 673 | if !isAPIKey { |
| 667 | − r.Header.Set("Originator", "codex_cli_rs") | |
| 668 | 674 | if auth != nil && auth.Metadata != nil { |
| 669 | 675 | if accountID, ok := auth.Metadata["account_id"].(string); ok { |
| 670 | 676 | r.Header.Set("Chatgpt-Account-Id", accountID) |
| @@ -814,9 +814,10 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth * · 813 unmodified lines | ||
| 814 | 814 | ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "") |
| 815 | 815 | misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "") |
| 816 | 816 | misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "") |
| 817 | + misc.EnsureHeader(headers, ginHeaders, "x-client-request-id", "") | |
| 817 | 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 | 821 | betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta")) |
| 821 | 822 | if betaHeader == "" && ginHeaders != nil { |
| 822 | 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 | 835 | 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 | 843 | if !isAPIKey { |
| 838 | − headers.Set("Originator", "codex_cli_rs") | |
| 839 | 844 | if auth != nil && auth.Metadata != nil { |
| 840 | 845 | if accountID, ok := auth.Metadata["account_id"].(string); ok { |
| 841 | 846 | if trimmed := strings.TrimSpace(accountID); trimmed != "" { |
| @@ -41,9 +41,46 @@ func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T) · 40 unmodified lines | ||
| 41 | 41 | if got := headers.Get("User-Agent"); got != codexUserAgent { |
| 42 | 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 | 47 | if got := headers.Get("x-codex-beta-features"); got != "" { |
| 45 | 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 | + | |
| 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 | + } | |
| 47 | 84 | } |
| 48 | 85 | |
| 49 | 86 | func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) { |
| @@ -177,6 +214,57 @@ func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) { · 127 unmodified lines | ||
| 177 | 214 | } |
| 178 | 215 | } |
| 179 | 216 | |
| 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 | + | |
| 180 | 268 | func contextWithGinHeaders(headers map[string]string) context.Context { |
| 181 | 269 | gin.SetMode(gin.TestMode) |
| 182 | 270 | recorder := httptest.NewRecorder() |
| @@ -104,59 +104,59 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ · 103 unmodified lines | ||
| 104 | 104 | |
| 105 | 105 | // Always try cached signature first (more reliable than client-provided) |
| 106 | 106 | // 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 | + } | |
| 112 | 113 | } |
| 113 | − } | |
| 114 | 114 | |
| 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 | + } | |
| 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 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 | + } | |
| 137 | 137 | |
| 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) | |
| 140 | 140 | |
| 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 | + } | |
| 149 | 149 | |
| 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) | |
| 160 | 160 | } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" { |
| 161 | 161 | prompt := contentResult.Get("text").String() |
| 162 | 162 | // Skip empty text parts to avoid Gemini API error: |
| @@ -60,7 +60,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR · 59 unmodified lines | ||
| 60 | 60 | rawJSON = bytes.TrimSpace(rawJSON[5:]) |
| 61 | 61 | |
| 62 | 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 | 65 | rootResult := gjson.ParseBytes(rawJSON) |
| 66 | 66 | |
| @@ -45,3 +45,48 @@ func TestConvertCodexResponseToOpenAI_FirstChunkUsesRequestModelName(t *testing. · 44 unmodified lines | ||
| 45 | 45 | t.Fatalf("expected model %q, got %q", modelName, gotModel) |
| 46 | 46 | } |
| 47 | 47 | } |
| 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"}}`), ¶m) | |
| 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"}}`), ¶m) | |
| 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\"}"}`), ¶m) | |
| 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 | +} | |
| @@ -1006,6 +1006,10 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 1005 unmodified lines | ||
| 1006 | 1006 | if modelID == "" { |
| 1007 | 1007 | modelID = m.Name |
| 1008 | 1008 | } |
| 1009 | + thinking := m.Thinking | |
| 1010 | + if thinking == nil { | |
| 1011 | + thinking = ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}} | |
| 1012 | + } | |
| 1009 | 1013 | ms = append(ms, &ModelInfo{ |
| 1010 | 1014 | ID: modelID, |
| 1011 | 1015 | Object: "model", |
| @@ -1013,7 +1017,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { · 1 unmodified line | ||
| 1013 | 1017 | OwnedBy: compat.Name, |
| 1014 | 1018 | Type: "openai-compatibility", |
| 1015 | 1019 | DisplayName: modelID, |
| 1016 | − UserDefined: true, | |
| 1020 | + UserDefined: false, | |
| 1021 | + Thinking: thinking, | |
| 1017 | 1022 | }) |
| 1018 | 1023 | } |
| 1019 | 1024 | // Register and return |