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 | ## 管理API | 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 | ## 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 | ||
| @@ -238,7 +238,9 @@ nonstream-keepalive-interval: 0 · 237 unmodified lines | |||
| 238 | # - api-key: "sk-or-v1-...b781" # without proxy-url | 238 | # - 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, |
| @@ -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 | ||
| 68 | var ( | 69 | var ( |
| 69 | callbackForwardersMu sync.Mutex | 70 | 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 | ||
| 73 | func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) { | 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 | return | 582 | 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 | return | 589 | 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 = abs | 594 | 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 | return | 598 | 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 | return | 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 { | 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 | return | 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 | return | 636 | 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 | return | 650 | 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 | return | 654 | 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 | return | 697 | 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 | return | 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 | 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 | return | 893 | 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 | ||
| 720 | func (h *Handler) findAuthForDelete(name string) *coreauth.Auth { | 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 | if h.authManager == nil { | 969 | if h.authManager == nil { |
| 784 | return nil | 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 | 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 error | 984 | 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 = lastRefresh | 1024 | 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.CreatedAt | 1027 | if existing, ok := h.authManager.GetByID(authID); ok { |
| 834 | if !hasLastRefresh { | 1028 | auth.CreatedAt = existing.CreatedAt |
| 835 | auth.LastRefreshedAt = existing.LastRefreshedAt | 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 | 1035 | } |
| 838 | auth.Runtime = existing.Runtime | 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 | _, err := h.authManager.Update(ctx, auth) | 1045 | _, err := h.authManager.Update(ctx, auth) |
| 840 | return err | 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 | "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 | ||
| 579 | func (m OpenAICompatibilityModel) GetName() string { return m.Name } | 584 | func (m OpenAICompatibilityModel) GetName() string { return m.Name } |
| @@ -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. |
| 74 | type ThinkingSupport struct { | 74 | type 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 availability | 88 | // ModelRegistration tracks a model's availability |
| @@ -28,8 +28,8 @@ import ( · 27 unmodified lines | |||
| 28 | ) | 28 | ) |
| 29 | 29 | ||
| 30 | const ( | 30 | const ( |
| 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 | ||
| 35 | var dataTag = []byte("data:") | 35 | var 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.Header | 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 | 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 = true | 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 | 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) |
| @@ -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 = true | 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 | 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 != "" { |
| @@ -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 | |||
| 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 | func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) { | 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 | func contextWithGinHeaders(headers map[string]string) context.Context { | 268 | func 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() |
| @@ -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 sessions | 106 | // 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 = cachedSig | 110 | 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 valid | 115 | // 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 message | 133 | // Store for subsequent tool_use in the same message |
| 134 | if cache.HasValidSignature(modelName, signature) { | 134 | if cache.HasValidSignature(modelName, signature) { |
| 135 | currentMessageThinkingSignature = signature | 135 | currentMessageThinkingSignature = signature |
| 136 | } | 136 | } |
| 137 | 137 | ||
| 138 | // Skip trailing unsigned thinking blocks on last assistant message | 138 | // 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 enabled | 142 | // Claude requires assistant messages to start with thinking blocks when thinking is enabled |
| 143 | // Converting to text would break this requirement | 143 | // 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 = false | 146 | enableThoughtTranslate = false |
| 147 | continue | 147 | continue |
| 148 | } | 148 | } |
| 149 | 149 | ||
| 150 | // Valid signature, send as thought block | 150 | // Valid signature, send as thought block |
| 151 | // Always include "text" field — Google Antigravity API requires it | 151 | // 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: |
| @@ -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 | ||
| @@ -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 | |||
| 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 | if modelID == "" { | 1006 | if modelID == "" { |
| 1007 | modelID = m.Name | 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 | 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 return | 1024 | // Register and return |