mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-24 10:10:28 +00:00
Merge branch 'router-for-me:main' into main
This commit is contained in:
@@ -58,11 +58,11 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB
|
|||||||
|
|
||||||
## はじめに
|
## はじめに
|
||||||
|
|
||||||
CLIProxyAPIガイド:[https://help.router-for.me/ja/](https://help.router-for.me/ja/)
|
CLIProxyAPIガイド:[https://help.router-for.me/](https://help.router-for.me/)
|
||||||
|
|
||||||
## 管理API
|
## 管理API
|
||||||
|
|
||||||
[MANAGEMENT_API.md](https://help.router-for.me/ja/management/api)を参照
|
[MANAGEMENT_API.md](https://help.router-for.me/management/api)を参照
|
||||||
|
|
||||||
## Amp CLIサポート
|
## Amp CLIサポート
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ CLIProxyAPIは[Amp CLI](https://ampcode.com)およびAmp IDE拡張機能の統
|
|||||||
- 利用できないモデルを代替モデルにルーティングする**モデルマッピング**(例:`claude-opus-4.5` → `claude-sonnet-4`)
|
- 利用できないモデルを代替モデルにルーティングする**モデルマッピング**(例:`claude-opus-4.5` → `claude-sonnet-4`)
|
||||||
- localhostのみの管理エンドポイントによるセキュリティファーストの設計
|
- localhostのみの管理エンドポイントによるセキュリティファーストの設計
|
||||||
|
|
||||||
**→ [Amp CLI統合ガイドの完全版](https://help.router-for.me/ja/agent-client/amp-cli.html)**
|
**→ [Amp CLI統合ガイドの完全版](https://help.router-for.me/agent-client/amp-cli.html)**
|
||||||
|
|
||||||
## SDKドキュメント
|
## SDKドキュメント
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,9 @@ nonstream-keepalive-interval: 0
|
|||||||
# - api-key: "sk-or-v1-...b781" # without proxy-url
|
# - api-key: "sk-or-v1-...b781" # without proxy-url
|
||||||
# models: # The models supported by the provider.
|
# models: # The models supported by the provider.
|
||||||
# - name: "moonshotai/kimi-k2:free" # The actual model name.
|
# - name: "moonshotai/kimi-k2:free" # The actual model name.
|
||||||
# alias: "kimi-k2" # The alias used in the API.
|
# alias: "kimi-k2" # The alias used in the API.
|
||||||
|
# thinking: # optional: omit to default to levels ["low","medium","high"]
|
||||||
|
# levels: ["low", "medium", "high"]
|
||||||
# # You may repeat the same alias to build an internal model pool.
|
# # You may repeat the same alias to build an internal model pool.
|
||||||
# # The client still sees only one alias in the model list.
|
# # The client still sees only one alias in the model list.
|
||||||
# # Requests to that alias will round-robin across the upstream names below,
|
# # Requests to that alias will round-robin across the upstream names below,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -66,8 +67,10 @@ type callbackForwarder struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
callbackForwardersMu sync.Mutex
|
callbackForwardersMu sync.Mutex
|
||||||
callbackForwarders = make(map[int]*callbackForwarder)
|
callbackForwarders = make(map[int]*callbackForwarder)
|
||||||
|
errAuthFileMustBeJSON = errors.New("auth file must be .json")
|
||||||
|
errAuthFileNotFound = errors.New("auth file not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
|
func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
|
||||||
@@ -579,32 +582,57 @@ func (h *Handler) UploadAuthFile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
if file, err := c.FormFile("file"); err == nil && file != nil {
|
|
||||||
name := filepath.Base(file.Filename)
|
fileHeaders, errMultipart := h.multipartAuthFileHeaders(c)
|
||||||
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
if errMultipart != nil {
|
||||||
c.JSON(400, gin.H{"error": "file must be .json"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid multipart form: %v", errMultipart)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dst := filepath.Join(h.cfg.AuthDir, name)
|
if len(fileHeaders) == 1 {
|
||||||
if !filepath.IsAbs(dst) {
|
if _, errUpload := h.storeUploadedAuthFile(ctx, fileHeaders[0]); errUpload != nil {
|
||||||
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
|
if errors.Is(errUpload, errAuthFileMustBeJSON) {
|
||||||
dst = abs
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be .json"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errUpload.Error()})
|
||||||
if errSave := c.SaveUploadedFile(file, dst); errSave != nil {
|
|
||||||
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, errRead := os.ReadFile(dst)
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
if errRead != nil {
|
return
|
||||||
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})
|
}
|
||||||
|
if len(fileHeaders) > 1 {
|
||||||
|
uploaded := make([]string, 0, len(fileHeaders))
|
||||||
|
failed := make([]gin.H, 0)
|
||||||
|
for _, file := range fileHeaders {
|
||||||
|
name, errUpload := h.storeUploadedAuthFile(ctx, file)
|
||||||
|
if errUpload != nil {
|
||||||
|
failureName := ""
|
||||||
|
if file != nil {
|
||||||
|
failureName = filepath.Base(file.Filename)
|
||||||
|
}
|
||||||
|
msg := errUpload.Error()
|
||||||
|
if errors.Is(errUpload, errAuthFileMustBeJSON) {
|
||||||
|
msg = "file must be .json"
|
||||||
|
}
|
||||||
|
failed = append(failed, gin.H{"name": failureName, "error": msg})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uploaded = append(uploaded, name)
|
||||||
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
c.JSON(http.StatusMultiStatus, gin.H{
|
||||||
|
"status": "partial",
|
||||||
|
"uploaded": len(uploaded),
|
||||||
|
"files": uploaded,
|
||||||
|
"failed": failed,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "uploaded": len(uploaded), "files": uploaded})
|
||||||
c.JSON(500, gin.H{"error": errReg.Error()})
|
return
|
||||||
return
|
}
|
||||||
}
|
if c.ContentType() == "multipart/form-data" {
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no files uploaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := c.Query("name")
|
name := c.Query("name")
|
||||||
@@ -621,17 +649,7 @@ func (h *Handler) UploadAuthFile(c *gin.Context) {
|
|||||||
c.JSON(400, gin.H{"error": "failed to read body"})
|
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
if err = h.writeAuthFile(ctx, filepath.Base(name), data); err != nil {
|
||||||
if !filepath.IsAbs(dst) {
|
|
||||||
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
|
|
||||||
dst = abs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
|
|
||||||
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = h.registerAuthFromFile(ctx, dst, data); err != nil {
|
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -678,11 +696,182 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
|
|||||||
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
|
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := c.Query("name")
|
|
||||||
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
names, errNames := requestedAuthFileNamesForDelete(c)
|
||||||
|
if errNames != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": errNames.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
c.JSON(400, gin.H{"error": "invalid name"})
|
c.JSON(400, gin.H{"error": "invalid name"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(names) == 1 {
|
||||||
|
if _, status, errDelete := h.deleteAuthFileByName(ctx, names[0]); errDelete != nil {
|
||||||
|
c.JSON(status, gin.H{"error": errDelete.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedFiles := make([]string, 0, len(names))
|
||||||
|
failed := make([]gin.H, 0)
|
||||||
|
for _, name := range names {
|
||||||
|
deletedName, _, errDelete := h.deleteAuthFileByName(ctx, name)
|
||||||
|
if errDelete != nil {
|
||||||
|
failed = append(failed, gin.H{"name": name, "error": errDelete.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deletedFiles = append(deletedFiles, deletedName)
|
||||||
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
c.JSON(http.StatusMultiStatus, gin.H{
|
||||||
|
"status": "partial",
|
||||||
|
"deleted": len(deletedFiles),
|
||||||
|
"files": deletedFiles,
|
||||||
|
"failed": failed,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted": len(deletedFiles), "files": deletedFiles})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) multipartAuthFileHeaders(c *gin.Context) ([]*multipart.FileHeader, error) {
|
||||||
|
if h == nil || c == nil || c.ContentType() != "multipart/form-data" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
form, err := c.MultipartForm()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if form == nil || len(form.File) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(form.File))
|
||||||
|
for key := range form.File {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
headers := make([]*multipart.FileHeader, 0)
|
||||||
|
for _, key := range keys {
|
||||||
|
headers = append(headers, form.File[key]...)
|
||||||
|
}
|
||||||
|
return headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) storeUploadedAuthFile(ctx context.Context, file *multipart.FileHeader) (string, error) {
|
||||||
|
if file == nil {
|
||||||
|
return "", fmt.Errorf("no file uploaded")
|
||||||
|
}
|
||||||
|
name := filepath.Base(strings.TrimSpace(file.Filename))
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".json") {
|
||||||
|
return "", errAuthFileMustBeJSON
|
||||||
|
}
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open uploaded file: %w", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(src)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read uploaded file: %w", err)
|
||||||
|
}
|
||||||
|
if err := h.writeAuthFile(ctx, name, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeAuthFile(ctx context.Context, name string, data []byte) error {
|
||||||
|
dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
||||||
|
if !filepath.IsAbs(dst) {
|
||||||
|
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
|
||||||
|
dst = abs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auth, err := h.buildAuthFromFileData(dst, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
|
||||||
|
return fmt.Errorf("failed to write file: %w", errWrite)
|
||||||
|
}
|
||||||
|
if err := h.upsertAuthRecord(ctx, auth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestedAuthFileNamesForDelete(c *gin.Context) ([]string, error) {
|
||||||
|
if c == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
names := uniqueAuthFileNames(c.QueryArray("name"))
|
||||||
|
if len(names) > 0 {
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read body")
|
||||||
|
}
|
||||||
|
body = bytes.TrimSpace(body)
|
||||||
|
if len(body) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var objectBody struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Names []string `json:"names"`
|
||||||
|
}
|
||||||
|
if body[0] == '[' {
|
||||||
|
var arrayBody []string
|
||||||
|
if err := json.Unmarshal(body, &arrayBody); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid request body")
|
||||||
|
}
|
||||||
|
return uniqueAuthFileNames(arrayBody), nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &objectBody); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]string, 0, len(objectBody.Names)+1)
|
||||||
|
if strings.TrimSpace(objectBody.Name) != "" {
|
||||||
|
out = append(out, objectBody.Name)
|
||||||
|
}
|
||||||
|
out = append(out, objectBody.Names...)
|
||||||
|
return uniqueAuthFileNames(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueAuthFileNames(names []string) []string {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(names))
|
||||||
|
out := make([]string, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string, int, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
||||||
|
return "", http.StatusBadRequest, fmt.Errorf("invalid name")
|
||||||
|
}
|
||||||
|
|
||||||
targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
targetPath := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
|
||||||
targetID := ""
|
targetID := ""
|
||||||
@@ -699,22 +888,19 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if errRemove := os.Remove(targetPath); errRemove != nil {
|
if errRemove := os.Remove(targetPath); errRemove != nil {
|
||||||
if os.IsNotExist(errRemove) {
|
if os.IsNotExist(errRemove) {
|
||||||
c.JSON(404, gin.H{"error": "file not found"})
|
return filepath.Base(name), http.StatusNotFound, errAuthFileNotFound
|
||||||
} else {
|
|
||||||
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", errRemove)})
|
|
||||||
}
|
}
|
||||||
return
|
return filepath.Base(name), http.StatusInternalServerError, fmt.Errorf("failed to remove file: %w", errRemove)
|
||||||
}
|
}
|
||||||
if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {
|
if errDeleteRecord := h.deleteTokenRecord(ctx, targetPath); errDeleteRecord != nil {
|
||||||
c.JSON(500, gin.H{"error": errDeleteRecord.Error()})
|
return filepath.Base(name), http.StatusInternalServerError, errDeleteRecord
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if targetID != "" {
|
if targetID != "" {
|
||||||
h.disableAuth(ctx, targetID)
|
h.disableAuth(ctx, targetID)
|
||||||
} else {
|
} else {
|
||||||
h.disableAuth(ctx, targetPath)
|
h.disableAuth(ctx, targetPath)
|
||||||
}
|
}
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
return filepath.Base(name), http.StatusOK, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) findAuthForDelete(name string) *coreauth.Auth {
|
func (h *Handler) findAuthForDelete(name string) *coreauth.Auth {
|
||||||
@@ -783,19 +969,27 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []
|
|||||||
if h.authManager == nil {
|
if h.authManager == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
auth, err := h.buildAuthFromFileData(path, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return h.upsertAuthRecord(ctx, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Auth, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return fmt.Errorf("auth path is empty")
|
return nil, fmt.Errorf("auth path is empty")
|
||||||
}
|
}
|
||||||
if data == nil {
|
if data == nil {
|
||||||
var err error
|
var err error
|
||||||
data, err = os.ReadFile(path)
|
data, err = os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read auth file: %w", err)
|
return nil, fmt.Errorf("failed to read auth file: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata := make(map[string]any)
|
metadata := make(map[string]any)
|
||||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||||
return fmt.Errorf("invalid auth file: %w", err)
|
return nil, fmt.Errorf("invalid auth file: %w", err)
|
||||||
}
|
}
|
||||||
provider, _ := metadata["type"].(string)
|
provider, _ := metadata["type"].(string)
|
||||||
if provider == "" {
|
if provider == "" {
|
||||||
@@ -829,13 +1023,25 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []
|
|||||||
if hasLastRefresh {
|
if hasLastRefresh {
|
||||||
auth.LastRefreshedAt = lastRefresh
|
auth.LastRefreshedAt = lastRefresh
|
||||||
}
|
}
|
||||||
if existing, ok := h.authManager.GetByID(authID); ok {
|
if h != nil && h.authManager != nil {
|
||||||
auth.CreatedAt = existing.CreatedAt
|
if existing, ok := h.authManager.GetByID(authID); ok {
|
||||||
if !hasLastRefresh {
|
auth.CreatedAt = existing.CreatedAt
|
||||||
auth.LastRefreshedAt = existing.LastRefreshedAt
|
if !hasLastRefresh {
|
||||||
|
auth.LastRefreshedAt = existing.LastRefreshedAt
|
||||||
|
}
|
||||||
|
auth.NextRefreshAfter = existing.NextRefreshAfter
|
||||||
|
auth.Runtime = existing.Runtime
|
||||||
}
|
}
|
||||||
auth.NextRefreshAfter = existing.NextRefreshAfter
|
}
|
||||||
auth.Runtime = existing.Runtime
|
return auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) upsertAuthRecord(ctx context.Context, auth *coreauth.Auth) error {
|
||||||
|
if h == nil || h.authManager == nil || auth == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if existing, ok := h.authManager.GetByID(auth.ID); ok {
|
||||||
|
auth.CreatedAt = existing.CreatedAt
|
||||||
_, err := h.authManager.Update(ctx, auth)
|
_, err := h.authManager.Update(ctx, auth)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
197
internal/api/handlers/management/auth_files_batch_test.go
Normal file
197
internal/api/handlers/management/auth_files_batch_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUploadAuthFile_BatchMultipart(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
authDir := t.TempDir()
|
||||||
|
manager := coreauth.NewManager(nil, nil, nil)
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||||
|
|
||||||
|
files := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{name: "alpha.json", content: `{"type":"codex","email":"alpha@example.com"}`},
|
||||||
|
{name: "beta.json", content: `{"type":"claude","email":"beta@example.com"}`},
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
for _, file := range files {
|
||||||
|
part, err := writer.CreateFormFile("file", file.name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = part.Write([]byte(file.content)); err != nil {
|
||||||
|
t.Fatalf("failed to write multipart content: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v0/management/auth-files", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
ctx.Request = req
|
||||||
|
|
||||||
|
h.UploadAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected upload status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if got, ok := payload["uploaded"].(float64); !ok || int(got) != len(files) {
|
||||||
|
t.Fatalf("expected uploaded=%d, got %#v", len(files), payload["uploaded"])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
fullPath := filepath.Join(authDir, file.name)
|
||||||
|
data, err := os.ReadFile(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected uploaded file %s to exist: %v", file.name, err)
|
||||||
|
}
|
||||||
|
if string(data) != file.content {
|
||||||
|
t.Fatalf("expected file %s content %q, got %q", file.name, file.content, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auths := manager.List()
|
||||||
|
if len(auths) != len(files) {
|
||||||
|
t.Fatalf("expected %d auth entries, got %d", len(files), len(auths))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadAuthFile_BatchMultipart_InvalidJSONDoesNotOverwriteExistingFile(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
authDir := t.TempDir()
|
||||||
|
manager := coreauth.NewManager(nil, nil, nil)
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||||
|
|
||||||
|
existingName := "alpha.json"
|
||||||
|
existingContent := `{"type":"codex","email":"alpha@example.com"}`
|
||||||
|
if err := os.WriteFile(filepath.Join(authDir, existingName), []byte(existingContent), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to seed existing auth file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{name: existingName, content: `{"type":"codex"`},
|
||||||
|
{name: "beta.json", content: `{"type":"claude","email":"beta@example.com"}`},
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
for _, file := range files {
|
||||||
|
part, err := writer.CreateFormFile("file", file.name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = part.Write([]byte(file.content)); err != nil {
|
||||||
|
t.Fatalf("failed to write multipart content: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("failed to close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v0/management/auth-files", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
ctx.Request = req
|
||||||
|
|
||||||
|
h.UploadAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusMultiStatus {
|
||||||
|
t.Fatalf("expected upload status %d, got %d with body %s", http.StatusMultiStatus, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(authDir, existingName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected existing auth file to remain readable: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != existingContent {
|
||||||
|
t.Fatalf("expected existing auth file to remain %q, got %q", existingContent, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
betaData, err := os.ReadFile(filepath.Join(authDir, "beta.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected valid auth file to be created: %v", err)
|
||||||
|
}
|
||||||
|
if string(betaData) != files[1].content {
|
||||||
|
t.Fatalf("expected beta auth file content %q, got %q", files[1].content, string(betaData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteAuthFile_BatchQuery(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
authDir := t.TempDir()
|
||||||
|
files := []string{"alpha.json", "beta.json"}
|
||||||
|
for _, name := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(authDir, name), []byte(`{"type":"codex"}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write auth file %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := coreauth.NewManager(nil, nil, nil)
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
|
||||||
|
h.tokenStore = &memoryAuthStore{}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodDelete,
|
||||||
|
"/v0/management/auth-files?name="+url.QueryEscape(files[0])+"&name="+url.QueryEscape(files[1]),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
ctx.Request = req
|
||||||
|
|
||||||
|
h.DeleteAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected delete status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if got, ok := payload["deleted"].(float64); !ok || int(got) != len(files) {
|
||||||
|
t.Fatalf("expected deleted=%d, got %#v", len(files), payload["deleted"])
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range files {
|
||||||
|
if _, err := os.Stat(filepath.Join(authDir, name)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected auth file %s to be removed, stat err: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -574,6 +575,10 @@ type OpenAICompatibilityModel struct {
|
|||||||
|
|
||||||
// Alias is the model name alias that clients will use to reference this model.
|
// Alias is the model name alias that clients will use to reference this model.
|
||||||
Alias string `yaml:"alias" json:"alias"`
|
Alias string `yaml:"alias" json:"alias"`
|
||||||
|
|
||||||
|
// Thinking configures the thinking/reasoning capability for this model.
|
||||||
|
// If nil, the model defaults to level-based reasoning with levels ["low", "medium", "high"].
|
||||||
|
Thinking *registry.ThinkingSupport `yaml:"thinking,omitempty" json:"thinking,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m OpenAICompatibilityModel) GetName() string { return m.Name }
|
func (m OpenAICompatibilityModel) GetName() string { return m.Name }
|
||||||
|
|||||||
@@ -73,16 +73,16 @@ type availableModelsCacheEntry struct {
|
|||||||
// Values are interpreted in provider-native token units.
|
// Values are interpreted in provider-native token units.
|
||||||
type ThinkingSupport struct {
|
type ThinkingSupport struct {
|
||||||
// Min is the minimum allowed thinking budget (inclusive).
|
// Min is the minimum allowed thinking budget (inclusive).
|
||||||
Min int `json:"min,omitempty"`
|
Min int `json:"min,omitempty" yaml:"min,omitempty"`
|
||||||
// Max is the maximum allowed thinking budget (inclusive).
|
// Max is the maximum allowed thinking budget (inclusive).
|
||||||
Max int `json:"max,omitempty"`
|
Max int `json:"max,omitempty" yaml:"max,omitempty"`
|
||||||
// ZeroAllowed indicates whether 0 is a valid value (to disable thinking).
|
// ZeroAllowed indicates whether 0 is a valid value (to disable thinking).
|
||||||
ZeroAllowed bool `json:"zero_allowed,omitempty"`
|
ZeroAllowed bool `json:"zero_allowed,omitempty" yaml:"zero-allowed,omitempty"`
|
||||||
// DynamicAllowed indicates whether -1 is a valid value (dynamic thinking budget).
|
// DynamicAllowed indicates whether -1 is a valid value (dynamic thinking budget).
|
||||||
DynamicAllowed bool `json:"dynamic_allowed,omitempty"`
|
DynamicAllowed bool `json:"dynamic_allowed,omitempty" yaml:"dynamic-allowed,omitempty"`
|
||||||
// Levels defines discrete reasoning effort levels (e.g., "low", "medium", "high").
|
// Levels defines discrete reasoning effort levels (e.g., "low", "medium", "high").
|
||||||
// When set, the model uses level-based reasoning instead of token budgets.
|
// When set, the model uses level-based reasoning instead of token budgets.
|
||||||
Levels []string `json:"levels,omitempty"`
|
Levels []string `json:"levels,omitempty" yaml:"levels,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelRegistration tracks a model's availability
|
// ModelRegistration tracks a model's availability
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
codexClientVersion = "0.101.0"
|
codexUserAgent = "codex_cli_rs/0.116.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"
|
||||||
codexUserAgent = "codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464"
|
codexOriginator = "codex_cli_rs"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dataTag = []byte("data:")
|
var dataTag = []byte("data:")
|
||||||
@@ -645,8 +645,10 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
|
|||||||
ginHeaders = ginCtx.Request.Header
|
ginHeaders = ginCtx.Request.Header
|
||||||
}
|
}
|
||||||
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "Version", codexClientVersion)
|
misc.EnsureHeader(r.Header, ginHeaders, "Version", "")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
|
misc.EnsureHeader(r.Header, ginHeaders, "Session_id", uuid.NewString())
|
||||||
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Codex-Turn-Metadata", "")
|
||||||
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Client-Request-Id", "")
|
||||||
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
|
cfgUserAgent, _ := codexHeaderDefaults(cfg, auth)
|
||||||
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
|
ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent)
|
||||||
|
|
||||||
@@ -663,8 +665,12 @@ func applyCodexHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, s
|
|||||||
isAPIKey = true
|
isAPIKey = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
|
||||||
|
r.Header.Set("Originator", originator)
|
||||||
|
} else if !isAPIKey {
|
||||||
|
r.Header.Set("Originator", codexOriginator)
|
||||||
|
}
|
||||||
if !isAPIKey {
|
if !isAPIKey {
|
||||||
r.Header.Set("Originator", "codex_cli_rs")
|
|
||||||
if auth != nil && auth.Metadata != nil {
|
if auth != nil && auth.Metadata != nil {
|
||||||
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
||||||
r.Header.Set("Chatgpt-Account-Id", accountID)
|
r.Header.Set("Chatgpt-Account-Id", accountID)
|
||||||
|
|||||||
@@ -814,9 +814,10 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
|
|||||||
ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "")
|
ensureHeaderWithPriority(headers, ginHeaders, "x-codex-beta-features", cfgBetaFeatures, "")
|
||||||
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "")
|
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-state", "")
|
||||||
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "")
|
misc.EnsureHeader(headers, ginHeaders, "x-codex-turn-metadata", "")
|
||||||
|
misc.EnsureHeader(headers, ginHeaders, "x-client-request-id", "")
|
||||||
misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "")
|
misc.EnsureHeader(headers, ginHeaders, "x-responsesapi-include-timing-metrics", "")
|
||||||
|
misc.EnsureHeader(headers, ginHeaders, "Version", "")
|
||||||
|
|
||||||
misc.EnsureHeader(headers, ginHeaders, "Version", codexClientVersion)
|
|
||||||
betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta"))
|
betaHeader := strings.TrimSpace(headers.Get("OpenAI-Beta"))
|
||||||
if betaHeader == "" && ginHeaders != nil {
|
if betaHeader == "" && ginHeaders != nil {
|
||||||
betaHeader = strings.TrimSpace(ginHeaders.Get("OpenAI-Beta"))
|
betaHeader = strings.TrimSpace(ginHeaders.Get("OpenAI-Beta"))
|
||||||
@@ -834,8 +835,12 @@ func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *
|
|||||||
isAPIKey = true
|
isAPIKey = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" {
|
||||||
|
headers.Set("Originator", originator)
|
||||||
|
} else if !isAPIKey {
|
||||||
|
headers.Set("Originator", codexOriginator)
|
||||||
|
}
|
||||||
if !isAPIKey {
|
if !isAPIKey {
|
||||||
headers.Set("Originator", "codex_cli_rs")
|
|
||||||
if auth != nil && auth.Metadata != nil {
|
if auth != nil && auth.Metadata != nil {
|
||||||
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
if accountID, ok := auth.Metadata["account_id"].(string); ok {
|
||||||
if trimmed := strings.TrimSpace(accountID); trimmed != "" {
|
if trimmed := strings.TrimSpace(accountID); trimmed != "" {
|
||||||
|
|||||||
@@ -41,9 +41,46 @@ func TestApplyCodexWebsocketHeadersDefaultsToCurrentResponsesBeta(t *testing.T)
|
|||||||
if got := headers.Get("User-Agent"); got != codexUserAgent {
|
if got := headers.Get("User-Agent"); got != codexUserAgent {
|
||||||
t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
|
t.Fatalf("User-Agent = %s, want %s", got, codexUserAgent)
|
||||||
}
|
}
|
||||||
|
if got := headers.Get("Version"); got != "" {
|
||||||
|
t.Fatalf("Version = %q, want empty", got)
|
||||||
|
}
|
||||||
if got := headers.Get("x-codex-beta-features"); got != "" {
|
if got := headers.Get("x-codex-beta-features"); got != "" {
|
||||||
t.Fatalf("x-codex-beta-features = %q, want empty", got)
|
t.Fatalf("x-codex-beta-features = %q, want empty", got)
|
||||||
}
|
}
|
||||||
|
if got := headers.Get("X-Codex-Turn-Metadata"); got != "" {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Client-Request-Id"); got != "" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexWebsocketHeadersPassesThroughClientIdentityHeaders(t *testing.T) {
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Metadata: map[string]any{"email": "user@example.com"},
|
||||||
|
}
|
||||||
|
ctx := contextWithGinHeaders(map[string]string{
|
||||||
|
"Originator": "Codex Desktop",
|
||||||
|
"Version": "0.115.0-alpha.27",
|
||||||
|
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
|
||||||
|
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
|
||||||
|
})
|
||||||
|
|
||||||
|
headers := applyCodexWebsocketHeaders(ctx, http.Header{}, auth, "", nil)
|
||||||
|
|
||||||
|
if got := headers.Get("Originator"); got != "Codex Desktop" {
|
||||||
|
t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
|
||||||
|
}
|
||||||
|
if got := headers.Get("Version"); got != "0.115.0-alpha.27" {
|
||||||
|
t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"turn-1"}` {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %s, want %s", got, `{"turn_id":"turn-1"}`)
|
||||||
|
}
|
||||||
|
if got := headers.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {
|
func TestApplyCodexWebsocketHeadersUsesConfigDefaultsForOAuth(t *testing.T) {
|
||||||
@@ -177,6 +214,57 @@ func TestApplyCodexHeadersUsesConfigUserAgentForOAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexHeadersPassesThroughClientIdentityHeaders(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
auth := &cliproxyauth.Auth{
|
||||||
|
Provider: "codex",
|
||||||
|
Metadata: map[string]any{"email": "user@example.com"},
|
||||||
|
}
|
||||||
|
req = req.WithContext(contextWithGinHeaders(map[string]string{
|
||||||
|
"Originator": "Codex Desktop",
|
||||||
|
"Version": "0.115.0-alpha.27",
|
||||||
|
"X-Codex-Turn-Metadata": `{"turn_id":"turn-1"}`,
|
||||||
|
"X-Client-Request-Id": "019d2233-e240-7162-992d-38df0a2a0e0d",
|
||||||
|
}))
|
||||||
|
|
||||||
|
applyCodexHeaders(req, auth, "oauth-token", true, nil)
|
||||||
|
|
||||||
|
if got := req.Header.Get("Originator"); got != "Codex Desktop" {
|
||||||
|
t.Fatalf("Originator = %s, want %s", got, "Codex Desktop")
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("Version"); got != "0.115.0-alpha.27" {
|
||||||
|
t.Fatalf("Version = %s, want %s", got, "0.115.0-alpha.27")
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Codex-Turn-Metadata"); got != `{"turn_id":"turn-1"}` {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %s, want %s", got, `{"turn_id":"turn-1"}`)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Client-Request-Id"); got != "019d2233-e240-7162-992d-38df0a2a0e0d" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %s, want %s", got, "019d2233-e240-7162-992d-38df0a2a0e0d")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCodexHeadersDoesNotInjectClientOnlyHeadersByDefault(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, "https://example.com/responses", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyCodexHeaders(req, nil, "oauth-token", true, nil)
|
||||||
|
|
||||||
|
if got := req.Header.Get("Version"); got != "" {
|
||||||
|
t.Fatalf("Version = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Codex-Turn-Metadata"); got != "" {
|
||||||
|
t.Fatalf("X-Codex-Turn-Metadata = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Client-Request-Id"); got != "" {
|
||||||
|
t.Fatalf("X-Client-Request-Id = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contextWithGinHeaders(headers map[string]string) context.Context {
|
func contextWithGinHeaders(headers map[string]string) context.Context {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -104,59 +104,59 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
|
|
||||||
// Always try cached signature first (more reliable than client-provided)
|
// Always try cached signature first (more reliable than client-provided)
|
||||||
// Client may send stale or invalid signatures from different sessions
|
// Client may send stale or invalid signatures from different sessions
|
||||||
signature := ""
|
signature := ""
|
||||||
if thinkingText != "" {
|
if thinkingText != "" {
|
||||||
if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
|
if cachedSig := cache.GetCachedSignature(modelName, thinkingText); cachedSig != "" {
|
||||||
signature = cachedSig
|
signature = cachedSig
|
||||||
// log.Debugf("Using cached signature for thinking block")
|
// log.Debugf("Using cached signature for thinking block")
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to client signature only if cache miss and client signature is valid
|
|
||||||
if signature == "" {
|
|
||||||
signatureResult := contentResult.Get("signature")
|
|
||||||
clientSignature := ""
|
|
||||||
if signatureResult.Exists() && signatureResult.String() != "" {
|
|
||||||
arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
|
|
||||||
if len(arrayClientSignatures) == 2 {
|
|
||||||
if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
|
|
||||||
clientSignature = arrayClientSignatures[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cache.HasValidSignature(modelName, clientSignature) {
|
|
||||||
signature = clientSignature
|
// Fallback to client signature only if cache miss and client signature is valid
|
||||||
|
if signature == "" {
|
||||||
|
signatureResult := contentResult.Get("signature")
|
||||||
|
clientSignature := ""
|
||||||
|
if signatureResult.Exists() && signatureResult.String() != "" {
|
||||||
|
arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
|
||||||
|
if len(arrayClientSignatures) == 2 {
|
||||||
|
if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
|
||||||
|
clientSignature = arrayClientSignatures[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cache.HasValidSignature(modelName, clientSignature) {
|
||||||
|
signature = clientSignature
|
||||||
|
}
|
||||||
|
// log.Debugf("Using client-provided signature for thinking block")
|
||||||
}
|
}
|
||||||
// log.Debugf("Using client-provided signature for thinking block")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store for subsequent tool_use in the same message
|
// Store for subsequent tool_use in the same message
|
||||||
if cache.HasValidSignature(modelName, signature) {
|
if cache.HasValidSignature(modelName, signature) {
|
||||||
currentMessageThinkingSignature = signature
|
currentMessageThinkingSignature = signature
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip trailing unsigned thinking blocks on last assistant message
|
// Skip trailing unsigned thinking blocks on last assistant message
|
||||||
isUnsigned := !cache.HasValidSignature(modelName, signature)
|
isUnsigned := !cache.HasValidSignature(modelName, signature)
|
||||||
|
|
||||||
// If unsigned, skip entirely (don't convert to text)
|
// If unsigned, skip entirely (don't convert to text)
|
||||||
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
|
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
|
||||||
// Converting to text would break this requirement
|
// Converting to text would break this requirement
|
||||||
if isUnsigned {
|
if isUnsigned {
|
||||||
// log.Debugf("Dropping unsigned thinking block (no valid signature)")
|
// log.Debugf("Dropping unsigned thinking block (no valid signature)")
|
||||||
enableThoughtTranslate = false
|
enableThoughtTranslate = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid signature, send as thought block
|
// Valid signature, send as thought block
|
||||||
// Always include "text" field — Google Antigravity API requires it
|
// Always include "text" field — Google Antigravity API requires it
|
||||||
// even for redacted thinking where the text is empty.
|
// even for redacted thinking where the text is empty.
|
||||||
partJSON := []byte(`{}`)
|
partJSON := []byte(`{}`)
|
||||||
partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
|
partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
|
||||||
partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText)
|
partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText)
|
||||||
if signature != "" {
|
if signature != "" {
|
||||||
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
|
partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
|
||||||
}
|
}
|
||||||
clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts.-1", partJSON)
|
clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts.-1", partJSON)
|
||||||
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
||||||
prompt := contentResult.Get("text").String()
|
prompt := contentResult.Get("text").String()
|
||||||
// Skip empty text parts to avoid Gemini API error:
|
// Skip empty text parts to avoid Gemini API error:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
|
|||||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||||
|
|
||||||
// Initialize the OpenAI SSE template.
|
// Initialize the OpenAI SSE template.
|
||||||
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}]}`)
|
template := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{},"finish_reason":null,"native_finish_reason":null}]}`)
|
||||||
|
|
||||||
rootResult := gjson.ParseBytes(rawJSON)
|
rootResult := gjson.ParseBytes(rawJSON)
|
||||||
|
|
||||||
|
|||||||
@@ -45,3 +45,48 @@ func TestConvertCodexResponseToOpenAI_FirstChunkUsesRequestModelName(t *testing.
|
|||||||
t.Fatalf("expected model %q, got %q", modelName, gotModel)
|
t.Fatalf("expected model %q, got %q", modelName, gotModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertCodexResponseToOpenAI_ToolCallChunkOmitsNullContentFields(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var param any
|
||||||
|
|
||||||
|
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)
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
if gjson.GetBytes(out[0], "choices.0.delta.content").Exists() {
|
||||||
|
t.Fatalf("expected content to be omitted, got %s", string(out[0]))
|
||||||
|
}
|
||||||
|
if gjson.GetBytes(out[0], "choices.0.delta.reasoning_content").Exists() {
|
||||||
|
t.Fatalf("expected reasoning_content to be omitted, got %s", string(out[0]))
|
||||||
|
}
|
||||||
|
if !gjson.GetBytes(out[0], "choices.0.delta.tool_calls").Exists() {
|
||||||
|
t.Fatalf("expected tool_calls to exist, got %s", string(out[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertCodexResponseToOpenAI_ToolCallArgumentsDeltaOmitsNullContentFields(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var param any
|
||||||
|
|
||||||
|
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)
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("expected tool call announcement chunk, got %d", len(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
out = ConvertCodexResponseToOpenAI(ctx, "gpt-5.4", nil, nil, []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"query\":\"OpenAI\"}"}`), ¶m)
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
if gjson.GetBytes(out[0], "choices.0.delta.content").Exists() {
|
||||||
|
t.Fatalf("expected content to be omitted, got %s", string(out[0]))
|
||||||
|
}
|
||||||
|
if gjson.GetBytes(out[0], "choices.0.delta.reasoning_content").Exists() {
|
||||||
|
t.Fatalf("expected reasoning_content to be omitted, got %s", string(out[0]))
|
||||||
|
}
|
||||||
|
if !gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").Exists() {
|
||||||
|
t.Fatalf("expected tool call arguments delta to exist, got %s", string(out[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1006,6 +1006,10 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
if modelID == "" {
|
if modelID == "" {
|
||||||
modelID = m.Name
|
modelID = m.Name
|
||||||
}
|
}
|
||||||
|
thinking := m.Thinking
|
||||||
|
if thinking == nil {
|
||||||
|
thinking = ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}
|
||||||
|
}
|
||||||
ms = append(ms, &ModelInfo{
|
ms = append(ms, &ModelInfo{
|
||||||
ID: modelID,
|
ID: modelID,
|
||||||
Object: "model",
|
Object: "model",
|
||||||
@@ -1013,7 +1017,8 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
OwnedBy: compat.Name,
|
OwnedBy: compat.Name,
|
||||||
Type: "openai-compatibility",
|
Type: "openai-compatibility",
|
||||||
DisplayName: modelID,
|
DisplayName: modelID,
|
||||||
UserDefined: true,
|
UserDefined: false,
|
||||||
|
Thinking: thinking,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Register and return
|
// Register and return
|
||||||
|
|||||||
Reference in New Issue
Block a user