diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go index c0302052..4db27e79 100644 --- a/internal/api/handlers/management/api_tools.go +++ b/internal/api/handlers/management/api_tools.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor" coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" @@ -103,7 +104,7 @@ type apiCallResponse struct { func (h *Handler) APICall(c *gin.Context) { var body apiCallRequest if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errBindJSON, http.StatusBadRequest), gin.H{"error": "invalid body"}) return } diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 0a4ed74f..4ebda82b 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -13,6 +13,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/antigravity" "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/internal/credentialweight" @@ -38,13 +39,15 @@ type diskAuthFileCandidate struct { } var ( - callbackForwardersMu sync.Mutex - callbackForwarders = make(map[int]*callbackForwarder) - authFileEntryMu sync.Mutex - errAuthFileMustBeJSON = errors.New("auth file must be .json") - errAuthFileNotFound = errors.New("auth file not found") - errPluginVirtualAuth = errors.New("plugin virtual auth cannot be modified directly; edit or delete the source auth file") - newCodexOAuthService = func(cfg *config.Config) codexOAuthService { return codex.NewCodexAuth(cfg) } + callbackForwardersMu sync.Mutex + callbackForwarders = make(map[int]*callbackForwarder) + authFileEntryMu sync.Mutex + errAuthFileMustBeJSON = errors.New("auth file must be .json") + errInvalidAuthFile = errors.New("invalid auth file") + errAuthFileNotFound = errors.New("auth file not found") + errPluginVirtualAuth = errors.New("plugin virtual auth cannot be modified directly; edit or delete the source auth file") + newCodexOAuthService = func(cfg *config.Config) codexOAuthService { return codex.NewCodexAuth(cfg) } + newAntigravityOAuthService = func(cfg *config.Config) antigravityOAuthService { return antigravity.NewAntigravityAuth(cfg, nil) } ) func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) { @@ -119,9 +122,9 @@ func (h *Handler) ListAuthFiles(c *gin.Context) { h.mu.Lock() host := h.pluginHost h.mu.Unlock() - var quotaSupportedProviders map[string]struct{} + quota := authFileQuotaContext{v8: c.GetBool(ConfigV8ContextKey)} if host != nil { - quotaSupportedProviders = host.QuotaSupportedProvidersSet(c.Request.Context()) + quota.pluginIDs = host.QuotaProviderPluginIDs(c.Request.Context()) } auths := h.authManager.List() observedAt := time.Now().UTC() @@ -141,7 +144,7 @@ func (h *Handler) ListAuthFiles(c *gin.Context) { start, end := pagination.bounds(total) files := make([]gin.H, 0, end-start) for _, auth := range matching[start:end] { - if entry := h.buildAuthFileEntry(auth, quotaSupportedProviders); entry != nil { + if entry := h.buildAuthFileEntry(auth, quota); entry != nil { entry["cooldowns"] = nil if cooldownsKnown { entry["cooldowns"] = coreauth.CooldownSnapshotForAuth(auth, observedAt) @@ -157,7 +160,7 @@ func (h *Handler) ListAuthFiles(c *gin.Context) { if !matchesAuthFileLookup(auth, nameFilter, authIndexFilter) { continue } - if entry := h.buildAuthFileEntry(auth, quotaSupportedProviders); entry != nil { + if entry := h.buildAuthFileEntry(auth, quota); entry != nil { entry["cooldowns"] = nil if cooldownsKnown { entry["cooldowns"] = coreauth.CooldownSnapshotForAuth(auth, observedAt) @@ -478,10 +481,19 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context, pagination authFilesPagi c.JSON(200, authFilesListResponse(observedAt, files, pagination, total, end)) } -func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth, quotaSupported ...map[string]struct{}) gin.H { +// authFileQuotaContext carries request-scoped quota capability data for auth file entries. +type authFileQuotaContext struct { + // pluginIDs maps normalized provider keys to the quota plugin selected for them. + // A nil map means no quota plugins are available. + pluginIDs map[string]string + // v8 hides declarative quota_probe support because v8 only serves plugin quota routes. + v8 bool +} + +func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth, quota authFileQuotaContext) gin.H { authFileEntryMu.Lock() defer authFileEntryMu.Unlock() - return h.buildAuthFileEntryLocked(auth, quotaSupported...) + return h.buildAuthFileEntryLocked(auth, quota) } func isPersistentAuthFailure(auth *coreauth.Auth, now time.Time) bool { @@ -634,7 +646,7 @@ func reconcileAuthFileCooldownState(auth *coreauth.Auth, now time.Time) (unavail return unavailable, status, statusMessage, nextRetry } -func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth, quotaSupported ...map[string]struct{}) gin.H { +func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth, quota authFileQuotaContext) gin.H { if auth == nil { return nil } @@ -674,25 +686,15 @@ func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth, quotaSupported . if modelQuotas := modelQuotaObservationPayload(auth.Provider, auth.ModelStates); len(modelQuotas) > 0 { entry["model_quotas"] = modelQuotas } - var quotaSupportedMap map[string]struct{} - if len(quotaSupported) > 0 { - quotaSupportedMap = quotaSupported[0] - } - if quotaSupportedMap != nil { - if _, ok := quotaSupportedMap[strings.ToLower(strings.TrimSpace(auth.Provider))]; ok { - entry["supports_quota"] = true - entry["quota_provider"] = auth.Provider - } - } else { - h.mu.Lock() - host := h.pluginHost - h.mu.Unlock() - if host != nil && host.HasQuotaProvider(auth.Provider) { - entry["supports_quota"] = true - entry["quota_provider"] = auth.Provider + if pluginID := quota.pluginIDs[strings.ToLower(strings.TrimSpace(auth.Provider))]; pluginID != "" { + entry["supports_quota"] = true + entry["quota_provider"] = auth.Provider + if quota.v8 { + entry["quota_plugin_id"] = pluginID } } - if auth.Metadata != nil { + // Declarative quota_probe is only executed by the v0 quota/fetch route. + if !quota.v8 && auth.Metadata != nil { if probe, okProbe := auth.Metadata["quota_probe"]; okProbe && probe != nil { entry["supports_quota"] = true entry["quota_probe"] = probe @@ -784,6 +786,19 @@ func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth, quotaSupported . if requestRetry, ok := auth.RequestRetryOverride(); ok { entry["request_retry"] = requestRetry } + if quota.v8 && strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") { + entry["siwc_bind_supported"] = siwcBindEligible(auth) + entry["use_siwc"] = coreauth.IsCodexUseSIWC(auth) + entry["siwc_status"] = string(auth.CodexSIWCStatus(time.Now().UTC())) + section, _ := auth.Metadata["siwc"].(map[string]any) + accessToken, _ := section["access_token"].(string) + refreshToken, _ := section["refresh_token"].(string) + clientID, _ := section["client_id"].(string) + entry["siwc_bound"] = strings.TrimSpace(accessToken) != "" && strings.TrimSpace(refreshToken) != "" && strings.TrimSpace(clientID) != "" + if expiry, okExpiry := auth.SIWCExpirationTime(); okExpiry { + entry["siwc_expires_at"] = expiry.UTC().Format(time.RFC3339) + } + } return entry } diff --git a/internal/api/handlers/management/auth_files_crud.go b/internal/api/handlers/management/auth_files_crud.go index b5a3e242..30fee8de 100644 --- a/internal/api/handlers/management/auth_files_crud.go +++ b/internal/api/handlers/management/auth_files_crud.go @@ -17,6 +17,8 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" "github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer" coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" @@ -57,7 +59,7 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { fileHeaders, errMultipart := h.multipartAuthFileHeaders(c) if errMultipart != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid multipart form: %v", errMultipart)}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errMultipart, http.StatusBadRequest), gin.H{"error": fmt.Sprintf("invalid multipart form: %v", errMultipart)}) return } if len(fileHeaders) == 1 { @@ -66,6 +68,10 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "file must be .json"}) return } + if errors.Is(errUpload, errInvalidAuthFile) { + c.JSON(http.StatusBadRequest, gin.H{"error": errUpload.Error()}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": errUpload.Error()}) return } @@ -118,11 +124,15 @@ func (h *Handler) UploadAuthFile(c *gin.Context) { } data, err := io.ReadAll(c.Request.Body) if err != nil { - c.JSON(400, gin.H{"error": "failed to read body"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadRequest), gin.H{"error": "failed to read body"}) return } if err = h.writeAuthFile(ctx, filepath.Base(name), data); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) + status := http.StatusInternalServerError + if errors.Is(err, errInvalidAuthFile) { + status = http.StatusBadRequest + } + c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"status": "ok"}) @@ -156,13 +166,22 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { full = abs } } - if err = os.Remove(full); err == nil { + errDelete := func() error { + release := h.beginSIWCFileMutation(ctx, full) + defer release() + if errRemove := os.Remove(full); errRemove != nil { + return nil + } if errDel := h.deleteTokenRecord(ctx, full); errDel != nil { - c.JSON(500, gin.H{"error": errDel.Error()}) - return + return errDel } deleted++ h.removeAuth(ctx, full) + return nil + }() + if errDelete != nil { + c.JSON(500, gin.H{"error": errDelete.Error()}) + return } } c.JSON(200, gin.H{"status": "ok", "deleted": deleted}) @@ -171,7 +190,7 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) { names, errNames := requestedAuthFileNamesForDelete(c) if errNames != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": errNames.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errNames, http.StatusBadRequest), gin.H{"error": errNames.Error()}) return } if len(names) == 0 { @@ -269,7 +288,9 @@ func (h *Handler) writeAuthFile(ctx context.Context, name string, data []byte) e if err != nil { return err } - if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil { + release := h.beginSIWCFileMutation(ctx, dst) + defer release() + if errWrite := h.writeAuthFileBytes(dst, data, auth); errWrite != nil { return fmt.Errorf("failed to write file: %w", errWrite) } if err := h.upsertAuthRecord(ctx, auth); err != nil { @@ -283,6 +304,27 @@ func (h *Handler) writeAuthFile(ctx context.Context, name string, data []byte) e return nil } +func (h *Handler) writeAuthFileBytes(dst string, data []byte, auth *coreauth.Auth) error { + if h.shouldLockCodexAuthFile(auth) { + return codex.WithFileLock(dst, func() error { + return os.WriteFile(dst, data, 0o600) + }) + } + return os.WriteFile(dst, data, 0o600) +} + +func (h *Handler) shouldLockCodexAuthFile(auth *coreauth.Auth) bool { + if coreauth.IsCodexAuthRecord(auth) { + return true + } + if h != nil && h.authManager != nil && auth != nil && auth.ID != "" { + if existing, ok := h.authManager.GetByID(auth.ID); ok && coreauth.IsCodexAuthRecord(existing) { + return true + } + } + return false +} + func requestedAuthFileNamesForDelete(c *gin.Context) ([]string, error) { if c == nil { return nil, nil @@ -294,6 +336,10 @@ func requestedAuthFileNamesForDelete(c *gin.Context) ([]string, error) { body, err := io.ReadAll(c.Request.Body) if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return nil, err + } return nil, fmt.Errorf("failed to read body") } body = bytes.TrimSpace(body) @@ -366,6 +412,8 @@ func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string targetPath = abs } } + release := h.beginSIWCFileMutation(ctx, targetPath) + defer release() if errRemove := os.Remove(targetPath); errRemove != nil { if os.IsNotExist(errRemove) { return filepath.Base(name), http.StatusNotFound, errAuthFileNotFound @@ -479,9 +527,13 @@ func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Aut } metadata := make(map[string]any) if err := json.Unmarshal(data, &metadata); err != nil { - return nil, fmt.Errorf("invalid auth file: %w", err) + return nil, fmt.Errorf("%w: %w", errInvalidAuthFile, err) } coreauth.NormalizeCredentialMetadata(metadata) + // Loading tolerates invalid exclusion rules, so reject them here before anything is persisted. + if _, errExcluded := synthesizer.ExtractExcludedModelsFromMetadata(metadata); errExcluded != nil { + return nil, fmt.Errorf("%w: %w", errInvalidAuthFile, errExcluded) + } provider, _ := metadata["type"].(string) if provider == "" { provider = "unknown" @@ -506,7 +558,7 @@ func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Aut } generated, errSynthesize := synthesizer.SynthesizeAuthFile(sctx, path, data) if errSynthesize != nil { - return nil, fmt.Errorf("invalid auth file: %w", errSynthesize) + return nil, fmt.Errorf("%w: %w", errInvalidAuthFile, errSynthesize) } if len(generated) > 0 && generated[0] != nil { auth = generated[0].Clone() diff --git a/internal/api/handlers/management/auth_files_devin_oauth.go b/internal/api/handlers/management/auth_files_devin_oauth.go index 2913dd8b..79aab8c8 100644 --- a/internal/api/handlers/management/auth_files_devin_oauth.go +++ b/internal/api/handlers/management/auth_files_devin_oauth.go @@ -35,10 +35,11 @@ var newDevinOAuthService = func(cfg *config.Config) devinOAuthService { // Devin's authorization page strictly validates that redirect_uri matches // http://127.0.0.1:/callback (http protocol, 127.0.0.1 host, and /callback path). func (h *Handler) devinCallbackURL() (string, error) { - if h == nil || h.cfg == nil || h.cfg.Port <= 0 { + cfg := h.configSnapshot() + if h == nil || cfg == nil || cfg.Port <= 0 { return "", fmt.Errorf("server port is not configured") } - return fmt.Sprintf("http://127.0.0.1:%d/callback", h.cfg.Port), nil + return fmt.Sprintf("http://127.0.0.1:%d/callback", cfg.Port), nil } // RequestDevinToken starts the same callback/status flow used by the other WebUI providers. @@ -59,17 +60,23 @@ func (h *Handler) RequestDevinToken(c *gin.Context) { return } - authSvc := newDevinOAuthService(h.cfg) + sessionProxyURL := oauthSessionProxyURLFromContext(c) + cfg := h.oauthConfigWithProxy(sessionProxyURL) + authSvc := newDevinOAuthService(cfg) authURL := authSvc.BuildAuthorizationURL(redirectURI, pkceCodes.CodeChallenge, state) - RegisterOAuthSession(state, "devin") + RegisterOAuthSessionWithProxy(state, "devin", sessionProxyURL) // Do not inherit the HTTP request cancellation: login continues after returning the URL. ctx := PopulateAuthContext(context.Background(), c) - authDir := h.cfg.AuthDir + authDir := "" + if cfg != nil { + authDir = cfg.AuthDir + } go h.completeDevinOAuth(ctx, authDir, state, pkceCodes.CodeVerifier, authSvc) c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state}) } func (h *Handler) completeDevinOAuth(ctx context.Context, authDir, state, codeVerifier string, authSvc devinOAuthService) { + sessionProxyURL := GetOAuthSessionProxyURL(state) ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() go watchOAuthSessionCancel(ctx, cancel, state, "devin") @@ -121,11 +128,12 @@ func (h *Handler) completeDevinOAuth(ctx context.Context, authDir, state, codeVe } return } - if errGuard := guardOAuthSessionPendingForSave(state, "devin"); errGuard != nil { + applySessionProxyToAuthRecord(record, sessionProxyURL) + if errGuard := claimOAuthSessionCommit(state, "devin"); errGuard != nil { return } if _, errSave := h.saveTokenRecord(ctx, record); errSave != nil { - SetOAuthSessionError(state, "Failed to save authentication tokens") + FailOAuthSessionCommit(state, "Failed to save authentication tokens") return } CompleteOAuthSession(state) diff --git a/internal/api/handlers/management/auth_files_fields.go b/internal/api/handlers/management/auth_files_fields.go index 7304b06d..39020d47 100644 --- a/internal/api/handlers/management/auth_files_fields.go +++ b/internal/api/handlers/management/auth_files_fields.go @@ -17,6 +17,7 @@ import ( "github.com/gin-gonic/gin" claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v8/internal/credentialweight" "github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer" sdkAuth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth" @@ -37,7 +38,7 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { Disabled *bool `json:"disabled"` } if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadRequest), gin.H{"error": "invalid request body"}) return } @@ -125,19 +126,21 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { return } - applyAuthDisabledState(targetAuth, *req.Disabled) - updatedAuth, err := h.authManager.Update(ctx, targetAuth) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)}) + updatedAuth, committed, errModify := h.authManager.Modify(ctx, targetAuth.ID, targetAuth.RegistrationEpoch, func(current *coreauth.Auth) error { + applyAuthDisabledState(current, *req.Disabled) + return nil + }) + if errModify != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", errModify)}) return } - hookAuth := updatedAuth - if hookAuth == nil { - hookAuth = targetAuth + if !committed || updatedAuth == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) + return } locked = false h.authStatusMu.Unlock() - if errHook := h.invokePostAuthPersistHooks(ctx, []*coreauth.Auth{hookAuth}); errHook != nil { + if errHook := h.invokePostAuthPersistHooks(ctx, []*coreauth.Auth{updatedAuth}); errHook != nil { log.Errorf("post-auth persist hook failed for status update on %s: %v", targetAuth.ID, errHook) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to synchronize auth runtime: %v", errHook)}) return @@ -175,17 +178,18 @@ func (h *Handler) patchPluginVirtualSourceStatus(ctx context.Context, targetAuth !sameAuthFilePath(authAttribute(auth, coreauth.AttributeVirtualSource), sourcePath) { continue } - applyAuthDisabledState(auth, disabled) - auth.UpdatedAt = now - updated, errUpdate := h.authManager.Update(ctx, auth) + updated, committed, errUpdate := h.authManager.Modify(ctx, auth.ID, auth.RegistrationEpoch, func(current *coreauth.Auth) error { + applyAuthDisabledState(current, disabled) + current.UpdatedAt = now + return nil + }) if errUpdate != nil { return nil, fmt.Errorf("failed to update auth %s: %w", auth.ID, errUpdate) } - hookAuth := updated - if hookAuth == nil { - hookAuth = auth + if !committed || updated == nil { + continue } - hookAuths = append(hookAuths, hookAuth) + hookAuths = append(hookAuths, updated) } return hookAuths, nil } @@ -265,7 +269,7 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { decoder := json.NewDecoder(c.Request.Body) decoder.UseNumber() if err := decoder.Decode(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadRequest), gin.H{"error": "invalid request body"}) return } @@ -318,7 +322,8 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { } } - if targetAuth == nil { + // Config-sourced credentials are managed through config endpoints, not as auth files. + if targetAuth == nil || targetAuth.AuthSourceKind() == coreauth.AuthSourceConfig { c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) return } @@ -326,6 +331,42 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()}) return } + + updatedAuth, committed, errModify := h.authManager.Modify(ctx, targetAuth.ID, targetAuth.RegistrationEpoch, func(current *coreauth.Auth) error { + return applyAuthFileFieldsPatch(current, req, requestRetryPatch) + }) + if errModify != nil { + var patchErr *authFileFieldPatchError + if errors.As(errModify, &patchErr) { + c.JSON(http.StatusBadRequest, gin.H{"error": patchErr.message}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", errModify)}) + return + } + if !committed || updatedAuth == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) + return + } + if h.postAuthPersistHook != nil { + if errHook := h.postAuthPersistHook(ctx, updatedAuth); errHook != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("post-auth persist hook failed: %v", errHook)}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +type authFileFieldPatchError struct { + message string +} + +func (e *authFileFieldPatchError) Error() string { + return e.message +} + +func applyAuthFileFieldsPatch(targetAuth *coreauth.Auth, req map[string]json.RawMessage, requestRetryPatch authFileRequestRetryPatch) error { coreauth.NormalizeCredentialMetadata(targetAuth.Metadata) changed := false @@ -333,13 +374,29 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { for key, rawValue := range req { fieldPath := strings.TrimSpace(key) if fieldPath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "field name is required"}) - return + return &authFileFieldPatchError{message: "field name is required"} } value, errDecode := decodeAuthFileFieldValue(rawValue) if errDecode != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid field %s", fieldPath)}) - return + return &authFileFieldPatchError{message: fmt.Sprintf("invalid field %s", fieldPath)} + } + if rootAuthFileField(fieldPath) == "excluded_models" { + if fieldPath != "excluded_models" { + return &authFileFieldPatchError{message: "excluded_models does not support nested fields"} + } + if value != nil { + models, okModels := value.([]any) + validModels := okModels + for _, model := range models { + if _, okString := model.(string); !okString { + validModels = false + break + } + } + if !validModels { + return &authFileFieldPatchError{message: "excluded_models must be a string array or null"} + } + } } if targetAuth.Metadata == nil { targetAuth.Metadata = make(map[string]any) @@ -350,24 +407,20 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { delete(targetAuth.Metadata, coreauth.AttributeWeight) } else { if _, okNumber := value.(json.Number); !okNumber { - c.JSON(http.StatusBadRequest, gin.H{"error": "weight must be an integer"}) - return + return &authFileFieldPatchError{message: "weight must be an integer"} } weight, errWeight := credentialweight.ParseValue(value) if errWeight != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": errWeight.Error()}) - return + return &authFileFieldPatchError{message: errWeight.Error()} } targetAuth.Metadata[coreauth.AttributeWeight] = weight } } else if rootAuthFileField(fieldPath) == coreauth.AttributeWeight { - c.JSON(http.StatusBadRequest, gin.H{"error": "weight does not support nested fields"}) - return + return &authFileFieldPatchError{message: "weight does not support nested fields"} } else if fieldPath == "headers" { applyAuthFileHeadersPatch(targetAuth, value) } else if errSet := setAuthFileMetadataValue(targetAuth.Metadata, fieldPath, value); errSet != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": errSet.Error()}) - return + return &authFileFieldPatchError{message: errSet.Error()} } if root := rootAuthFileField(fieldPath); root != "" { touchedRoots[root] = struct{}{} @@ -387,32 +440,26 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { } if changed { syncAuthFileMetadataFields(targetAuth, touchedRoots) + if _, touched := touchedRoots["excluded_models"]; touched { + excluded, errExcluded := synthesizer.ExtractExcludedModelsFromMetadata(targetAuth.Metadata) + if errExcluded != nil { + return &authFileFieldPatchError{message: errExcluded.Error()} + } + // ApplyAuthExcludedModelsMeta never clears attributes, so drop stale values first. + delete(targetAuth.Attributes, "excluded_models") + delete(targetAuth.Attributes, "excluded_models_hash") + // Store only the credential's own rules; global rules are resolved at model registration. + // An empty authKind keeps the existing auth_kind attribute unchanged. + synthesizer.ApplyAuthExcludedModelsMeta(targetAuth, excluded, "") + } } if !changed { - c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) - return + return &authFileFieldPatchError{message: "no fields to update"} } targetAuth.UpdatedAt = time.Now() - - updatedAuth, err := h.authManager.Update(ctx, targetAuth) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)}) - return - } - if h.postAuthPersistHook != nil { - hookAuth := updatedAuth - if hookAuth == nil { - hookAuth = targetAuth - } - if errHook := h.postAuthPersistHook(ctx, hookAuth); errHook != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("post-auth persist hook failed: %v", errHook)}) - return - } - } - - c.JSON(http.StatusOK, gin.H{"status": "ok"}) + return nil } func decodeAuthFileFieldValue(raw json.RawMessage) (any, error) { @@ -897,9 +944,9 @@ func (h *Handler) tokenStoreWithBaseDir() coreauth.Store { store = sdkAuth.GetTokenStore() h.tokenStore = store } - if h.cfg != nil { + if cfg := h.configSnapshot(); cfg != nil { if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok { - dirSetter.SetBaseDir(h.cfg.AuthDir) + dirSetter.SetBaseDir(cfg.AuthDir) } } return store @@ -911,13 +958,13 @@ func (h *Handler) mergeExistingAuthFileMetadata(record *coreauth.Auth) { } var existingMap map[string]any - if h.cfg != nil && strings.TrimSpace(h.cfg.AuthDir) != "" { + if cfg := h.configSnapshot(); cfg != nil && strings.TrimSpace(cfg.AuthDir) != "" { targetFile := record.FileName if targetFile == "" { targetFile = record.ID } if targetFile != "" { - fullPath := filepath.Join(h.cfg.AuthDir, targetFile) + fullPath := filepath.Join(cfg.AuthDir, targetFile) if raw, errRead := os.ReadFile(fullPath); errRead == nil && len(raw) > 0 { _ = json.Unmarshal(raw, &existingMap) } @@ -983,7 +1030,7 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s persistedRecord := record if data, errRead := os.ReadFile(savedPath); errRead == nil && len(data) > 0 { auths, errSynthesize := synthesizer.SynthesizeAuthFile(&synthesizer.SynthesisContext{ - Config: h.cfg, + Config: h.configSnapshot(), AuthDir: filepath.Dir(savedPath), Now: time.Now(), IDGenerator: synthesizer.NewStableIDGenerator(), diff --git a/internal/api/handlers/management/auth_files_oauth_callback.go b/internal/api/handlers/management/auth_files_oauth_callback.go index f33f82e1..0efdb35f 100644 --- a/internal/api/handlers/management/auth_files_oauth_callback.go +++ b/internal/api/handlers/management/auth_files_oauth_callback.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost" log "github.com/sirupsen/logrus" ) @@ -26,8 +27,15 @@ type callbackForwarder struct { } func isWebUIRequest(c *gin.Context) bool { + if c == nil { + return false + } raw := strings.TrimSpace(c.Query("is_webui")) if raw == "" { + if val, ok := c.Get(oauthSessionIsWebUIContextKey); ok { + b, _ := val.(bool) + return b + } return false } switch strings.ToLower(raw) { @@ -172,11 +180,21 @@ func (h *Handler) ServePluginAuthURL(c *gin.Context) bool { return false } provider, ok := pluginAuthProviderFromURL(c.Request.URL) + if !ok && strings.TrimSpace(c.Request.URL.Path) == "/v8/management/oauth/auth-url" { + normalized, errNormalize := NormalizePluginOAuthCallbackProvider(oauthSessionProviderFromContext(c)) + if errNormalize == nil { + provider, ok = normalized, true + } + } if !ok || !host.HasAuthProvider(provider) { return false } + sessionProxyURL := oauthSessionProxyURLFromContext(c) ctx := PopulateAuthContext(context.Background(), c) + if sessionProxyURL != "" { + ctx = pluginhost.WithOAuthProxyURL(ctx, sessionProxyURL) + } callbackPath := "/v0/management/oauth-callback" query := c.Request.URL.Query() if c.Request.URL.Path == "/v8/management/oauth/auth-url" { @@ -190,12 +208,22 @@ func (h *Handler) ServePluginAuthURL(c *gin.Context) bool { return true } metadata := queryValuesToMetadata(query) + if extraMeta := oauthSessionExtraMetadataFromContext(c); len(extraMeta) > 0 { + if metadata == nil { + metadata = make(map[string]any, len(extraMeta)) + } + for k, v := range extraMeta { + if _, exists := metadata[k]; !exists { + metadata[k] = v + } + } + } resp, handled, errStart := host.StartLogin(ctx, provider, baseURL, metadata) if !handled { return false } if errStart != nil { - log.WithError(errStart).Error("failed to start plugin auth login") + log.Errorf("failed to start plugin auth login: %s", redactSessionProxyInString(errStart.Error(), sessionProxyURL)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"}) return true } @@ -210,7 +238,7 @@ func (h *Handler) ServePluginAuthURL(c *gin.Context) bool { c.JSON(http.StatusBadGateway, gin.H{"error": "invalid oauth state"}) return true } - if errRegister := RegisterPluginOAuthSession(state, provider, resp.Metadata); errRegister != nil { + if errRegister := RegisterPluginOAuthSessionWithProxy(state, provider, resp.Metadata, sessionProxyURL); errRegister != nil { log.WithError(errRegister).WithField("provider", provider).Error("failed to register plugin oauth session") c.JSON(http.StatusBadGateway, gin.H{"error": "failed to generate authorization url"}) return true diff --git a/internal/api/handlers/management/auth_files_provider_oauth.go b/internal/api/handlers/management/auth_files_provider_oauth.go index 48fc4ef4..dffe8733 100644 --- a/internal/api/handlers/management/auth_files_provider_oauth.go +++ b/internal/api/handlers/management/auth_files_provider_oauth.go @@ -34,6 +34,13 @@ type codexOAuthService interface { CreateTokenStorage(bundle *codex.CodexAuthBundle) *codex.CodexTokenStorage } +type antigravityOAuthService interface { + BuildAuthURL(state, redirectURI string) string + ExchangeCodeForTokens(ctx context.Context, code, redirectURI string) (*antigravity.TokenResponse, error) + FetchUserInfo(ctx context.Context, accessToken string) (string, error) + FetchProjectID(ctx context.Context, accessToken string) (string, error) +} + func (h *Handler) RequestAnthropicToken(c *gin.Context) { ctx := context.Background() ctx = PopulateAuthContext(ctx, c) @@ -56,8 +63,10 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) { return } + sessionProxyURL := oauthSessionProxyURLFromContext(c) + cfg := h.oauthConfigWithProxy(sessionProxyURL) // Initialize Claude auth service - anthropicAuth := claude.NewClaudeAuth(h.cfg) + anthropicAuth := claude.NewClaudeAuthWithProxyURL(cfg, sessionProxyURL) // Generate authorization URL (then override redirect_uri to reuse server port) authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes) @@ -67,7 +76,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) { return } - RegisterOAuthSession(state, "anthropic") + RegisterOAuthSessionWithProxy(state, "anthropic", sessionProxyURL) isWebUI := isWebUIRequest(c) var forwarder *callbackForwarder @@ -92,7 +101,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) { } // Helper: wait for callback file - waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-anthropic-%s.oauth", state)) + waitFile := filepath.Join(cfg.AuthDir, fmt.Sprintf(".oauth-anthropic-%s.oauth", state)) waitForFile := func(path string, timeout time.Duration) (map[string]string, error) { deadline := time.Now().Add(timeout) for { @@ -146,7 +155,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) { bundle, errExchange := anthropicAuth.ExchangeCodeForTokens(ctx, code, state, pkceCodes) if errExchange != nil { authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errExchange) - log.Errorf("Failed to exchange authorization code for tokens: %v", authErr) + log.Errorf("Failed to exchange authorization code for tokens: %s", redactSessionProxyInString(authErr.Error(), sessionProxyURL)) SetOAuthSessionError(state, "Failed to exchange authorization code for tokens") return } @@ -174,13 +183,14 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) { Storage: tokenStorage, Metadata: metadata, } - if errGuard := guardOAuthSessionPendingForSave(state, "anthropic"); errGuard != nil { + applySessionProxyToAuthRecord(record, sessionProxyURL) + if errGuard := claimOAuthSessionCommit(state, "anthropic"); errGuard != nil { return } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { log.Errorf("Failed to save authentication tokens: %v", errSave) - SetOAuthSessionError(state, "Failed to save authentication tokens") + FailOAuthSessionCommit(state, "Failed to save authentication tokens") return } @@ -217,8 +227,10 @@ func (h *Handler) RequestCodexToken(c *gin.Context) { return } + sessionProxyURL := oauthSessionProxyURLFromContext(c) + cfg := h.oauthConfigWithProxy(sessionProxyURL) // Initialize Codex auth service - openaiAuth := newCodexOAuthService(h.cfg) + openaiAuth := newCodexOAuthService(cfg) // Generate authorization URL authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes) @@ -228,7 +240,7 @@ func (h *Handler) RequestCodexToken(c *gin.Context) { return } - RegisterOAuthSession(state, "codex") + RegisterOAuthSessionWithProxy(state, "codex", sessionProxyURL) isWebUI := isWebUIRequest(c) var forwarder *callbackForwarder @@ -253,7 +265,7 @@ func (h *Handler) RequestCodexToken(c *gin.Context) { } // Wait for callback file - waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-codex-%s.oauth", state)) + waitFile := filepath.Join(cfg.AuthDir, fmt.Sprintf(".oauth-codex-%s.oauth", state)) deadline := time.Now().Add(5 * time.Minute) var code string for { @@ -293,8 +305,8 @@ func (h *Handler) RequestCodexToken(c *gin.Context) { bundle, errExchange := openaiAuth.ExchangeCodeForTokens(ctx, code, pkceCodes) if errExchange != nil { authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errExchange) - SetOAuthSessionError(state, oauthSessionErrorWithCause("Failed to exchange authorization code for tokens", errExchange)) - log.Errorf("Failed to exchange authorization code for tokens: %v", authErr) + SetOAuthSessionError(state, redactSessionProxyInString(oauthSessionErrorWithCause("Failed to exchange authorization code for tokens", errExchange), sessionProxyURL)) + log.Errorf("Failed to exchange authorization code for tokens: %s", redactSessionProxyInString(authErr.Error(), sessionProxyURL)) return } @@ -336,12 +348,13 @@ func (h *Handler) RequestCodexToken(c *gin.Context) { "plan_type": planType, }, } - if errGuard := guardOAuthSessionPendingForSave(state, "codex"); errGuard != nil { + applySessionProxyToAuthRecord(record, sessionProxyURL) + if errGuard := claimOAuthSessionCommit(state, "codex"); errGuard != nil { return } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { - SetOAuthSessionError(state, "Failed to save authentication tokens") + FailOAuthSessionCommit(state, "Failed to save authentication tokens") log.Errorf("Failed to save authentication tokens: %v", errSave) return } @@ -362,7 +375,9 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) { fmt.Println("Initializing Antigravity authentication...") - authSvc := antigravity.NewAntigravityAuth(h.cfg, nil) + sessionProxyURL := oauthSessionProxyURLFromContext(c) + cfg := h.oauthConfigWithProxy(sessionProxyURL) + authSvc := newAntigravityOAuthService(cfg) state, errState := misc.GenerateRandomState() if errState != nil { @@ -374,7 +389,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) { redirectURI := fmt.Sprintf("http://localhost:%d/oauth-callback", antigravity.CallbackPort) authURL := authSvc.BuildAuthURL(state, redirectURI) - RegisterOAuthSession(state, "antigravity") + RegisterOAuthSessionWithProxy(state, "antigravity", sessionProxyURL) isWebUI := isWebUIRequest(c) var forwarder *callbackForwarder @@ -398,7 +413,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) { defer stopCallbackForwarderInstance(antigravity.CallbackPort, forwarder) } - waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-antigravity-%s.oauth", state)) + waitFile := filepath.Join(cfg.AuthDir, fmt.Sprintf(".oauth-antigravity-%s.oauth", state)) deadline := time.Now().Add(5 * time.Minute) var authCode string for { @@ -437,7 +452,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) { tokenResp, errToken := authSvc.ExchangeCodeForTokens(ctx, authCode, redirectURI) if errToken != nil { - log.Errorf("Failed to exchange token: %v", errToken) + log.Errorf("Failed to exchange token: %s", redactSessionProxyInString(errToken.Error(), sessionProxyURL)) SetOAuthSessionError(state, "Failed to exchange token") return } @@ -451,7 +466,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) { email, errInfo := authSvc.FetchUserInfo(ctx, accessToken) if errInfo != nil { - log.Errorf("Failed to fetch user info: %v", errInfo) + log.Errorf("Failed to fetch user info: %s", redactSessionProxyInString(errInfo.Error(), sessionProxyURL)) SetOAuthSessionError(state, "Failed to fetch user info") return } @@ -466,6 +481,11 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) { if accessToken != "" { fetchedProjectID, errProject := authSvc.FetchProjectID(ctx, accessToken) if errProject != nil { + if sessionProxyURL != "" { + log.Errorf("antigravity: failed to fetch project ID: %s", redactSessionProxyInString(errProject.Error(), sessionProxyURL)) + SetOAuthSessionError(state, "Failed to fetch project ID") + return + } log.Warnf("antigravity: failed to fetch project ID: %v", errProject) } else { projectID = fetchedProjectID @@ -502,13 +522,14 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) { Label: label, Metadata: metadata, } - if errGuard := guardOAuthSessionPendingForSave(state, "antigravity"); errGuard != nil { + applySessionProxyToAuthRecord(record, sessionProxyURL) + if errGuard := claimOAuthSessionCommit(state, "antigravity"); errGuard != nil { return } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { log.Errorf("Failed to save token to file: %v", errSave) - SetOAuthSessionError(state, "Failed to save token to file") + FailOAuthSessionCommit(state, "Failed to save token to file") return } @@ -529,12 +550,14 @@ func (h *Handler) RequestXAIToken(c *gin.Context) { fmt.Println("Initializing xAI authentication...") + sessionProxyURL := oauthSessionProxyURLFromContext(c) + cfg := h.oauthConfigWithProxy(sessionProxyURL) state := fmt.Sprintf("xai-%d", time.Now().UnixNano()) - authSvc := xaiauth.NewXAIAuth(h.cfg) + authSvc := xaiauth.NewXAIAuthWithProxyURL(cfg, sessionProxyURL) deviceFlow, errStartDeviceFlow := authSvc.StartDeviceFlow(ctx) if errStartDeviceFlow != nil { - log.Errorf("Failed to start xAI device flow: %v", errStartDeviceFlow) + log.Errorf("Failed to start xAI device flow: %s", redactSessionProxyInString(errStartDeviceFlow.Error(), sessionProxyURL)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start device authorization flow"}) return } @@ -543,7 +566,7 @@ func (h *Handler) RequestXAIToken(c *gin.Context) { authURL = strings.TrimSpace(deviceFlow.VerificationURI) } - RegisterOAuthSession(state, "xai") + RegisterOAuthSessionWithProxy(state, "xai", sessionProxyURL) go func() { pollCtx, cancelPoll := context.WithCancel(ctx) @@ -556,8 +579,8 @@ func (h *Handler) RequestXAIToken(c *gin.Context) { if !IsOAuthSessionPending(state, "xai") { return } - log.Errorf("xAI authentication failed: %v", errWaitForAuthorization) - SetOAuthSessionError(state, oauthSessionErrorWithCause("Authentication failed", errWaitForAuthorization)) + log.Errorf("xAI authentication failed: %s", redactSessionProxyInString(errWaitForAuthorization.Error(), sessionProxyURL)) + SetOAuthSessionError(state, redactSessionProxyInString(oauthSessionErrorWithCause("Authentication failed", errWaitForAuthorization), sessionProxyURL)) return } if !IsOAuthSessionPending(state, "xai") { @@ -609,13 +632,14 @@ func (h *Handler) RequestXAIToken(c *gin.Context) { "base_url": tokenStorage.BaseURL, }, } - if errGuard := guardOAuthSessionPendingForSave(state, "xai"); errGuard != nil { + applySessionProxyToAuthRecord(record, sessionProxyURL) + if errGuard := claimOAuthSessionCommit(state, "xai"); errGuard != nil { return } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { log.Errorf("Failed to save xAI token to file: %v", errSave) - SetOAuthSessionError(state, "Failed to save token to file") + FailOAuthSessionCommit(state, "Failed to save token to file") return } @@ -642,12 +666,14 @@ func (h *Handler) RequestMetaToken(c *gin.Context) { fmt.Println("Initializing Meta authentication...") + sessionProxyURL := oauthSessionProxyURLFromContext(c) + cfg := h.oauthConfigWithProxy(sessionProxyURL) state := fmt.Sprintf("meta-%d", time.Now().UnixNano()) - authSvc := metaauth.NewMetaAuth(h.cfg) + authSvc := metaauth.NewMetaAuthWithProxyURL(cfg, sessionProxyURL) deviceFlow, errStartDeviceFlow := authSvc.StartDeviceFlow(ctx) if errStartDeviceFlow != nil { - log.Errorf("Failed to start Meta device flow: %v", errStartDeviceFlow) + log.Errorf("Failed to start Meta device flow: %s", redactSessionProxyInString(errStartDeviceFlow.Error(), sessionProxyURL)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start device authorization flow"}) return } @@ -656,7 +682,7 @@ func (h *Handler) RequestMetaToken(c *gin.Context) { authURL = strings.TrimSpace(deviceFlow.VerificationURI) } - RegisterOAuthSession(state, "meta") + RegisterOAuthSessionWithProxy(state, "meta", sessionProxyURL) go func() { pollCtx, cancelPoll := context.WithCancel(ctx) @@ -669,8 +695,8 @@ func (h *Handler) RequestMetaToken(c *gin.Context) { if !IsOAuthSessionPending(state, "meta") { return } - log.Errorf("Meta authentication failed: %v", errWaitForAuthorization) - SetOAuthSessionError(state, oauthSessionErrorWithCause("Authentication failed", errWaitForAuthorization)) + log.Errorf("Meta authentication failed: %s", redactSessionProxyInString(errWaitForAuthorization.Error(), sessionProxyURL)) + SetOAuthSessionError(state, redactSessionProxyInString(oauthSessionErrorWithCause("Authentication failed", errWaitForAuthorization), sessionProxyURL)) return } if !IsOAuthSessionPending(state, "meta") { @@ -685,13 +711,14 @@ func (h *Handler) RequestMetaToken(c *gin.Context) { } record := buildMetaAuthRecord(bundle, tokenStorage) - if errGuard := guardOAuthSessionPendingForSave(state, "meta"); errGuard != nil { + applySessionProxyToAuthRecord(record, sessionProxyURL) + if errGuard := claimOAuthSessionCommit(state, "meta"); errGuard != nil { return } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { log.Errorf("Failed to save Meta token to file: %v", errSave) - SetOAuthSessionError(state, "Failed to save token to file") + FailOAuthSessionCommit(state, "Failed to save token to file") return } @@ -785,6 +812,8 @@ func (h *Handler) RequestKimiToken(c *gin.Context) { domain = qDomain } else if qChan := strings.TrimSpace(c.Query("channel")); qChan != "" { domain = qChan + } else if bodyDomain := oauthSessionDomainFromContext(c); bodyDomain != "" { + domain = bodyDomain } h.requestKimiTokenWithDomain(c, domain) } @@ -813,14 +842,16 @@ func (h *Handler) requestKimiTokenWithDomain(c *gin.Context, domain string) { fmt.Printf("Initializing %s authentication...\n", displayName) + sessionProxyURL := oauthSessionProxyURLFromContext(c) + cfg := h.oauthConfigWithProxy(sessionProxyURL) state := fmt.Sprintf("%s-%d", statePrefix, time.Now().UnixNano()) // Initialize Kimi auth service - kimiAuth := kimi.NewKimiAuthWithDomain(h.cfg, domain) + kimiAuth := kimi.NewKimiAuthWithDomain(cfg, domain) // Generate authorization URL deviceFlow, errStartDeviceFlow := kimiAuth.StartDeviceFlow(ctx) if errStartDeviceFlow != nil { - log.Errorf("Failed to generate authorization URL: %v", errStartDeviceFlow) + log.Errorf("Failed to generate authorization URL: %s", redactSessionProxyInString(errStartDeviceFlow.Error(), sessionProxyURL)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"}) return } @@ -829,7 +860,7 @@ func (h *Handler) requestKimiTokenWithDomain(c *gin.Context, domain string) { authURL = deviceFlow.VerificationURI } - RegisterOAuthSession(state, providerName) + RegisterOAuthSessionWithProxy(state, providerName, sessionProxyURL) go func() { pollCtx, cancelPoll := context.WithCancel(ctx) @@ -842,8 +873,8 @@ func (h *Handler) requestKimiTokenWithDomain(c *gin.Context, domain string) { if !IsOAuthSessionPending(state, providerName) { return } - SetOAuthSessionError(state, oauthSessionErrorWithCause("Authentication failed", errWaitForAuthorization)) - fmt.Printf("%s authentication failed: %v\n", displayName, errWaitForAuthorization) + SetOAuthSessionError(state, redactSessionProxyInString(oauthSessionErrorWithCause("Authentication failed", errWaitForAuthorization), sessionProxyURL)) + fmt.Printf("%s authentication failed: %s\n", displayName, redactSessionProxyInString(errWaitForAuthorization.Error(), sessionProxyURL)) return } if !IsOAuthSessionPending(state, providerName) { @@ -887,13 +918,14 @@ func (h *Handler) requestKimiTokenWithDomain(c *gin.Context, domain string) { "domain": domain, }, } - if errGuard := guardOAuthSessionPendingForSave(state, providerName); errGuard != nil { + applySessionProxyToAuthRecord(record, sessionProxyURL) + if errGuard := claimOAuthSessionCommit(state, providerName); errGuard != nil { return } savedPath, errSave := h.saveTokenRecord(ctx, record) if errSave != nil { log.Errorf("Failed to save authentication tokens: %v", errSave) - SetOAuthSessionError(state, "Failed to save authentication tokens") + FailOAuthSessionCommit(state, "Failed to save authentication tokens") return } @@ -960,57 +992,67 @@ func (h *Handler) GetAuthStatus(c *gin.Context) { return } - provider, status, isPlugin, metadata, completed, ok := GetOAuthSessionDetails(state) + session, ok := oauthSessions.Get(state) if !ok { c.JSON(http.StatusOK, gin.H{"status": "error", "error": "unknown or expired state"}) return } - if completed { + if session.Completed { c.JSON(http.StatusOK, gin.H{"status": "ok"}) return } - if status != "" { - c.JSON(http.StatusOK, gin.H{"status": "error", "error": status}) + if session.Status != "" { + c.JSON(http.StatusOK, gin.H{"status": "error", "error": session.Status}) return } + if session.Committing || session.SIWCCommitting { + c.JSON(http.StatusOK, gin.H{"status": "wait"}) + return + } + provider := session.Provider + isPlugin := session.Source == oauthSessionSourcePlugin + metadata := session.Metadata + sessionProxyURL := strings.TrimSpace(session.ProxyURL) h.mu.Lock() host := h.pluginHost h.mu.Unlock() if isPlugin && host != nil && host.HasAuthProvider(provider) { ctx := PopulateAuthContext(context.Background(), c) + if sessionProxyURL != "" { + ctx = pluginhost.WithOAuthProxyURL(ctx, sessionProxyURL) + } resp, handled, errPoll := host.PollLogin(ctx, provider, state, metadata) if handled { if errPoll != nil { - message := strings.TrimSpace(errPoll.Error()) - if message == "" { - message = "Authentication failed" - } - SetOAuthSessionError(state, message) - c.JSON(http.StatusOK, gin.H{"status": "error", "error": message}) + message := redactSessionProxyInString(strings.TrimSpace(errPoll.Error()), sessionProxyURL) + writePluginPollFailureStatus(c, state, message) return } switch resp.Status { case "", pluginapi.AuthLoginStatusPending: - c.JSON(http.StatusOK, gin.H{"status": "wait"}) + writeNonCommitPluginPollStatus(c, state, true) return case pluginapi.AuthLoginStatusError: - message := strings.TrimSpace(resp.Message) - if message == "" { - message = "Authentication failed" - } - SetOAuthSessionError(state, message) - c.JSON(http.StatusOK, gin.H{"status": "error", "error": message}) + message := redactSessionProxyInString(strings.TrimSpace(resp.Message), sessionProxyURL) + writePluginPollFailureStatus(c, state, message) return case pluginapi.AuthLoginStatusSuccess: + if errGuard := claimOAuthSessionCommit(state, provider); errGuard != nil { + writeNonCommitPluginPollStatus(c, state, false) + return + } records := pluginLoginPollAuths(host, resp) if len(records) == 0 { - SetOAuthSessionError(state, "Authentication failed") + FailOAuthSessionCommit(state, "Authentication failed") c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Authentication failed"}) return } + for _, record := range records { + applySessionProxyToAuthRecord(record, sessionProxyURL) + } if errSave := h.savePluginLoginRecords(ctx, records); errSave != nil { log.WithError(errSave).WithField("provider", provider).Error("failed to save plugin auth tokens") - SetOAuthSessionError(state, "Failed to save authentication tokens") + FailOAuthSessionCommit(state, "Failed to save authentication tokens") c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Failed to save authentication tokens"}) return } @@ -1026,6 +1068,44 @@ func (h *Handler) GetAuthStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "wait"}) } +func writePluginPollFailureStatus(c *gin.Context, state, message string) { + errStatus, completed, committing, ok := oauthSessions.FailPendingPoll(state, message) + if !ok { + c.JSON(http.StatusOK, gin.H{"status": "error", "error": "unknown or expired state"}) + return + } + if completed { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + return + } + if committing { + c.JSON(http.StatusOK, gin.H{"status": "wait"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "error", "error": errStatus}) +} + +func writeNonCommitPluginPollStatus(c *gin.Context, state string, allowPendingWait bool) { + session, ok := oauthSessions.Get(state) + if !ok { + c.JSON(http.StatusOK, gin.H{"status": "error", "error": "unknown or expired state"}) + return + } + if session.Completed { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + return + } + if session.Status != "" { + c.JSON(http.StatusOK, gin.H{"status": "error", "error": session.Status}) + return + } + if session.Committing || session.SIWCCommitting || allowPendingWait { + c.JSON(http.StatusOK, gin.H{"status": "wait"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "error", "error": "unknown or expired state"}) +} + func pluginLoginPollAuths(host *pluginhost.Host, resp pluginapi.AuthLoginPollResponse) []*coreauth.Auth { if host == nil { return nil diff --git a/internal/api/handlers/management/auth_files_refresh.go b/internal/api/handlers/management/auth_files_refresh.go index 7955ff14..c144379c 100644 --- a/internal/api/handlers/management/auth_files_refresh.go +++ b/internal/api/handlers/management/auth_files_refresh.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" ) // RefreshAuthFiles triggers active refresh for a single auth file or all auth files. @@ -24,7 +25,7 @@ func (h *Handler) RefreshAuthFiles(c *gin.Context) { } if c.Request.Body != nil && c.Request.ContentLength != 0 { if errBind := c.ShouldBindJSON(&req); errBind != nil && !errors.Is(errBind, io.EOF) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body: " + errBind.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errBind, http.StatusBadRequest), gin.H{"error": "invalid request body: " + errBind.Error()}) return } } diff --git a/internal/api/handlers/management/auth_files_v8.go b/internal/api/handlers/management/auth_files_v8.go index 7d8151a8..15096d7a 100644 --- a/internal/api/handlers/management/auth_files_v8.go +++ b/internal/api/handlers/management/auth_files_v8.go @@ -1,15 +1,255 @@ package management import ( + "bytes" + "encoding/json" + "fmt" + "io" "net/http" "strings" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" + coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil" ) -// StartOAuthV8 dispatches login using the provider query parameter. +const ( + oauthSessionProxyURLContextKey = "management.oauth.proxy_url" + oauthSessionProviderContextKey = "management.oauth.provider" + oauthSessionDomainContextKey = "management.oauth.domain" + oauthSessionIsWebUIContextKey = "management.oauth.is_webui" + oauthSessionMetadataContextKey = "management.oauth.metadata" +) + +func oauthSessionProxyURLFromContext(c *gin.Context) string { + if c == nil { + return "" + } + val, ok := c.Get(oauthSessionProxyURLContextKey) + if !ok { + return "" + } + proxyURL, _ := val.(string) + return strings.TrimSpace(proxyURL) +} + +func oauthSessionProviderFromContext(c *gin.Context) string { + if c == nil { + return "" + } + val, ok := c.Get(oauthSessionProviderContextKey) + if !ok { + return "" + } + provider, _ := val.(string) + return strings.TrimSpace(provider) +} + +func oauthSessionDomainFromContext(c *gin.Context) string { + if c == nil { + return "" + } + val, ok := c.Get(oauthSessionDomainContextKey) + if !ok { + return "" + } + domain, _ := val.(string) + return strings.TrimSpace(domain) +} + +func oauthSessionExtraMetadataFromContext(c *gin.Context) map[string]any { + if c == nil { + return nil + } + val, ok := c.Get(oauthSessionMetadataContextKey) + if !ok { + return nil + } + meta, _ := val.(map[string]any) + return cloneOAuthSessionMetadata(meta) +} + +func validateOAuthSessionProxyURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + if !proxyutil.ValidRequestProxy(trimmed) { + return "", fmt.Errorf("invalid proxy_url") + } + setting, errParse := proxyutil.Parse(trimmed) + if errParse != nil || setting.Mode != proxyutil.ModeProxy || setting.URL == nil { + return "", fmt.Errorf("invalid proxy_url") + } + if strings.HasSuffix(setting.URL.Host, ":") { + return "", fmt.Errorf("invalid proxy_url") + } + if setting.URL.RawQuery != "" || setting.URL.Fragment != "" || (setting.URL.Path != "" && setting.URL.Path != "/") { + return "", fmt.Errorf("invalid proxy_url") + } + if (setting.URL.Scheme == "socks5" || setting.URL.Scheme == "socks5h") && setting.URL.Port() == "" { + return "", fmt.Errorf("invalid proxy_url") + } + if _, _, errTransport := proxyutil.BuildHTTPTransport(trimmed); errTransport != nil { + return "", fmt.Errorf("invalid proxy_url") + } + if _, _, errDialer := proxyutil.BuildDialer(trimmed); errDialer != nil { + return "", fmt.Errorf("invalid proxy_url") + } + return trimmed, nil +} + +func applySessionProxyToAuthRecord(record *coreauth.Auth, proxyURL string) { + proxyURL = strings.TrimSpace(proxyURL) + if record == nil || proxyURL == "" { + return + } + record.ProxyURL = proxyURL + if record.Metadata == nil { + record.Metadata = make(map[string]any) + } + record.Metadata["proxy_url"] = proxyURL + if setter, ok := record.Storage.(interface{ SetMetadata(map[string]any) }); ok { + setter.SetMetadata(record.Metadata) + } +} + +// StartOAuthV8 dispatches login using the provider query parameter or POST JSON body. func (h *Handler) StartOAuthV8(c *gin.Context) { - switch strings.ToLower(strings.TrimSpace(c.Query("provider"))) { + if c.Request != nil && c.Request.URL != nil { + if query := c.Request.URL.Query(); query.Has("proxy_url") || query.Has("proxy-url") { + c.JSON(http.StatusBadRequest, gin.H{"error": "proxy_url must be provided in the request body"}) + return + } + } + + provider := strings.TrimSpace(c.Query("provider")) + if c.Request != nil && c.Request.Body != nil { + rawBody, errRead := io.ReadAll(c.Request.Body) + if errRead != nil { + c.JSON(clienterror.HTTPStatusFromErrorOr(errRead, http.StatusBadRequest), gin.H{"error": "invalid request body"}) + return + } + if len(bytes.TrimSpace(rawBody)) > 0 { + if c.Request.Method != http.MethodPost { + c.JSON(http.StatusBadRequest, gin.H{"error": "request body requires POST"}) + return + } + var bodyMap map[string]any + if errUnmarshal := json.Unmarshal(rawBody, &bodyMap); errUnmarshal != nil || bodyMap == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if rawProvider, exists := bodyMap["provider"]; exists && rawProvider != nil { + providerStr, ok := rawProvider.(string) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid provider"}) + return + } + bodyProvider := strings.TrimSpace(providerStr) + if provider != "" && bodyProvider != "" && !strings.EqualFold(provider, bodyProvider) { + c.JSON(http.StatusBadRequest, gin.H{"error": "conflicting provider parameter"}) + return + } + if provider == "" { + provider = bodyProvider + } + } + + var rawProxySnake, rawProxyKebab string + if v, exists := bodyMap["proxy_url"]; exists && v != nil { + s, ok := v.(string) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid proxy_url"}) + return + } + rawProxySnake = strings.TrimSpace(s) + } + if v, exists := bodyMap["proxy-url"]; exists && v != nil { + s, ok := v.(string) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid proxy_url"}) + return + } + rawProxyKebab = strings.TrimSpace(s) + } + if rawProxySnake != "" && rawProxyKebab != "" && rawProxySnake != rawProxyKebab { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid proxy_url"}) + return + } + rawProxy := rawProxySnake + if rawProxy == "" { + rawProxy = rawProxyKebab + } + validatedProxy, errProxy := validateOAuthSessionProxyURL(rawProxy) + if errProxy != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid proxy_url"}) + return + } + if validatedProxy != "" { + c.Set(oauthSessionProxyURLContextKey, validatedProxy) + } + + if rawDomain, exists := bodyMap["domain"]; exists && rawDomain != nil { + domainStr, ok := rawDomain.(string) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid domain"}) + return + } + if trimmed := strings.TrimSpace(domainStr); trimmed != "" { + c.Set(oauthSessionDomainContextKey, trimmed) + } + } else if rawChan, exists := bodyMap["channel"]; exists && rawChan != nil { + chanStr, ok := rawChan.(string) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel"}) + return + } + if trimmed := strings.TrimSpace(chanStr); trimmed != "" { + c.Set(oauthSessionDomainContextKey, trimmed) + } + } + + if rawWebUI, exists := bodyMap["is_webui"]; exists && rawWebUI != nil { + switch v := rawWebUI.(type) { + case bool: + c.Set(oauthSessionIsWebUIContextKey, v) + case string: + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + c.Set(oauthSessionIsWebUIContextKey, true) + case "0", "false", "no", "off", "": + c.Set(oauthSessionIsWebUIContextKey, false) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid is_webui"}) + return + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid is_webui"}) + return + } + } + + extraMeta := make(map[string]any) + for k, v := range bodyMap { + switch k { + case "provider", "proxy_url", "proxy-url", "is_webui": + continue + default: + extraMeta[k] = v + } + } + if len(extraMeta) > 0 { + c.Set(oauthSessionMetadataContextKey, extraMeta) + } + } + } + + c.Set(oauthSessionProviderContextKey, provider) + + switch strings.ToLower(strings.TrimSpace(provider)) { case "": c.JSON(http.StatusBadRequest, gin.H{"error": "provider is required"}) case "claude": diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go index c480c730..a6c02c4d 100644 --- a/internal/api/handlers/management/config_basic.go +++ b/internal/api/handlers/management/config_basic.go @@ -235,8 +235,10 @@ func (h *Handler) PutLogsMaxTotalSizeMB(c *gin.Context) { if value < 0 { value = 0 } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.LogsMaxTotalSizeMB = value - h.persist(c) + h.persistLocked(c) } // ErrorLogsMaxFiles @@ -255,8 +257,10 @@ func (h *Handler) PutErrorLogsMaxFiles(c *gin.Context) { if value < 0 { value = 10 } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.ErrorLogsMaxFiles = value - h.persist(c) + h.persistLocked(c) } // Request log @@ -341,8 +345,10 @@ func (h *Handler) PutRoutingStrategy(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid strategy"}) return } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.Routing.Strategy = normalized - h.persist(c) + h.persistLocked(c) } // Proxy URL @@ -351,6 +357,8 @@ func (h *Handler) PutProxyURL(c *gin.Context) { h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v }) } func (h *Handler) DeleteProxyURL(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() h.cfg.ProxyURL = "" - h.persist(c) + h.persistLocked(c) } diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 0d978ab1..5863ece7 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -65,14 +65,18 @@ func (h *Handler) putStringList(c *gin.Context, set func([]string), after func() } arr = obj.Items } + h.mu.Lock() + defer h.mu.Unlock() set(arr) if after != nil { after() } - h.persist(c) + h.persistLocked(c) } -func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) { +// patchStringList resolves the list through targetOf under h.mu, so it edits the +// current config instead of one a reload has replaced. +func (h *Handler) patchStringList(c *gin.Context, targetOf func() *[]string, after func()) { var body struct { Old *string `json:"old"` New *string `json:"new"` @@ -83,12 +87,15 @@ func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func() c.JSON(400, gin.H{"error": "invalid body"}) return } + h.mu.Lock() + defer h.mu.Unlock() + target := targetOf() if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) { (*target)[*body.Index] = *body.Value if after != nil { after() } - h.persist(c) + h.persistLocked(c) return } if body.Old != nil && body.New != nil { @@ -98,7 +105,7 @@ func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func() if after != nil { after() } - h.persist(c) + h.persistLocked(c) return } } @@ -106,13 +113,18 @@ func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func() if after != nil { after() } - h.persist(c) + h.persistLocked(c) return } c.JSON(400, gin.H{"error": "missing fields"}) } -func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) { +// deleteFromStringList resolves the list through targetOf under h.mu, so it edits +// the current config instead of one a reload has replaced. +func (h *Handler) deleteFromStringList(c *gin.Context, targetOf func() *[]string, after func()) { + h.mu.Lock() + defer h.mu.Unlock() + target := targetOf() if idxStr := c.Query("index"); idxStr != "" { var idx int _, err := fmt.Sscanf(idxStr, "%d", &idx) @@ -121,7 +133,7 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after f if after != nil { after() } - h.persist(c) + h.persistLocked(c) return } } @@ -136,7 +148,7 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after f if after != nil { after() } - h.persist(c) + h.persistLocked(c) return } c.JSON(400, gin.H{"error": "missing index or value"}) @@ -150,10 +162,10 @@ func (h *Handler) PutAPIKeys(c *gin.Context) { }, nil) } func (h *Handler) PatchAPIKeys(c *gin.Context) { - h.patchStringList(c, &h.cfg.APIKeys, func() {}) + h.patchStringList(c, func() *[]string { return &h.cfg.APIKeys }, func() {}) } func (h *Handler) DeleteAPIKeys(c *gin.Context) { - h.deleteFromStringList(c, &h.cfg.APIKeys, func() {}) + h.deleteFromStringList(c, func() *[]string { return &h.cfg.APIKeys }, func() {}) } // gemini-api-key: []GeminiKey @@ -1230,8 +1242,10 @@ func (h *Handler) PutOAuthExcludedModels(c *gin.Context) { } entries = wrapper.Items } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.OAuthExcludedModels = config.NormalizeOAuthExcludedModels(entries) - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) { @@ -1249,6 +1263,8 @@ func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) { return } normalized := config.NormalizeExcludedModels(body.Models) + h.mu.Lock() + defer h.mu.Unlock() if len(normalized) == 0 { if h.cfg.OAuthExcludedModels == nil { c.JSON(404, gin.H{"error": "provider not found"}) @@ -1262,14 +1278,14 @@ func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) { if len(h.cfg.OAuthExcludedModels) == 0 { h.cfg.OAuthExcludedModels = nil } - h.persist(c) + h.persistLocked(c) return } if h.cfg.OAuthExcludedModels == nil { h.cfg.OAuthExcludedModels = make(map[string][]string) } h.cfg.OAuthExcludedModels[provider] = normalized - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) { @@ -1278,6 +1294,8 @@ func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) { c.JSON(400, gin.H{"error": "missing provider"}) return } + h.mu.Lock() + defer h.mu.Unlock() if h.cfg.OAuthExcludedModels == nil { c.JSON(404, gin.H{"error": "provider not found"}) return @@ -1290,7 +1308,7 @@ func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) { if len(h.cfg.OAuthExcludedModels) == 0 { h.cfg.OAuthExcludedModels = nil } - h.persist(c) + h.persistLocked(c) } // oauth-model-alias: map[string][]OAuthModelAlias @@ -1315,8 +1333,10 @@ func (h *Handler) PutOAuthModelAlias(c *gin.Context) { } entries = wrapper.Items } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.OAuthModelAlias = sanitizedOAuthModelAlias(entries) - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchOAuthModelAlias(c *gin.Context) { @@ -1343,6 +1363,8 @@ func (h *Handler) PatchOAuthModelAlias(c *gin.Context) { normalizedMap := sanitizedOAuthModelAlias(map[string][]config.OAuthModelAlias{channel: body.Aliases}) normalized := normalizedMap[channel] + h.mu.Lock() + defer h.mu.Unlock() if len(normalized) == 0 { if h.cfg.OAuthModelAlias == nil { c.JSON(404, gin.H{"error": "channel not found"}) @@ -1356,14 +1378,14 @@ func (h *Handler) PatchOAuthModelAlias(c *gin.Context) { if len(h.cfg.OAuthModelAlias) == 0 { h.cfg.OAuthModelAlias = nil } - h.persist(c) + h.persistLocked(c) return } if h.cfg.OAuthModelAlias == nil { h.cfg.OAuthModelAlias = make(map[string][]config.OAuthModelAlias) } h.cfg.OAuthModelAlias[channel] = normalized - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) { @@ -1375,6 +1397,8 @@ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) { c.JSON(400, gin.H{"error": "missing channel"}) return } + h.mu.Lock() + defer h.mu.Unlock() if h.cfg.OAuthModelAlias == nil { c.JSON(404, gin.H{"error": "channel not found"}) return @@ -1387,7 +1411,7 @@ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) { if len(h.cfg.OAuthModelAlias) == 0 { h.cfg.OAuthModelAlias = nil } - h.persist(c) + h.persistLocked(c) } // oauth-request-scoped-errors: map[string][]RequestScopedErrorRule @@ -1412,8 +1436,10 @@ func (h *Handler) PutOAuthRequestScopedErrors(c *gin.Context) { } entries = wrapper.Items } + h.mu.Lock() + defer h.mu.Unlock() h.cfg.OAuthRequestScopedErrors = sanitizedOAuthRequestScopedErrors(entries) - h.persist(c) + h.persistLocked(c) } func (h *Handler) PatchOAuthRequestScopedErrors(c *gin.Context) { @@ -1440,6 +1466,8 @@ func (h *Handler) PatchOAuthRequestScopedErrors(c *gin.Context) { normalizedMap := sanitizedOAuthRequestScopedErrors(map[string][]config.RequestScopedErrorRule{channel: body.Rules}) normalized := normalizedMap[channel] + h.mu.Lock() + defer h.mu.Unlock() if len(normalized) == 0 { if h.cfg.OAuthRequestScopedErrors == nil { c.JSON(404, gin.H{"error": "channel not found"}) @@ -1453,14 +1481,14 @@ func (h *Handler) PatchOAuthRequestScopedErrors(c *gin.Context) { if len(h.cfg.OAuthRequestScopedErrors) == 0 { h.cfg.OAuthRequestScopedErrors = nil } - h.persist(c) + h.persistLocked(c) return } if h.cfg.OAuthRequestScopedErrors == nil { h.cfg.OAuthRequestScopedErrors = make(map[string][]config.RequestScopedErrorRule) } h.cfg.OAuthRequestScopedErrors[channel] = normalized - h.persist(c) + h.persistLocked(c) } func (h *Handler) DeleteOAuthRequestScopedErrors(c *gin.Context) { @@ -1472,6 +1500,8 @@ func (h *Handler) DeleteOAuthRequestScopedErrors(c *gin.Context) { c.JSON(400, gin.H{"error": "missing channel"}) return } + h.mu.Lock() + defer h.mu.Unlock() if h.cfg.OAuthRequestScopedErrors == nil { c.JSON(404, gin.H{"error": "channel not found"}) return @@ -1484,7 +1514,7 @@ func (h *Handler) DeleteOAuthRequestScopedErrors(c *gin.Context) { if len(h.cfg.OAuthRequestScopedErrors) == 0 { h.cfg.OAuthRequestScopedErrors = nil } - h.persist(c) + h.persistLocked(c) } // codex-api-key: []CodexKey diff --git a/internal/api/handlers/management/config_v8.go b/internal/api/handlers/management/config_v8.go index 7bf13575..5f944095 100644 --- a/internal/api/handlers/management/config_v8.go +++ b/internal/api/handlers/management/config_v8.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "gopkg.in/yaml.v3" ) @@ -18,15 +19,26 @@ const ConfigV8ContextKey = "management.config-v8" // ConfigV8 serves the persisted configuration using v8 names. GET is a view; // only a successful configuration mutation migrates the stored document. +// +// Reads return an ETag holding the SHA-256 of the on-disk config bytes. +// Writes honour an optional If-Match against that token, so a client can +// detect that another writer saved after its read (412) instead of silently +// overwriting the change. Without If-Match, writes stay unconditional. func (h *Handler) ConfigV8(c *gin.Context) { h.mu.Lock() defer h.mu.Unlock() - data, err := os.ReadFile(h.configFilePath) + raw, err := os.ReadFile(h.configFilePath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "read_failed"}) return } - data, _, err = config.NormalizeConfigLayout(data, true) + diskHash := config.ComputeSourceHash(raw) + if c.Request.Method != http.MethodGet && !ifMatchSatisfied(c.Request.Header.Values("If-Match"), diskHash) { + c.Header("ETag", configV8ETag(diskHash)) + c.JSON(http.StatusPreconditionFailed, gin.H{"error": "precondition_failed"}) + return + } + data, _, err := config.NormalizeConfigLayout(raw, true) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid_config", "message": err.Error()}) return @@ -60,6 +72,7 @@ func (h *Handler) ConfigV8(c *gin.Context) { return } c.Header("Cache-Control", "no-store") + c.Header("ETag", configV8ETag(diskHash)) if yamlRequest { c.Data(http.StatusOK, "application/yaml; charset=utf-8", data) return @@ -86,7 +99,7 @@ func (h *Handler) ConfigV8(c *gin.Context) { } else { body, errRead := io.ReadAll(c.Request.Body) if errRead != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_body"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errRead, http.StatusBadRequest), gin.H{"error": "invalid_body"}) return } if !yamlRequest && !json.Valid(body) { @@ -158,6 +171,7 @@ func (h *Handler) ConfigV8(c *gin.Context) { } } stripAPIKeysAuthIndexesFromRoot(root) + config.NormalizeCollectionNodeStyles(root) data, err = yaml.Marshal(&doc) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_config", "message": err.Error()}) @@ -186,12 +200,66 @@ func (h *Handler) ConfigV8(c *gin.Context) { } // Runtime-only ownership state is never sourced from an uploaded YAML document. next.Home = h.cfg.Home + // WriteConfig may normalize the bytes, so report the version actually on + // disk. If it cannot be read back, omit the ETag instead of guessing one. + // Like Home, SourceHash keeps its runtime owner and is published by the + // reload, so it is not overwritten on next here. + if saved, errSaved := os.ReadFile(h.configFilePath); errSaved == nil { + c.Header("ETag", configV8ETag(config.ComputeSourceHash(saved))) + } h.cfg = next snapshot := h.reloadSnapshotConfigLocked() h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), snapshot) c.JSON(http.StatusOK, gin.H{"status": "ok", "config-version": 8}) } +// configV8ETag renders a config source hash as a strong ETag. +func configV8ETag(hash string) string { + return `"` + hash + `"` +} + +// ifMatchSatisfied reports whether the optional If-Match header lines pass +// the RFC 9110 strong comparison against currentHash. Repeated lines form +// one list. An absent header always passes; a present but blank header +// carries no tag and fails; the whole field "*" matches any current +// version; weak and malformed entity tags never match. +func ifMatchSatisfied(values []string, currentHash string) bool { + if len(values) == 0 { + return true + } + field := strings.TrimSpace(strings.Join(values, ",")) + if field == "*" { + return true + } + for _, tag := range splitEntityTagList(field) { + tag = strings.TrimSpace(tag) + if len(tag) >= 2 && tag[0] == '"' && tag[len(tag)-1] == '"' && !strings.Contains(tag[1:len(tag)-1], `"`) && tag[1:len(tag)-1] == currentHash { + return true + } + } + return false +} + +// splitEntityTagList splits an If-Match field value on list commas. A comma +// inside a quoted entity tag is tag content, not a separator. +func splitEntityTagList(field string) []string { + parts := []string{} + start := 0 + quoted := false + for i := 0; i < len(field); i++ { + switch field[i] { + case '"': + quoted = !quoted + case ',': + if !quoted { + parts = append(parts, field[start:i]) + start = i + 1 + } + } + } + return append(parts, field[start:]) +} + var v8ICEServersPath = []string{"oauth", "providers", "codex", "live-media-relay", "ice-servers"} // JSON reads redact TURN secrets. Preserve omitted credentials when a client diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go index 21630e85..529c3a8b 100644 --- a/internal/api/handlers/management/handler.go +++ b/internal/api/handlers/management/handler.go @@ -61,6 +61,8 @@ type Handler struct { pluginStoreHTTPClient pluginstore.HTTPDoer pluginStoreRateLimiter *pluginstore.GitHubRateLimiter pluginReleases pluginReleaseCache + siwcOAuthFactory func(*config.Config) siwcOAuthService + siwcCommitMu sync.Mutex } type configReloadSnapshot struct { @@ -74,7 +76,7 @@ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Man envSecret = strings.TrimSpace(envSecret) h := &Handler{ - cfg: cfg, + cfg: cfg.CloneForRuntime(), configFilePath: configFilePath, failedAttempts: make(map[string]*attemptInfo), authManager: manager, @@ -122,13 +124,64 @@ func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manag } // SetConfig updates the in-memory config reference when the server hot-reloads. +// A config parsed from an older version of the config file is ignored: a later +// save already changed the file, and the reload for that save publishes it. func (h *Handler) SetConfig(cfg *config.Config) { if h == nil { return } + cloned := cfg.CloneForRuntime() h.mu.Lock() - h.cfg = cfg - h.mu.Unlock() + defer h.mu.Unlock() + if h.isStaleConfigFileSnapshotLocked(cloned) { + log.Debug("management: ignoring config reload parsed from an older config file version") + return + } + h.cfg = cloned +} + +// configSnapshot reads h.cfg under h.mu, for code that runs outside the request holding it. +func (h *Handler) configSnapshot() *config.Config { + h.mu.Lock() + defer h.mu.Unlock() + return h.cfg +} + +// oauthConfigWithProxy returns a configuration snapshot for an OAuth session. +// When proxyURL is non-empty, it returns a cloned config with ProxyURL overridden +// without mutating the shared handler configuration. +func (h *Handler) oauthConfigWithProxy(proxyURL string) *config.Config { + cfg := h.configSnapshot() + proxyURL = strings.TrimSpace(proxyURL) + if proxyURL == "" { + return cfg + } + if cfg == nil { + return &config.Config{ + SDKConfig: config.SDKConfig{ProxyURL: proxyURL}, + } + } + cloned := cfg.CloneForRuntime() + if cloned == nil { + copied := *cfg + cloned = &copied + } + cloned.ProxyURL = proxyURL + return cloned +} + +// isStaleConfigFileSnapshotLocked reports whether cfg was parsed from config file +// bytes that are no longer on disk. Management saves hold h.mu, so none can land +// during the check. Callers must hold h.mu. +func (h *Handler) isStaleConfigFileSnapshotLocked(cfg *config.Config) bool { + if cfg == nil || cfg.SourceHash == "" || h.configFilePath == "" { + return false + } + data, errRead := os.ReadFile(h.configFilePath) + if errRead != nil { + return false + } + return config.ComputeSourceHash(data) != cfg.SourceHash } // SetAuthManager updates the auth manager reference used by management endpoints. @@ -137,7 +190,9 @@ func (h *Handler) SetAuthManager(manager *coreauth.Manager) { return } h.mu.Lock() - h.authManager = manager + if h.authManager != manager { + h.authManager = manager + } h.mu.Unlock() } @@ -147,7 +202,9 @@ func (h *Handler) SetPluginHost(host *pluginhost.Host) { return } h.mu.Lock() - h.pluginHost = host + if h.pluginHost != host { + h.pluginHost = host + } h.mu.Unlock() } @@ -306,6 +363,7 @@ func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, p return false, http.StatusForbidden, "remote management disabled" } + h.mu.Lock() cfg := h.cfg var ( allowRemote bool @@ -315,6 +373,7 @@ func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, p allowRemote = cfg.RemoteManagement.AllowRemote secretHash = cfg.RemoteManagement.SecretKey } + h.mu.Unlock() if h.allowRemoteOverride { allowRemote = true } @@ -397,13 +456,6 @@ func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, p return true, 0, "" } -// persist saves the current in-memory config to disk. -func (h *Handler) persist(c *gin.Context) bool { - h.mu.Lock() - defer h.mu.Unlock() - return h.persistLocked(c) -} - // persistLocked saves the current in-memory config to disk. // It expects the caller to hold h.mu. func (h *Handler) persistLocked(c *gin.Context) bool { @@ -422,7 +474,8 @@ func (h *Handler) persistLocked(c *gin.Context) bool { return true } -// Helper methods for simple types +// Helper methods for simple types. The setter and the save run under h.mu, so a +// reload publishing h.cfg cannot replace it between the change and the save. func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) { var body struct { Value *bool `json:"value"` @@ -431,8 +484,10 @@ func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) return } + h.mu.Lock() + defer h.mu.Unlock() set(*body.Value) - h.persist(c) + h.persistLocked(c) } func (h *Handler) updateIntField(c *gin.Context, set func(int)) { @@ -443,8 +498,10 @@ func (h *Handler) updateIntField(c *gin.Context, set func(int)) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) return } + h.mu.Lock() + defer h.mu.Unlock() set(*body.Value) - h.persist(c) + h.persistLocked(c) } func (h *Handler) updateStringField(c *gin.Context, set func(string)) { @@ -455,6 +512,8 @@ func (h *Handler) updateStringField(c *gin.Context, set func(string)) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) return } + h.mu.Lock() + defer h.mu.Unlock() set(*body.Value) - h.persist(c) + h.persistLocked(c) } diff --git a/internal/api/handlers/management/model_definitions.go b/internal/api/handlers/management/model_definitions.go index 4e85c72f..e1bbdfd0 100644 --- a/internal/api/handlers/management/model_definitions.go +++ b/internal/api/handlers/management/model_definitions.go @@ -2,9 +2,11 @@ package management import ( "net/http" + "sort" "strings" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost" "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" ) @@ -21,7 +23,10 @@ func (h *Handler) GetStaticModelDefinitions(c *gin.Context) { } models := registry.GetStaticModelDefinitionsByChannel(channel) - if models == nil { + if len(models) == 0 { + models = h.pluginModelDefinitionsByChannel(channel) + } + if len(models) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "unknown channel", "channel": channel}) return } @@ -31,3 +36,30 @@ func (h *Handler) GetStaticModelDefinitions(c *gin.Context) { "models": models, }) } + +func (h *Handler) pluginModelDefinitionsByChannel(channel string) []*registry.ModelInfo { + var host *pluginhost.Host + if h != nil { + h.mu.Lock() + host = h.pluginHost + h.mu.Unlock() + } + if host != nil { + if models := host.ModelsForProvider(channel); len(models) > 0 { + return models + } + } + models := registry.GetGlobalRegistry().GetAvailableModelsByProvider(channel) + if len(models) > 1 { + sort.Slice(models, func(i, j int) bool { + if models[i] == nil { + return models[j] != nil + } + if models[j] == nil { + return false + } + return models[i].ID < models[j].ID + }) + } + return models +} diff --git a/internal/api/handlers/management/oauth_callback.go b/internal/api/handlers/management/oauth_callback.go index b0d3e9d5..030788a7 100644 --- a/internal/api/handlers/management/oauth_callback.go +++ b/internal/api/handlers/management/oauth_callback.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" log "github.com/sirupsen/logrus" ) @@ -26,7 +27,7 @@ func (h *Handler) PostOAuthCallback(c *gin.Context) { var req oauthCallbackRequest if errBindJSON := c.ShouldBindJSON(&req); errBindJSON != nil { - c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid body"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errBindJSON, http.StatusBadRequest), gin.H{"status": "error", "error": "invalid body"}) return } h.handleOAuthCallback(c, req) diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go index 336f9bbc..57563ab4 100644 --- a/internal/api/handlers/management/oauth_sessions.go +++ b/internal/api/handlers/management/oauth_sessions.go @@ -4,12 +4,14 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "path/filepath" "strings" "sync" "time" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil" log "github.com/sirupsen/logrus" ) @@ -32,14 +34,21 @@ var ( errOAuthSessionExists = errors.New("oauth session already exists") ) +// ErrSIWCCallbackRequiresV8 prevents legacy callback sinks from accepting SIWC codes. +var ErrSIWCCallbackRequiresV8 = fmt.Errorf("%w: SIWC callbacks require /v8/management/oauth/callback", errOAuthSessionNotPending) + type oauthSession struct { - Provider string - Status string - Source string - Metadata map[string]any - Completed bool - CreatedAt time.Time - ExpiresAt time.Time + Provider string + Status string + Source string + ProxyURL string + Metadata map[string]any + SIWC *siwcBindSession + Committing bool + SIWCCommitting bool + Completed bool + CreatedAt time.Time + ExpiresAt time.Time } type oauthSessionStore struct { @@ -66,15 +75,23 @@ func newOAuthSessionStore(ttl time.Duration) *oauthSessionStore { func (s *oauthSessionStore) purgeExpiredLocked(now time.Time) { for state, session := range s.sessions { - if !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) { + if (session.Completed || (!session.Committing && !session.SIWCCommitting)) && !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) { + if session.SIWC != nil { + session.SIWC.cancel() + } delete(s.sessions, state) } } } func (s *oauthSessionStore) Register(state, provider string) { + s.RegisterWithProxy(state, provider, "") +} + +func (s *oauthSessionStore) RegisterWithProxy(state, provider, proxyURL string) { state = strings.TrimSpace(state) provider = strings.ToLower(strings.TrimSpace(provider)) + proxyURL = strings.TrimSpace(proxyURL) if state == "" || provider == "" { return } @@ -88,14 +105,20 @@ func (s *oauthSessionStore) Register(state, provider string) { Provider: provider, Status: "", Source: oauthSessionSourceBuiltin, + ProxyURL: proxyURL, CreatedAt: now, ExpiresAt: now.Add(s.ttl), } } func (s *oauthSessionStore) RegisterPlugin(state, provider string, metadata map[string]any) error { + return s.RegisterPluginWithProxy(state, provider, metadata, "") +} + +func (s *oauthSessionStore) RegisterPluginWithProxy(state, provider string, metadata map[string]any, proxyURL string) error { state = strings.TrimSpace(state) provider = strings.ToLower(strings.TrimSpace(provider)) + proxyURL = strings.TrimSpace(proxyURL) if state == "" || provider == "" { return fmt.Errorf("%w: empty state or provider", errInvalidOAuthState) } @@ -115,6 +138,7 @@ func (s *oauthSessionStore) RegisterPlugin(state, provider string, metadata map[ Provider: provider, Status: "", Source: oauthSessionSourcePlugin, + ProxyURL: proxyURL, Metadata: cloneOAuthSessionMetadata(metadata), CreatedAt: now, ExpiresAt: now.Add(s.ttl), @@ -136,14 +160,78 @@ func (s *oauthSessionStore) SetError(state, message string) { s.mu.Lock() defer s.mu.Unlock() + s.purgeExpiredLocked(now) + session, ok := s.sessions[state] + if !ok || session.Completed || session.Committing { + return + } + session.Status = redactSessionProxyInString(message, session.ProxyURL) + session.ProxyURL = "" + session.SIWCCommitting = false + session.ExpiresAt = now.Add(s.ttl) + s.sessions[state] = session +} + +func (s *oauthSessionStore) FailCommit(state, message string) { + state = strings.TrimSpace(state) + message = strings.TrimSpace(message) + if state == "" { + return + } + if message == "" { + message = "Authentication failed" + } + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + s.purgeExpiredLocked(now) session, ok := s.sessions[state] if !ok || session.Completed { return } - session.Status = message + session.Status = redactSessionProxyInString(message, session.ProxyURL) + session.ProxyURL = "" + session.Committing = false + session.SIWCCommitting = false + session.ExpiresAt = now.Add(s.ttl) + s.sessions[state] = session +} + +func (s *oauthSessionStore) FailPendingPoll(state, message string) (errStatus string, completed bool, committing bool, ok bool) { + state = strings.TrimSpace(state) + message = strings.TrimSpace(message) + if state == "" { + return "", false, false, false + } + if message == "" { + message = "Authentication failed" + } + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + s.purgeExpiredLocked(now) + session, exists := s.sessions[state] + if !exists { + return "", false, false, false + } + if session.Completed { + return "", true, false, true + } + if session.Committing || session.SIWCCommitting { + return "", false, true, true + } + if session.Status != "" { + return session.Status, false, false, true + } + session.Status = redactSessionProxyInString(message, session.ProxyURL) + session.ProxyURL = "" session.ExpiresAt = now.Add(s.ttl) s.sessions[state] = session + return session.Status, false, false, true } func (s *oauthSessionStore) Complete(state string) { @@ -162,7 +250,10 @@ func (s *oauthSessionStore) Complete(state string) { return } session.Status = "" + session.ProxyURL = "" session.Metadata = nil + session.Committing = false + session.SIWCCommitting = false session.Completed = true session.ExpiresAt = now.Add(s.completedTTL) s.sessions[state] = session @@ -182,9 +273,12 @@ func (s *oauthSessionStore) CompleteProvider(provider string, source string) int s.purgeExpiredLocked(now) removed := 0 for state, session := range s.sessions { - if !session.Completed && strings.EqualFold(session.Provider, provider) && (source == "" || session.Source == source) { + if session.SIWC == nil && !session.Completed && !session.Committing && !session.SIWCCommitting && strings.EqualFold(session.Provider, provider) && (source == "" || session.Source == source) { session.Status = "" + session.ProxyURL = "" session.Metadata = nil + session.Committing = false + session.SIWCCommitting = false session.Completed = true session.ExpiresAt = now.Add(s.completedTTL) s.sessions[state] = session @@ -229,6 +323,30 @@ func (s *oauthSessionStore) IsPending(state, provider string) bool { return strings.EqualFold(session.Provider, provider) } +func (s *oauthSessionStore) claimCommit(state, provider string) bool { + state = strings.TrimSpace(state) + provider = strings.ToLower(strings.TrimSpace(provider)) + if state == "" { + return false + } + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + s.purgeExpiredLocked(now) + session, ok := s.sessions[state] + if !ok || session.Completed || session.Status != "" || session.Committing || session.SIWCCommitting { + return false + } + if provider != "" && !strings.EqualFold(session.Provider, provider) { + return false + } + session.Committing = true + s.sessions[state] = session + return true +} + // Cancel removes a pending OAuth session so background waiters exit without saving credentials. // Returns true when a pending session was cancelled. func (s *oauthSessionStore) Cancel(state string) bool { @@ -236,6 +354,12 @@ func (s *oauthSessionStore) Cancel(state string) bool { if state == "" { return false } + // SIWC completion and cancellation share a per-session gate. A cancellation + // that wins this gate prevents persistence; after a committed bind it is a no-op. + if session, ok := s.Get(state); ok && session.SIWC != nil { + session.SIWC.mu.Lock() + defer session.SIWC.mu.Unlock() + } now := time.Now() s.mu.Lock() @@ -243,9 +367,13 @@ func (s *oauthSessionStore) Cancel(state string) bool { s.purgeExpiredLocked(now) session, ok := s.sessions[state] - if !ok || session.Completed || session.Status != "" { + if !ok || session.Completed || session.Status != "" || session.Committing || session.SIWCCommitting { return false } + if session.SIWC != nil { + session.SIWC.cancel() + session.SIWC.pkce = nil + } delete(s.sessions, state) return true } @@ -265,12 +393,50 @@ var oauthSessions = newOAuthSessionStore(oauthSessionTTL) func RegisterOAuthSession(state, provider string) { oauthSessions.Register(state, provider) } +func RegisterOAuthSessionWithProxy(state, provider, proxyURL string) { + oauthSessions.RegisterWithProxy(state, provider, proxyURL) +} + func RegisterPluginOAuthSession(state, provider string, metadata map[string]any) error { return oauthSessions.RegisterPlugin(state, provider, metadata) } +func RegisterPluginOAuthSessionWithProxy(state, provider string, metadata map[string]any, proxyURL string) error { + return oauthSessions.RegisterPluginWithProxy(state, provider, metadata, proxyURL) +} + +func GetOAuthSessionProxyURL(state string) string { + session, ok := oauthSessions.Get(state) + if !ok || session.Completed || session.Status != "" { + return "" + } + return strings.TrimSpace(session.ProxyURL) +} + +func redactSessionProxyInString(text, proxyURL string) string { + proxyURL = strings.TrimSpace(proxyURL) + if text == "" || proxyURL == "" { + return text + } + redacted := proxyutil.Redact(proxyURL) + if redacted != "" && redacted != proxyURL { + text = strings.ReplaceAll(text, proxyURL, redacted) + } + if parsed, errParse := url.Parse(proxyURL); errParse == nil && parsed != nil && parsed.User != nil { + if userInfo := parsed.User.String(); userInfo != "" { + text = strings.ReplaceAll(text, userInfo, "redacted") + } + if password, hasPass := parsed.User.Password(); hasPass && password != "" { + text = strings.ReplaceAll(text, password, "redacted") + } + } + return text +} + func SetOAuthSessionError(state, message string) { oauthSessions.SetError(state, message) } +func FailOAuthSessionCommit(state, message string) { oauthSessions.FailCommit(state, message) } + func CompleteOAuthSession(state string) { oauthSessions.Complete(state) } func CompleteOAuthSessionsByProvider(provider string) int { @@ -312,6 +478,16 @@ func guardOAuthSessionPendingForSave(state, provider string) error { return errOAuthSessionNotPending } +// claimOAuthSessionCommit atomically verifies that the session is still pending +// and transitions it into the committing state so a concurrent CancelOAuthSession +// cannot report true while credentials are being persisted. +func claimOAuthSessionCommit(state, provider string) error { + if oauthSessions.claimCommit(state, provider) { + return nil + } + return errOAuthSessionNotPending +} + // CancelOAuthSession cancels a pending OAuth session by state. // Background callback and device-code waiters observe IsOAuthSessionPending as false and exit without saving credentials. func CancelOAuthSession(state string) bool { @@ -481,6 +657,9 @@ func WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, err if err != nil { return "", err } + if session, ok := oauthSessions.Get(state); ok && session.SIWC != nil { + return "", ErrSIWCCallbackRequiresV8 + } if !IsOAuthSessionPending(state, canonicalProvider) { return "", errOAuthSessionNotPending } diff --git a/internal/api/handlers/management/oauth_siwc.go b/internal/api/handlers/management/oauth_siwc.go new file mode 100644 index 00000000..4f271821 --- /dev/null +++ b/internal/api/handlers/management/oauth_siwc.go @@ -0,0 +1,471 @@ +package management + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" + "github.com/router-for-me/CLIProxyAPI/v8/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" +) + +const siwcBindTTL = 5 * time.Minute + +type siwcOAuthService interface { + GenerateSIWCAuthURL(state, nonce, deviceID, redirectURI string, pkce *codex.PKCECodes) (string, error) + ExchangeSIWCCodeForTokens(ctx context.Context, code, clientID, redirectURI, deviceID string, pkce *codex.PKCECodes) (*codex.CodexSIWCTokenData, error) +} + +// siwcBindSession is server-owned: callbacks cannot select a different target, +// redirect URI, PKCE verifier, or token endpoint. It never contains an auth snapshot. +type siwcBindSession struct { + mu sync.Mutex + owner *Handler + manager *coreauth.Manager + service siwcOAuthService + targetID string + epoch uint64 + accountID string + path string + authDir string + deviceID string + redirect string + pkce *codex.PKCECodes + ctx context.Context + cancel context.CancelFunc + exchanging bool + exchangeDone chan struct{} +} + +func siwcBindEligible(auth *coreauth.Auth) bool { + if auth == nil || !strings.EqualFold(auth.Provider, "codex") || auth.AuthSourceKind() != coreauth.AuthSourceFile || auth.AuthKind() != coreauth.AuthKindOAuth || isRuntimeOnlyAuth(auth) { + return false + } + for _, key := range []string{"access_token", "refresh_token"} { + value, _ := auth.Metadata[key].(string) + if strings.TrimSpace(value) == "" { + return false + } + } + return strings.TrimSpace(authAttribute(auth, "path")) != "" +} + +// resolveSIWCBindFile resolves only a server-selected auth path, confines it to +// auth-dir, and reads the current credential rather than trusting a cached token. +func resolveSIWCBindFile(authDir, path string) (string, map[string]any, error) { + dir, errResolve := util.ResolveAuthDir(authDir) + if errResolve != nil || strings.TrimSpace(dir) == "" { + return "", nil, errors.New("auth directory is unavailable") + } + dir, errAbs := filepath.Abs(dir) + if errAbs != nil { + return "", nil, errAbs + } + dir, errDir := filepath.EvalSymlinks(dir) + if errDir != nil { + return "", nil, errDir + } + dir, errAbs = filepath.Abs(dir) + if errAbs != nil { + return "", nil, errAbs + } + path = strings.TrimSpace(path) + if path == "" { + return "", nil, errors.New("credential must be an existing regular file") + } + if !filepath.IsAbs(path) { + path, errAbs = filepath.Abs(path) + if errAbs != nil { + return "", nil, errAbs + } + } + // Match the commit-time rule: directory aliases are allowed, but the + // credential itself must not be a symlink. + originalInfo, errLstat := os.Lstat(path) + if errLstat != nil || !originalInfo.Mode().IsRegular() { + return "", nil, errors.New("credential must be an existing regular file") + } + realPath, errPath := filepath.EvalSymlinks(path) + if errPath != nil { + return "", nil, errPath + } + realPath, errAbs = filepath.Abs(realPath) + if errAbs != nil { + return "", nil, errAbs + } + relative, errRel := filepath.Rel(dir, realPath) + if errRel != nil || relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { + return "", nil, errors.New("credential must belong to auth-dir") + } + info, errStat := os.Stat(realPath) + if errStat != nil || !info.Mode().IsRegular() { + return "", nil, errors.New("credential must be an existing regular file") + } + data, errRead := os.ReadFile(realPath) + if errRead != nil { + return "", nil, errRead + } + var metadata map[string]any + if errJSON := json.Unmarshal(data, &metadata); errJSON != nil { + return "", nil, errors.New("invalid credential JSON") + } + provider, _ := metadata["type"].(string) + if !strings.EqualFold(strings.TrimSpace(provider), "codex") { + return "", nil, errors.New("credential type must be codex") + } + for _, key := range []string{"access_token", "refresh_token"} { + value, _ := metadata[key].(string) + if strings.TrimSpace(value) == "" { + return "", nil, fmt.Errorf("codex credential requires %s", key) + } + } + return realPath, metadata, nil +} + +// GetSIWCStatus returns the read-only local SIWC status ("off", "valid", or "invalid") +// for a Codex credential without triggering any upstream refresh or verification request. +func (h *Handler) GetSIWCStatus(c *gin.Context) { + if h == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "handler unavailable"}) + return + } + name := strings.TrimSpace(c.Query("name")) + if name == "" { + name = strings.TrimSpace(c.Query("id")) + } + authIndex := strings.TrimSpace(c.Query("auth_index")) + if authIndex == "" { + authIndex = strings.TrimSpace(c.Query("authIndex")) + } + if name == "" && authIndex == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name or auth_index is required"}) + return + } + h.mu.Lock() + cfg, manager := h.cfg, h.authManager + h.mu.Unlock() + if manager == nil && (cfg == nil || strings.TrimSpace(cfg.AuthDir) == "") { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth manager unavailable"}) + return + } + var target *coreauth.Auth + if manager != nil { + if name != "" { + target, _ = h.lookupAuthFile(name, authIndex) + } else { + target = h.authByIndex(authIndex) + } + } else if name != "" && authIndex == "" && !isUnsafeAuthFileName(name) && strings.HasSuffix(strings.ToLower(name), ".json") { + full := filepath.Join(cfg.AuthDir, name) + if data, errRead := os.ReadFile(full); errRead == nil { + var metadata map[string]any + if json.Unmarshal(data, &metadata) == nil && metadata != nil { + provider, _ := metadata["type"].(string) + target = &coreauth.Auth{ID: name, FileName: name, Provider: strings.TrimSpace(provider), Metadata: metadata} + } + } + } + if target == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) + return + } + if !coreauth.IsCodexAuthRecord(target) { + c.JSON(http.StatusBadRequest, gin.H{"error": "SIWC status requires a Codex credential"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": string(target.CodexSIWCStatus(time.Now()))}) +} + +// StartSIWCBind creates a v8-only OAuth session for an existing Codex credential. +// Remote browsers complete it by submitting the full loopback callback URL; no +// listener on the server's port 1455 is required or started by this endpoint. +func (h *Handler) StartSIWCBind(c *gin.Context) { + var req struct { + Name string `json:"name"` + } + if errBind := c.ShouldBindJSON(&req); errBind != nil { + c.JSON(clienterror.HTTPStatusFromErrorOr(errBind, http.StatusBadRequest), gin.H{"error": "invalid request body"}) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + h.siwcCommitMu.Lock() + defer h.siwcCommitMu.Unlock() + h.mu.Lock() + cfg, manager, factory := h.cfg.CloneForRuntime(), h.authManager, h.siwcOAuthFactory + h.mu.Unlock() + if cfg == nil || manager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth manager unavailable"}) + return + } + target, ok := manager.GetByID(req.Name) + if !ok { + for _, auth := range manager.List() { + if auth.FileName == req.Name { + target = auth + break + } + } + } + if target == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"}) + return + } + if !siwcBindEligible(target) { + c.JSON(http.StatusBadRequest, gin.H{"error": "SIWC binding requires a file-backed Codex OAuth credential"}) + return + } + path, metadata, errPath := resolveSIWCBindFile(cfg.AuthDir, authAttribute(target, "path")) + if errPath != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "SIWC binding target is unavailable or invalid"}) + return + } + accountID, _ := target.Metadata["account_id"].(string) + if (accountID != "" && metadata["account_id"] != accountID) || (accountID == "" && metadata["account_id"] != nil && metadata["account_id"] != "") { + c.JSON(http.StatusBadRequest, gin.H{"error": "SIWC binding target is unavailable or invalid"}) + return + } + deviceID := uuid.NewString() + if section, okSection := metadata["siwc"].(map[string]any); okSection { + if existing, _ := section["device_id"].(string); codex.IsValidUUID(existing) { + deviceID = strings.ToLower(strings.TrimSpace(existing)) + } + } + pkce, errPKCE := codex.GeneratePKCECodes() + state, errState := codex.GenerateRandomBase64URL() + nonce, errNonce := codex.GenerateRandomBase64URL() + if errPKCE != nil || errState != nil || errNonce != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize SIWC authorization"}) + return + } + var service siwcOAuthService = codex.NewCodexAuth(cfg) + if factory != nil { + service = factory(cfg) + } + authURL, errURL := service.GenerateSIWCAuthURL(state, nonce, deviceID, codex.SIWCRedirectURI, pkce) + if errURL != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate SIWC authorization URL"}) + return + } + ctx, cancel := context.WithTimeout(PopulateAuthContext(context.Background(), c), siwcBindTTL) + flow := &siwcBindSession{owner: h, manager: manager, service: service, targetID: target.ID, epoch: target.RegistrationEpoch, + accountID: accountID, path: path, authDir: cfg.AuthDir, deviceID: deviceID, redirect: codex.SIWCRedirectURI, pkce: pkce, ctx: ctx, cancel: cancel} + if !oauthSessions.registerSIWC(state, flow) { + cancel() + c.JSON(http.StatusConflict, gin.H{"error": "a SIWC binding is already pending for this credential"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state, "flow": "siwc_bind", "expires_in": int(siwcBindTTL.Seconds())}) +} + +func (s *oauthSessionStore) registerSIWC(state string, flow *siwcBindSession) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + s.purgeExpiredLocked(now) + if _, exists := s.sessions[state]; exists { + return false + } + for _, session := range s.sessions { + if session.SIWC != nil && !session.Completed && session.Status == "" && session.SIWC.manager == flow.manager && session.SIWC.targetID == flow.targetID { + return false + } + } + s.sessions[state] = oauthSession{Provider: "codex", Source: oauthSessionSourceBuiltin, SIWC: flow, CreatedAt: now, ExpiresAt: now.Add(siwcBindTTL)} + return true +} + +// claimSIWCCommit keeps a session addressable until an already-started disk +// commit finishes. Expiration must not turn a durable successful bind into an +// unknown-state response. Cancellation is serialized by the session gate. +func (s *oauthSessionStore) claimSIWCCommit(state string, flow *siwcBindSession) bool { + s.mu.Lock() + defer s.mu.Unlock() + s.purgeExpiredLocked(time.Now()) + session, ok := s.sessions[state] + if !ok || session.SIWC != flow || session.Completed || session.Status != "" || session.SIWCCommitting || flow.ctx.Err() != nil { + return false + } + session.SIWCCommitting = true + s.sessions[state] = session + return true +} + +type siwcCallbackRequest struct { + oauthCallbackRequest + ClientID string `json:"client_id"` +} + +func (h *Handler) PostOAuthCallbackV8(c *gin.Context) { + var req siwcCallbackRequest + if errBind := c.ShouldBindJSON(&req); errBind != nil { + c.JSON(clienterror.HTTPStatusFromErrorOr(errBind, http.StatusBadRequest), gin.H{"status": "error", "error": "invalid body"}) + return + } + h.handleOAuthCallbackV8(c, req) +} + +func (h *Handler) GetOAuthCallbackV8(c *gin.Context) { + h.handleOAuthCallbackV8(c, siwcCallbackRequest{oauthCallbackRequest: oauthCallbackRequest{ + Provider: c.Query("provider"), State: c.Query("state"), Code: c.Query("code"), + Error: firstNonEmpty(c.Query("error"), c.Query("error_description")), + }, ClientID: c.Query("client_id")}) +} + +func (h *Handler) handleOAuthCallbackV8(c *gin.Context, req siwcCallbackRequest) { + state := strings.TrimSpace(req.State) + var redirect *url.URL + if raw := strings.TrimSpace(req.RedirectURL); raw != "" { + var errParse error + redirect, errParse = url.Parse(raw) + if errParse != nil { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid redirect_url"}) + return + } + if state == "" { + state = strings.TrimSpace(redirect.Query().Get("state")) + } + } + session, ok := oauthSessions.Get(state) + if !ok || session.SIWC == nil { + h.handleOAuthCallback(c, req.oauthCallbackRequest) + return + } + flow := session.SIWC + if c.Request.Method == http.MethodGet { + query := c.Request.URL.Query() + for _, key := range []string{"provider", "state", "code", "client_id", "error"} { + if len(query[key]) > 1 { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "conflicting callback parameters"}) + return + } + } + } + if flow.owner != h { + c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "unknown or expired state"}) + return + } + if provider := strings.TrimSpace(req.Provider); provider != "" { + canonical, errProvider := NormalizeOAuthProvider(provider) + if errProvider != nil || canonical != "codex" { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "provider does not match state"}) + return + } + } + code, clientID, oauthError := strings.TrimSpace(req.Code), strings.TrimSpace(req.ClientID), strings.TrimSpace(req.Error) + if redirect != nil { + expected, _ := url.Parse(flow.redirect) + if redirect.Scheme != expected.Scheme || redirect.Host != expected.Host || redirect.Path != expected.Path || redirect.User != nil || redirect.Fragment != "" { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "unexpected SIWC callback URL"}) + return + } + query := redirect.Query() + for key, target := range map[string]*string{"state": &state, "code": &code, "client_id": &clientID, "error": &oauthError} { + value := strings.TrimSpace(query.Get(key)) + if len(query[key]) > 1 || (value != "" && *target != "" && value != *target) { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "conflicting callback parameters"}) + return + } + if *target == "" { + *target = value + } + } + } + if oauthError == "" && (code == "" || clientID == "") { + c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "SIWC callback requires code and client_id"}) + return + } + flow.mu.Lock() + defer flow.mu.Unlock() + if flow.exchanging || flow.ctx.Err() != nil || !oauthSessions.IsPending(state, "codex") { + c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "SIWC session is no longer accepting callbacks"}) + return + } + if oauthError != "" { + flow.fail(state, "SIWC authorization was denied or failed") + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + return + } + flow.exchanging = true + flow.exchangeDone = make(chan struct{}) + go flow.exchange(state, code, clientID) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// fail intentionally never publishes upstream response bodies or callback values. +// Caller holds the session gate. +func (flow *siwcBindSession) fail(state, message string) { + SetOAuthSessionError(state, message) + flow.cancel() + flow.pkce = nil +} + +func (flow *siwcBindSession) exchange(state, code, clientID string) { + defer close(flow.exchangeDone) + // Copy under the gate so cancellation and terminal cleanup cannot race PKCE use. + flow.mu.Lock() + pkce := flow.pkce + flow.mu.Unlock() + tokenData, errExchange := flow.service.ExchangeSIWCCodeForTokens(flow.ctx, code, clientID, flow.redirect, flow.deviceID, pkce) + flow.mu.Lock() + flow.owner.siwcCommitMu.Lock() + var persisted *coreauth.Auth + defer func() { + flow.owner.siwcCommitMu.Unlock() + flow.mu.Unlock() + if persisted != nil { + flow.owner.mu.Lock() + hook := flow.owner.postAuthPersistHook + flow.owner.mu.Unlock() + if hook != nil { + if errHook := hook(context.Background(), persisted); errHook != nil { + log.Warn("SIWC binding saved but post-auth notification failed") + } + } + } + }() + if flow.ctx.Err() != nil || !oauthSessions.IsPending(state, "codex") { + flow.pkce = nil + return + } + if errExchange != nil || tokenData == nil { + flow.fail(state, "Failed to exchange SIWC authorization code; start a new binding") + return + } + path, _, errPath := resolveSIWCBindFile(flow.authDir, flow.path) + if errPath != nil || path != flow.path { + flow.fail(state, "SIWC binding target is no longer available") + return + } + if !oauthSessions.claimSIWCCommit(state, flow) { + flow.cancel() + flow.pkce = nil + return + } + updated, errBind := flow.manager.BindCodexSIWC(flow.ctx, flow.targetID, flow.epoch, flow.accountID, tokenData) + if errBind != nil { + flow.fail(state, "Failed to save SIWC binding; the target may have changed") + return + } + // Binding is durable and published before the session reports success. + persisted = updated + CompleteOAuthSession(state) + flow.cancel() + flow.pkce = nil +} diff --git a/internal/api/handlers/management/oauth_siwc_mutations.go b/internal/api/handlers/management/oauth_siwc_mutations.go new file mode 100644 index 00000000..71537d28 --- /dev/null +++ b/internal/api/handlers/management/oauth_siwc_mutations.go @@ -0,0 +1,76 @@ +package management + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +type siwcFileMutationKey struct{} + +// UploadAuthFileV8 coordinates file replacement with explicit SIWC binding. +func (h *Handler) UploadAuthFileV8(c *gin.Context) { + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), siwcFileMutationKey{}, true)) + h.UploadAuthFile(c) +} + +// DeleteAuthFileV8 coordinates file removal with explicit SIWC binding. +func (h *Handler) DeleteAuthFileV8(c *gin.Context) { + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), siwcFileMutationKey{}, true)) + h.DeleteAuthFile(c) +} + +// beginSIWCFileMutation fences disk mutation through manager publication. Legacy +// callers deliberately retain their existing behavior. Never acquire flow.mu: +// exchange commits acquire flow.mu before this gate and then the session store. +func (h *Handler) beginSIWCFileMutation(ctx context.Context, path string) func() { + if ctx == nil || ctx.Value(siwcFileMutationKey{}) != true { + return func() {} + } + h.siwcCommitMu.Lock() + oauthSessions.mu.Lock() + now := time.Now() + for state, session := range oauthSessions.sessions { + flow := session.SIWC + if flow == nil || flow.owner != h || session.Completed || session.Status != "" || !sameSIWCMutationPath(path, flow.path) { + continue + } + // Even identical reuploads invalidate the old authorization intent. + session.Status = "SIWC binding target changed; start a new binding" + session.ExpiresAt = now.Add(oauthSessions.completedTTL) + oauthSessions.sessions[state] = session + flow.cancel() + } + oauthSessions.mu.Unlock() + return h.siwcCommitMu.Unlock +} + +func sameSIWCMutationPath(a, b string) bool { + // SameFile also handles case-insensitive filesystems on macOS and aliases. + if ai, errA := os.Stat(a); errA == nil { + if bi, errB := os.Stat(b); errB == nil && os.SameFile(ai, bi) { + return true + } + } + canonical := func(path string) string { + if real, err := filepath.EvalSymlinks(path); err == nil { + path = real + } else if parent, errParent := filepath.EvalSymlinks(filepath.Dir(path)); errParent == nil { + path = filepath.Join(parent, filepath.Base(path)) + } + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + return filepath.Clean(path) + } + a, b = canonical(a), canonical(b) + if runtime.GOOS == "windows" { + return strings.EqualFold(a, b) + } + return a == b +} diff --git a/internal/api/handlers/management/plugin_quota.go b/internal/api/handlers/management/plugin_quota.go index be8d1e73..f8a6cdb6 100644 --- a/internal/api/handlers/management/plugin_quota.go +++ b/internal/api/handlers/management/plugin_quota.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" log "github.com/sirupsen/logrus" @@ -252,7 +253,7 @@ func (h *Handler) FetchPluginQuota(c *gin.Context) { pluginID := strings.TrimSpace(c.Param("id")) var body credentialQuotaRequest if errBind := c.ShouldBindJSON(&body); errBind != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errBind, http.StatusBadRequest), gin.H{"error": "invalid request body"}) return } authIndex := body.resolveAuthIndex() @@ -306,7 +307,12 @@ func (h *Handler) ResetPluginQuota(c *gin.Context) { } if authIndex == "" { var body credentialQuotaRequest - _ = c.ShouldBindJSON(&body) + if errBind := c.ShouldBindJSON(&body); errBind != nil { + if status := clienterror.HTTPStatusFromError(errBind); status == http.StatusRequestEntityTooLarge { + c.JSON(status, gin.H{"error": errBind.Error()}) + return + } + } authIndex = body.resolveAuthIndex() } if authIndex == "" { diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go index 05b80d00..dc214243 100644 --- a/internal/api/handlers/management/plugin_store.go +++ b/internal/api/handlers/management/plugin_store.go @@ -13,6 +13,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/internal/htmlsanitize" "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost" @@ -228,7 +229,7 @@ func (h *Handler) installPluginFromStore(c *gin.Context, goos, goarch string) { } requestedVersion, errVersionRequest := pluginInstallRequestedVersion(c) if errVersionRequest != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request", "message": errVersionRequest.Error()}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errVersionRequest, http.StatusBadRequest), gin.H{"error": "invalid_request", "message": errVersionRequest.Error()}) return } installCtx := c.Request.Context() diff --git a/internal/api/handlers/management/plugins.go b/internal/api/handlers/management/plugins.go index cd7f6fb0..2259abdc 100644 --- a/internal/api/handlers/management/plugins.go +++ b/internal/api/handlers/management/plugins.go @@ -51,6 +51,7 @@ type pluginMetadataInfo struct { } type pluginConfigFieldInfo struct { + Default any `json:"default"` Name string `json:"name"` Type string `json:"type"` EnumValues []string `json:"enum_values"` @@ -469,6 +470,7 @@ func pluginConfigFields(fields []pluginapi.ConfigField) []pluginConfigFieldInfo out = append(out, pluginConfigFieldInfo{ Name: htmlsanitize.String(field.Name), Type: htmlsanitize.String(string(field.Type)), + Default: htmlsanitize.JSONValue(field.Default), EnumValues: htmlsanitize.Strings(field.EnumValues), Description: htmlsanitize.String(field.Description), }) diff --git a/internal/api/handlers/management/quota.go b/internal/api/handlers/management/quota.go index a87a05ef..4b8735d4 100644 --- a/internal/api/handlers/management/quota.go +++ b/internal/api/handlers/management/quota.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" ) // Quota exceeded toggles @@ -34,7 +35,7 @@ func (h *Handler) ResetQuota(c *gin.Context) { AuthIndex string `json:"auth_index"` } if errBindJSON := c.ShouldBindJSON(&req); errBindJSON != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(errBindJSON, http.StatusBadRequest), gin.H{"error": "invalid request body"}) return } diff --git a/internal/api/handlers/management/vertex_import.go b/internal/api/handlers/management/vertex_import.go index 32c89623..5e8a6540 100644 --- a/internal/api/handlers/management/vertex_import.go +++ b/internal/api/handlers/management/vertex_import.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/vertex" + "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" ) @@ -26,7 +27,7 @@ func (h *Handler) ImportVertexCredential(c *gin.Context) { fileHeader, err := c.FormFile("file") if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "file required"}) + c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadRequest), gin.H{"error": "file required"}) return } diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index b55713ae..57352bbe 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -305,12 +305,22 @@ func logDroppedAntigravityToolUseSignature(modelName string, messageIndex, conte // // Returns: // - []byte: The transformed request data in Antigravity API format -func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte { +func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertClaudeRequestToAntigravity(modelName, inputRawJSON, stream) + +} + +// convertClaudeRequestToAntigravity also reports an attachment Antigravity +// cannot receive when it leaves a user turn with nothing to send. +func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) { enableThoughtTranslate := true - rawJSON := inputRawJSON + // The internal commentary marker must be restored as assistant text before + // any thinking handling, so it is never dropped or treated as reasoning. + rawJSON, _ := sigcompat.RestoreCodexCommentaryMarkers(inputRawJSON) if shouldBuildAntigravityWebSearchRequest(modelName, rawJSON) { - return buildAntigravityWebSearchRequest(modelName, rawJSON) + return buildAntigravityWebSearchRequest(modelName, rawJSON), nil } + var drops translatorcommon.UserTurnDrops functionNameMap := util.SanitizedFunctionNameMap(rawJSON) // system instruction @@ -732,6 +742,15 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ partJSON := []byte(`{}`) partJSON, _ = sjson.SetRawBytes(partJSON, "inlineData", inlineDataJSON) partItems = append(partItems, partJSON) + } else if originalRole == "user" { + // A part that cannot be inlined (url or file source) is dropped; the turn is refused only if nothing else is left. + drops.Drop("image") + } + } else if contentTypeResult.Type == gjson.String && (contentTypeResult.String() == "document" || contentTypeResult.String() == "container_upload") { + if partJSON := claudeBase64InlineData(contentResult.Get("source")); partJSON != nil { + partItems = append(partItems, partJSON) + } else if originalRole == "user" { + drops.Drop(contentTypeResult.String()) } } } @@ -740,6 +759,11 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ clearPendingDetachedSignature() } + if originalRole == "user" { + // Whitespace-only text is forwarded but never keeps an emptied turn alive. + drops.EndTurn(translatorcommon.CountSendableGeminiParts(partItems)) + } + // Reorder model parts: thinking first, regular content second, function calls and trailing signature carriers last. if len(partItems) == 0 { continue @@ -942,7 +966,27 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ out = sigcompat.SanitizeGeminiRequestThoughtSignatures(out, "request.contents") } - return out + return out, drops.Err() +} + +// claudeBase64InlineData converts a Claude base64 source into an Antigravity +// inlineData part. A file id or url carries no bytes, so it stays unconverted +// and the caller reports it. +func claudeBase64InlineData(source gjson.Result) []byte { + if source.Get("type").String() != "base64" { + return nil + } + mimeType := source.Get("media_type").String() + data := source.Get("data").String() + if mimeType == "" || data == "" { + return nil + } + inlineDataJSON := []byte(`{}`) + inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "mimeType", mimeType) + inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "data", data) + partJSON := []byte(`{}`) + partJSON, _ = sjson.SetRawBytes(partJSON, "inlineData", inlineDataJSON) + return partJSON } func antigravityClaudeContent(role string, parts [][]byte) []byte { diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go index c1ecddec..81778875 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_response.go +++ b/internal/translator/antigravity/claude/antigravity_claude_response.go @@ -62,25 +62,28 @@ func formatClaudeSignatureValue(modelName, signature string) string { // This structure tracks the current state of the response translation process to ensure // proper sequencing of SSE events and transitions between different content types. type Params struct { - HasFirstResponse bool // Indicates if the initial message_start event has been sent - ResponseType int // Current response type: 0=none, 1=content, 2=thinking, 3=function - ResponseIndex int // Index counter for content blocks in the streaming response - HasFinishReason bool // Tracks whether a finish reason has been observed - FinishReason string // The finish reason string returned by the provider - HasUsageMetadata bool // Tracks whether usage metadata has been observed - PromptTokenCount int64 // Cached prompt token count from usage metadata - CandidatesTokenCount int64 // Cached candidate token count from usage metadata - ThoughtsTokenCount int64 // Cached thinking token count from usage metadata - TotalTokenCount int64 // Cached total token count from usage metadata - CachedTokenCount int64 // Cached content token count (indicates prompt caching) - HasSentFinalEvents bool // Indicates if final content/message events have been sent - HasToolUse bool // Indicates if tool use was observed in the stream - HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output - HasSemanticContent bool - LastSemanticKind string - HasWebSearchTool bool - WebSearchRequests int64 - WebSearchTextBuffer strings.Builder + ModelName string // Resolved once from the translated request + WebSearchStreamMode bool // Resolved once from both request formats + RequestMetadataResolved bool // Also caches empty model names and disabled web search + HasFirstResponse bool // Indicates if the initial message_start event has been sent + ResponseType int // Current response type: 0=none, 1=content, 2=thinking, 3=function + ResponseIndex int // Index counter for content blocks in the streaming response + HasFinishReason bool // Tracks whether a finish reason has been observed + FinishReason string // The finish reason string returned by the provider + HasUsageMetadata bool // Tracks whether usage metadata has been observed + PromptTokenCount int64 // Cached prompt token count from usage metadata + CandidatesTokenCount int64 // Cached candidate token count from usage metadata + ThoughtsTokenCount int64 // Cached thinking token count from usage metadata + TotalTokenCount int64 // Cached total token count from usage metadata + CachedTokenCount int64 // Cached content token count (indicates prompt caching) + HasSentFinalEvents bool // Indicates if final content/message events have been sent + HasToolUse bool // Indicates if tool use was observed in the stream + HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output + HasSemanticContent bool + LastSemanticKind string + HasWebSearchTool bool + WebSearchRequests int64 + WebSearchTextBuffer strings.Builder // Signature caching support CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching @@ -89,6 +92,11 @@ type Params struct { // Reverse map: sanitized Gemini function name → original Claude tool name. // Populated lazily on the first response chunk from the original request JSON. ToolNameMap map[string]string + + CurrentToolName string // Client-facing name of the currently open tool_use block + CurrentToolIsParameterless bool // Whether the open tool_use block was declared parameterless + CurrentToolInputEmitted bool // Whether cleaned input JSON was already emitted for the open tool_use block + CurrentToolPendingEmpty bool // Whether an empty "{}" input_json_delta is deferred awaiting tool_use completion } // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. @@ -128,9 +136,15 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR ToolNameMap: util.DisambiguatedToolNameMap(originalRequestRawJSON), } } - modelName := gjson.GetBytes(requestRawJSON, "model").String() - params := (*param).(*Params) + // Request metadata is resolved once per stream; callers pass the same + // request bytes for the stream's lifetime. + if !params.RequestMetadataResolved { + params.ModelName = gjson.GetBytes(requestRawJSON, "model").String() + params.WebSearchStreamMode = shouldTranslateWebSearchGrounding(originalRequestRawJSON, requestRawJSON) + params.RequestMetadataResolved = true + } + modelName := params.ModelName if bytes.Equal(rawJSON, []byte("[DONE]")) { output := make([]byte, 0, 256) @@ -151,7 +165,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR appendEvent := func(event, payload string) { output = translatorcommon.AppendSSEEventString(output, event, payload, 3) } - webSearchStreamMode := shouldTranslateWebSearchGrounding(originalRequestRawJSON, requestRawJSON) + webSearchStreamMode := params.WebSearchStreamMode appendThinkingSignature := func(signature, direction, targetKind string) { if signature == "" || params.ResponseType != 2 { return @@ -166,10 +180,19 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR params.CurrentThinkingSigned = true params.HasContent = true } + flushPendingToolInput := func() { + if params.ResponseType == 3 && params.CurrentToolPendingEmpty && !params.CurrentToolInputEmitted { + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", "{}") + appendEvent("content_block_delta", string(data)) + params.CurrentToolInputEmitted = true + params.CurrentToolPendingEmpty = false + } + } closeCurrentBlock := func() { if params.ResponseType == 0 { return } + flushPendingToolInput() appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex)) params.ResponseIndex++ params.ResponseType = 0 @@ -299,6 +322,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR params.HasContent = true } else { if params.ResponseType != 0 { + flushPendingToolInput() appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex)) params.ResponseIndex++ } @@ -326,6 +350,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR params.HasContent = true } else if partText != "" { if params.ResponseType != 0 { + flushPendingToolInput() appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex)) params.ResponseIndex++ } @@ -353,9 +378,40 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR params.HasToolUse = true fcName := util.RestoreSanitizedToolName(params.ToolNameMap, functionCallResult.Get("name").String()) + if params.ResponseType == 3 && fcName == "" && params.CurrentToolName != "" { + if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { + partialJSON := util.CleanClaudeToolArgumentsWithTranslated(fcArgsResult.Raw, params.CurrentToolName, originalRequestRawJSON, requestRawJSON) + if params.CurrentToolIsParameterless { + if params.CurrentToolInputEmitted { + partialJSON = "" + } else { + params.CurrentToolInputEmitted = true + params.CurrentToolPendingEmpty = false + } + } else if partialJSON == "{}" { + if !params.CurrentToolInputEmitted { + params.CurrentToolPendingEmpty = true + } + partialJSON = "" + } else if partialJSON != "" { + params.CurrentToolInputEmitted = true + params.CurrentToolPendingEmpty = false + } + if partialJSON != "" || !params.CurrentToolPendingEmpty { + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", partialJSON) + appendEvent("content_block_delta", string(data)) + } + } + params.HasContent = true + params.HasSemanticContent = true + params.LastSemanticKind = geminiClaudeCarrierFunction + continue + } + // Handle state transitions when switching to function calls // Close any existing function call block first if params.ResponseType == 3 { + flushPendingToolInput() appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex)) params.ResponseIndex++ params.ResponseType = 0 @@ -370,10 +426,15 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR // Close any other existing content block if params.ResponseType != 0 { + flushPendingToolInput() appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex)) params.ResponseIndex++ } + params.CurrentToolName = fcName + params.CurrentToolIsParameterless = util.ClaudeToolIsParameterless(fcName, originalRequestRawJSON) + params.CurrentToolInputEmitted = false + params.CurrentToolPendingEmpty = false // Start a new tool use content block // This creates the structure for a function call in Claude Code format // Create the tool use block with unique ID and function details @@ -387,8 +448,23 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR appendEvent("content_block_start", string(data)) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { - data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", fcArgsResult.Raw) - appendEvent("content_block_delta", string(data)) + cleanArgs := util.CleanClaudeToolArgumentsWithTranslated(fcArgsResult.Raw, fcName, originalRequestRawJSON, requestRawJSON) + if params.CurrentToolIsParameterless { + if params.CurrentToolInputEmitted { + cleanArgs = "" + } else { + params.CurrentToolInputEmitted = true + } + } else if cleanArgs == "{}" { + params.CurrentToolPendingEmpty = true + cleanArgs = "" + } else if cleanArgs != "" { + params.CurrentToolInputEmitted = true + } + if cleanArgs != "" || !params.CurrentToolPendingEmpty { + data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", cleanArgs) + appendEvent("content_block_delta", string(data)) + } } params.ResponseType = 3 params.HasContent = true @@ -401,17 +477,26 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR if finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() { params.HasFinishReason = true params.FinishReason = finishReasonResult.String() + if params.FinishReason != "" { + flushPendingToolInput() + } } if usageResult := gjson.GetBytes(rawJSON, "response.usageMetadata"); usageResult.Exists() { params.HasUsageMetadata = true + rawPromptTokenCount := usageResult.Get("promptTokenCount").Int() params.CachedTokenCount = usageResult.Get("cachedContentTokenCount").Int() - params.PromptTokenCount = usageResult.Get("promptTokenCount").Int() - params.CachedTokenCount + params.PromptTokenCount = rawPromptTokenCount - params.CachedTokenCount + if params.PromptTokenCount < 0 { + params.PromptTokenCount = 0 + } params.CandidatesTokenCount = usageResult.Get("candidatesTokenCount").Int() params.ThoughtsTokenCount = usageResult.Get("thoughtsTokenCount").Int() params.TotalTokenCount = usageResult.Get("totalTokenCount").Int() if params.CandidatesTokenCount == 0 && params.TotalTokenCount > 0 { - params.CandidatesTokenCount = params.TotalTokenCount - params.PromptTokenCount - params.ThoughtsTokenCount + // totalTokenCount includes the cached part of promptTokenCount, so the + // fallback must use the raw prompt count, not the cache-reduced one. + params.CandidatesTokenCount = params.TotalTokenCount - rawPromptTokenCount - params.ThoughtsTokenCount if params.CandidatesTokenCount < 0 { params.CandidatesTokenCount = 0 } @@ -467,6 +552,12 @@ func appendFinalEvents(params *Params, output *[]byte, force bool) { return } + if params.ResponseType == 3 && params.CurrentToolPendingEmpty && !params.CurrentToolInputEmitted { + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", "{}") + *output = translatorcommon.AppendSSEEventString(*output, "content_block_delta", string(data), 3) + params.CurrentToolInputEmitted = true + params.CurrentToolPendingEmpty = false + } if params.ResponseType != 0 { *output = translatorcommon.AppendSSEEventString(*output, "content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex), 3) params.ResponseType = 0 @@ -475,7 +566,9 @@ func appendFinalEvents(params *Params, output *[]byte, force bool) { stopReason := resolveStopReason(params) usageOutputTokens := params.CandidatesTokenCount + params.ThoughtsTokenCount if usageOutputTokens == 0 && params.TotalTokenCount > 0 { - usageOutputTokens = params.TotalTokenCount - params.PromptTokenCount + // PromptTokenCount excludes cached tokens for input_tokens, while + // TotalTokenCount includes them; add the cache back for the fallback. + usageOutputTokens = params.TotalTokenCount - (params.PromptTokenCount + params.CachedTokenCount) if usageOutputTokens < 0 { usageOutputTokens = 0 } @@ -651,7 +744,8 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or } if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && gjson.Valid(args.Raw) && args.IsObject() { - toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(args.Raw)) + cleanArgs := util.CleanClaudeToolArgumentsWithTranslated(args.Raw, name, originalRequestRawJSON, requestRawJSON) + toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(cleanArgs)) } blocks = append(blocks, toolBlock) diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index e247f25f..9f902a6b 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -35,7 +35,7 @@ import ( // // Returns: // - []byte: The transformed request data in Gemini API format -func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte { +func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) { rawJSON := inputRawJSON functionNameMap := util.SanitizedFunctionNameMap(inputRawJSON) // Keep the envelope in []byte form. Round-tripping through string copies the @@ -49,7 +49,7 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ fixedJSON, errFixCLIToolResponse := fixCLIToolResponse(rawJSON) if errFixCLIToolResponse != nil { - return []byte{} + return []byte{}, nil } rawJSON = fixedJSON @@ -155,7 +155,7 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ rawJSON = signature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "request.contents") } - return common.AttachDefaultSafetySettings(rawJSON, "request.safetySettings") + return common.AttachDefaultSafetySettings(rawJSON, "request.safetySettings"), nil } // normalizeGeminiGenerationConfigResponseSchema converts generationConfig.responseJsonSchema diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_request.go b/internal/translator/antigravity/interactions/interactions_antigravity_request.go index 88fa61f9..a1f1049e 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_request.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_request.go @@ -11,7 +11,14 @@ import ( "github.com/tidwall/sjson" ) -func ConvertInteractionsRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertInteractionsRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertInteractionsRequestToAntigravity(modelName, inputRawJSON, stream) + +} + +// convertInteractionsRequestToAntigravity also reports a user turn that was left +// empty because its only attachment has no Antigravity equivalent. +func convertInteractionsRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { root := gjson.ParseBytes(inputRawJSON) functionNameMap := util.SanitizedFunctionNameMap(inputRawJSON) out := []byte(`{"project":"","request":{"contents":[]},"model":""}`) @@ -22,7 +29,7 @@ func ConvertInteractionsRequestToAntigravity(modelName string, inputRawJSON []by out = copyInteractionsSystemToAntigravity(out, root) out = copyInteractionsGenerationConfigToAntigravity(out, root) contentItems := translatorcommon.NewRawArrayItems(root.Get("input.#").Int()) - appendInteractionsInputToAntigravity(&contentItems, root.Get("input")) + errInput := appendInteractionsInputToAntigravity(&contentItems, root.Get("input")) out = translatorcommon.SetRawArrayItems(out, "request.contents", contentItems) out = copyInteractionsToolsToAntigravity(out, root, functionNameMap) if gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String() == "NONE" { @@ -30,7 +37,7 @@ func ConvertInteractionsRequestToAntigravity(modelName string, inputRawJSON []by } out = rewriteInteractionsFunctionNames(out, functionNameMap) out = attachDefaultAntigravitySafetySettings(out) - return out + return out, errInput } func rewriteInteractionsFunctionNames(out []byte, functionNameMap map[string]string) []byte { @@ -298,11 +305,36 @@ type antigravityInteractionsInputContext struct { inModelTurn bool lastStepType string pendingSignature string + // run follows the consecutive user content so that an attachment Antigravity + // cannot carry is refused only when its whole user turn is left with nothing to send. + run translatorcommon.UserRun + // instruction is set while the step being added is developer or system content. + instruction bool +} + +// userRun returns the tracker for content added with role. Model content and +// developer or system content close the open user turn and have no tracker, so +// they can never keep an emptied user turn alive. +func (ctx *antigravityInteractionsInputContext) userRun(role string) *translatorcommon.UserRun { + if role == "user" && !ctx.instruction { + return &ctx.run + } + ctx.run.End() + return nil +} + +// appendText adds a text content; blank text does not keep a user turn alive. +func (ctx *antigravityInteractionsInputContext) appendText(role, text string) { + appendAntigravityTextContent(&ctx.items, role, text) + run := ctx.userRun(role) + if strings.TrimSpace(text) != "" { + run.Add() + } } -func appendInteractionsInputToAntigravity(items *[][]byte, input gjson.Result) { +func appendInteractionsInputToAntigravity(items *[][]byte, input gjson.Result) error { if !input.Exists() { - return + return nil } ctx := &antigravityInteractionsInputContext{ items: *items, @@ -311,11 +343,13 @@ func appendInteractionsInputToAntigravity(items *[][]byte, input gjson.Result) { appendAntigravityTextContent(&ctx.items, "user", input.String()) ctx.lastStepType = "text" *items = ctx.items - return + return nil } if input.IsArray() { input.ForEach(func(_, item gjson.Result) bool { - appendInteractionsStepToAntigravity(ctx, item, "user") + if !appendInteractionsContentBlockItem(ctx, item) { + appendInteractionsStepToAntigravity(ctx, item, "user") + } return true }) } else if steps := input.Get("steps"); steps.Exists() && steps.IsArray() { @@ -323,24 +357,58 @@ func appendInteractionsInputToAntigravity(items *[][]byte, input gjson.Result) { if role := input.Get("role").String(); role == "model" || role == "assistant" { defaultRole = "model" } + ctx.instruction = translatorcommon.IsInteractionsInstructionStep(input, false) steps.ForEach(func(_, step gjson.Result) bool { appendInteractionsStepToAntigravity(ctx, step, defaultRole) return true }) + ctx.instruction = false } else { appendInteractionsStepToAntigravity(ctx, input, "user") } flushPendingAntigravitySignature(ctx) *items = ctx.items + ctx.run.End() + return ctx.run.Err() +} + +// appendInteractionsContentBlockItem accepts the shorthand where input is a bare +// list of content blocks instead of steps. Those items have no step envelope and +// would otherwise be dropped, losing images and files sent that way. +func appendInteractionsContentBlockItem(ctx *antigravityInteractionsInputContext, item gjson.Result) bool { + switch strings.ToLower(strings.TrimSpace(item.Get("type").String())) { + case "text", "image", "audio", "video", "document": + default: + return false + } + if item.Get("content").Exists() || item.Get("steps").Exists() || item.Get("parts").Exists() { + return false + } + // Steps carrying an explicit role keep the step dispatch so that system or + // developer content still closes the open user turn instead of masking a + // dropped attachment. + if item.Get("role").Exists() { + return false + } + if ctx.inModelTurn { + flushPendingAntigravitySignature(ctx) + ctx.inModelTurn = false + } + appendInteractionsContentPartToAntigravity(&ctx.items, "user", item, ctx.userRun("user")) + ctx.lastStepType = "user_input" + return true } func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContext, step gjson.Result, defaultRole string) { + inheritedInstruction := ctx.instruction + ctx.instruction = translatorcommon.IsInteractionsInstructionStep(step, inheritedInstruction) + defer func() { ctx.instruction = inheritedInstruction }() if step.Type == gjson.String { if ctx.inModelTurn { flushPendingAntigravitySignature(ctx) ctx.inModelTurn = false } - appendAntigravityTextContent(&ctx.items, defaultRole, step.String()) + ctx.appendText(defaultRole, step.String()) ctx.lastStepType = "text" return } @@ -359,6 +427,7 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex } switch step.Get("type").String() { case "model_output": + ctx.run.End() if ctx.pendingSignature != "" { carrier := antigravityTextPartJSON("", false) carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature) @@ -380,6 +449,7 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex ctx.inModelTurn = true ctx.lastStepType = "model_output" case "thought": + ctx.run.End() if sig := firstNonEmptyString(step.Get("signature").String(), step.Get("thought_signature").String(), step.Get("thoughtSignature").String()); sig != "" { if ctx.pendingSignature != "" && ctx.pendingSignature != sig { carrier := antigravityTextPartJSON("", false) @@ -403,6 +473,7 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex ctx.inModelTurn = true ctx.lastStepType = "thought" case "function_call": + ctx.run.End() part := buildAntigravityFunctionCallPart(step) sig := firstNonEmptyString(step.Get("signature").String(), step.Get("thought_signature").String(), step.Get("thoughtSignature").String()) if sig == "" && ctx.pendingSignature != "" { @@ -438,6 +509,8 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex ctx.inModelTurn = false } part := buildAntigravityFunctionResultPart(step) + // A tool result is content the model reads, so it keeps the surrounding user turn. + ctx.run.Add() if ctx.lastStepType == "function_result" && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "user" { ctx.items[len(ctx.items)-1] = appendGeminiUserContentPart(ctx.items[len(ctx.items)-1], part) } else { @@ -450,9 +523,9 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex ctx.inModelTurn = false } if step.Get("parts").Exists() { - appendInteractionsNativeContentToAntigravity(&ctx.items, step, defaultRole) + appendInteractionsNativeContentToAntigravity(ctx, step, defaultRole) } else { - appendInteractionsContentListToAntigravity(&ctx.items, defaultRole, step.Get("content")) + appendInteractionsContentListToAntigravity(ctx, defaultRole, step.Get("content")) } ctx.lastStepType = "user_input" default: @@ -461,11 +534,11 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex ctx.inModelTurn = false } if step.Get("parts").Exists() { - appendInteractionsNativeContentToAntigravity(&ctx.items, step, defaultRole) + appendInteractionsNativeContentToAntigravity(ctx, step, defaultRole) } else if step.Get("content").Exists() { - appendInteractionsContentListToAntigravity(&ctx.items, defaultRole, step.Get("content")) + appendInteractionsContentListToAntigravity(ctx, defaultRole, step.Get("content")) } else if text := step.Get("text"); text.Exists() { - appendAntigravityTextContent(&ctx.items, defaultRole, text.String()) + ctx.appendText(defaultRole, text.String()) } ctx.lastStepType = "default" } @@ -623,21 +696,30 @@ func buildAntigravityFunctionResultPart(step gjson.Result) []byte { return part } -func appendInteractionsNativeContentToAntigravity(items *[][]byte, step gjson.Result, defaultRole string) { +func appendInteractionsNativeContentToAntigravity(ctx *antigravityInteractionsInputContext, step gjson.Result, defaultRole string) { parts := step.Get("parts") if !parts.Exists() || !parts.IsArray() { return } + role := antigravityContentRole(step.Get("role").String(), defaultRole) + run := ctx.userRun(role) partItems := make([][]byte, 0, 4) parts.ForEach(func(_, part gjson.Result) bool { - if partJSON := interactionsNativeAntigravityPart(part); len(partJSON) > 0 { - partItems = append(partItems, partJSON) + partJSON := interactionsNativeAntigravityPart(part) + if len(partJSON) == 0 { + if droppedType := translatorcommon.InteractionsAttachmentType(part); droppedType != "" { + run.Drop(droppedType) + } + return true + } + partItems = append(partItems, partJSON) + if translatorcommon.GeminiPartIsSendable(partJSON) { + run.Add() } return true }) if len(partItems) > 0 { - role := antigravityContentRole(step.Get("role").String(), defaultRole) - *items = append(*items, antigravityContent(role, partItems)) + ctx.items = append(ctx.items, antigravityContent(role, partItems)) } } @@ -666,28 +748,38 @@ func appendInteractionsStepContentToAntigravity(items *[][]byte, role string, st } } -func appendInteractionsContentListToAntigravity(items *[][]byte, role string, content gjson.Result) { +func appendInteractionsContentListToAntigravity(ctx *antigravityInteractionsInputContext, role string, content gjson.Result) { if !content.Exists() { return } + run := ctx.userRun(role) if content.IsArray() { content.ForEach(func(_, part gjson.Result) bool { - appendInteractionsContentPartToAntigravity(items, role, part) + appendInteractionsContentPartToAntigravity(&ctx.items, role, part, run) return true }) return } if content.IsObject() { - appendInteractionsContentPartToAntigravity(items, role, content) + appendInteractionsContentPartToAntigravity(&ctx.items, role, content, run) } else if content.Type == gjson.String { - appendAntigravityTextContent(items, role, content.String()) + ctx.appendText(role, content.String()) } } -func appendInteractionsContentPartToAntigravity(items *[][]byte, role string, part gjson.Result) { +// appendInteractionsContentPartToAntigravity adds one content part. run is nil for +// model content; for user content it records what is sent and what cannot be. +func appendInteractionsContentPartToAntigravity(items *[][]byte, role string, part gjson.Result, run *translatorcommon.UserRun) { partJSON := appendInteractionsContentToAntigravityPart(nil, part, false) - if len(partJSON) > 0 { - *items = append(*items, antigravityContent(role, [][]byte{partJSON})) + if len(partJSON) == 0 { + if droppedType := translatorcommon.InteractionsAttachmentType(part); droppedType != "" { + run.Drop(droppedType) + } + return + } + *items = append(*items, antigravityContent(role, [][]byte{partJSON})) + if translatorcommon.GeminiPartIsSendable(partJSON) { + run.Add() } } @@ -716,11 +808,8 @@ func appendInteractionsContentToAntigravityPart(_ []byte, content gjson.Result, return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data))) } } - if uri := content.Get("file_uri"); uri.Exists() || content.Get("fileUri").Exists() { - fileURI := uri.String() - if fileURI == "" { - fileURI = content.Get("fileUri").String() - } + // uri is the Interactions spelling of file_uri. + if fileURI := firstNonEmptyString(content.Get("file_uri").String(), content.Get("fileUri").String(), content.Get("uri").String()); fileURI != "" { mimeType := content.Get("mime_type").String() if mimeType == "" { mimeType = content.Get("mimeType").String() diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_response.go b/internal/translator/antigravity/interactions/interactions_antigravity_response.go index 18f2804d..aefdb64b 100644 --- a/internal/translator/antigravity/interactions/interactions_antigravity_response.go +++ b/internal/translator/antigravity/interactions/interactions_antigravity_response.go @@ -19,6 +19,9 @@ type antigravityToInteractionsStreamState struct { Completed bool Done bool ActiveStepOpen bool + PendingUsage gjson.Result + HasPendingUsage bool + UsageAfterSeen bool ID string StepID string ActiveStepType string @@ -48,7 +51,11 @@ func ConvertAntigravityResponseToInteractions(ctx context.Context, modelName str if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) { if !st.Completed { out = appendAntigravityInteractionsStepStop(out, st) - out = appendAntigravityInteractionsCompleted(out, st, modelName, gjson.Result{}) + completedRoot := gjson.Result{} + if st.HasPendingUsage { + completedRoot = st.PendingUsage + } + out = appendAntigravityInteractionsCompleted(out, st, modelName, completedRoot) } out = appendAntigravityInteractionsDone(out, st) continue @@ -67,14 +74,30 @@ func ConvertAntigravityResponseToInteractions(ctx context.Context, modelName str out = appendAntigravityPartToInteractionsStream(out, st, part) return true }) - hasFinish := root.Get("candidates.0.finishReason").Exists() - hasUsage := hasAntigravityStreamUsage(root) + finishReason := root.Get("candidates.0.finishReason") + hasFinish := finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" + frameHasUsage := hasAntigravityStreamUsage(root) + if frameHasUsage { + st.PendingUsage = root + st.HasPendingUsage = true + if st.Finished { + st.UsageAfterSeen = true + } + } if hasFinish && !st.Finished { out = appendAntigravityInteractionsStepStop(out, st) st.Finished = true + if frameHasUsage { + st.UsageAfterSeen = true + } } - if hasUsage && st.Finished && !st.Completed { - out = appendAntigravityInteractionsCompleted(out, st, modelName, root) + // Complete only on usage observed at or after the finish frame. + // Usage cached from earlier content frames is a fallback for the + // explicit [DONE] terminator, not a completion trigger: a later + // frame may still carry the final usage. + if st.UsageAfterSeen && st.Finished && !st.Completed { + out = appendAntigravityInteractionsCompleted(out, st, modelName, st.PendingUsage) + out = appendAntigravityInteractionsDone(out, st) } } return out @@ -111,6 +134,11 @@ func ConvertAntigravityResponseToInteractionsNonStream(ctx context.Context, mode func antigravityStreamPayloads(rawJSON []byte) [][]byte { trimmed := bytes.TrimSpace(rawJSON) + if bytes.Equal(trimmed, []byte("[DONE]")) { + // The bare sentinel is not a JSON array; without this guard the + // array branch below misparses it into a bogus payload. + return [][]byte{trimmed} + } if bytes.HasPrefix(trimmed, []byte("data:")) { return [][]byte{bytes.TrimSpace(trimmed[5:])} } diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index 310ebab4..7a65a95d 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -28,8 +28,16 @@ const antigravityFunctionThoughtSignature = "skip_thought_signature_validator" // // Returns: // - []byte: The transformed request data in Antigravity API format -func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) []byte { +func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { + return convertOpenAIRequestToAntigravity(modelName, inputRawJSON, stream) + +} + +// convertOpenAIRequestToAntigravity also reports a file part Antigravity cannot +// receive when it leaves a user turn with nothing to send. +func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) { rawJSON := inputRawJSON + var drops translatorcommon.UserTurnDrops functionNameMap := util.SanitizedFunctionNameMap(rawJSON) // Base envelope (no default thinkingConfig) out := []byte(`{"project":"","request":{"contents":[]},"model":"gemini-2.5-pro"}`) @@ -167,21 +175,17 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ partItems = append(partItems, antigravityOpenAITextPart(antigravityDemotedSystemText(text, isDemotedSystem))) } case "image_url": - imageURL := item.Get("image_url.url").String() - if len(imageURL) > 5 { - pieces := strings.SplitN(imageURL[5:], ";", 2) - if len(pieces) == 2 && len(pieces[1]) > 7 { - part := antigravityOpenAIInlineDataPart(pieces[0], pieces[1][7:], false) - partItems = append(partItems, part) - } + // Only a base64 data URL can be inlined; a remote URL has no equivalent here. + if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData("", "", item.Get("image_url.url").String()); ok { + partItems = append(partItems, antigravityOpenAIInlineDataPart(mimeType, data, false)) + } else { + drops.Drop("image_url") } case "video_url": - videoURL := item.Get("video_url.url").String() - if len(videoURL) > 5 { - pieces := strings.SplitN(videoURL[5:], ";", 2) - if len(pieces) == 2 && len(pieces[1]) > 7 { - partItems = append(partItems, antigravityOpenAIInlineDataPart(pieces[0], pieces[1][7:], false)) - } + if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData("", "", item.Get("video_url.url").String()); ok { + partItems = append(partItems, antigravityOpenAIInlineDataPart(mimeType, data, false)) + } else { + drops.Drop("video_url") } case "file": filename := item.Get("file.filename").String() @@ -190,16 +194,21 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ partItems = append(partItems, antigravityOpenAIInlineDataPart(mimeType, data, false)) } else { log.Warn("Invalid file data or unknown file name extension in user message, skip") + drops.Drop("file") } case "input_audio": audioData := item.Get("input_audio.data").String() if audioData != "" { mimeType := antigravityOpenAIAudioMIMEType(item.Get("input_audio.format").String()) partItems = append(partItems, antigravityOpenAIInlineDataPart(mimeType, audioData, true)) + } else { + drops.Drop("input_audio") } } } } + // Whitespace-only text is forwarded but never keeps an emptied turn alive. + drops.EndTurn(translatorcommon.CountSendableGeminiParts(partItems)) if len(partItems) > 0 { contentItems = append(contentItems, antigravityOpenAIContent("user", partItems)) } @@ -438,7 +447,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ if strings.Contains(strings.ToLower(modelName), "claude") { out = gemini.SanitizeAntigravityClaudeGeminiRequestSignatures(modelName, out) } - return common.AttachDefaultSafetySettings(out, "request.safetySettings") + return common.AttachDefaultSafetySettings(out, "request.safetySettings"), drops.Err() } func antigravityOpenAITextPart(text string) []byte { @@ -520,9 +529,6 @@ func applyOpenAIToolChoiceToAntigravity(out, rawJSON []byte, functionNameMap map } out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", mode) - if mode == "NONE" { - out, _ = sjson.DeleteBytes(out, "request.tools") - } if strings.TrimSpace(allowedName) != "" { mappedName := util.MapSanitizedFunctionName(functionNameMap, allowedName) out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{mappedName}) diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go index f3fbf4e8..1b8c029e 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go @@ -95,7 +95,7 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq } // 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":{"role":null,"content":null,"reasoning_content":null},"finish_reason":null,"native_finish_reason":null}]}`) // Extract and set the model version. if modelVersionResult := gjson.GetBytes(rawJSON, "response.modelVersion"); modelVersionResult.Exists() { @@ -178,15 +178,17 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq toolCallsResult := gjson.GetBytes(template, "choices.0.delta.tool_calls") functionCallIndex := (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex++ - if toolCallsResult.Exists() && toolCallsResult.IsArray() { - functionCallIndex = len(toolCallsResult.Array()) - } else { + if !toolCallsResult.IsArray() { template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`)) } functionCallTemplate := []byte(`{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`) fcName := util.RestoreSanitizedToolName((*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap, functionCallResult.Get("name").String()) - functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))) + fcID := strings.TrimSpace(functionCallResult.Get("id").String()) + if fcID == "" { + fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)) + } + functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fcID) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex) functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName) if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { @@ -273,6 +275,8 @@ func resolveOpenAIFinishReason(params *convertCliResponseToOpenAIChatParams) (fi finishReason = "tool_calls" case params.UpstreamFinishReason == "MAX_TOKENS": finishReason = "max_tokens" + case isAntigravityContentFilterFinishReason(params.UpstreamFinishReason): + finishReason = "content_filter" default: finishReason = "stop" } @@ -283,9 +287,18 @@ func resolveOpenAIFinishReason(params *convertCliResponseToOpenAIChatParams) (fi return finishReason, nativeFinishReason } +func isAntigravityContentFilterFinishReason(reason string) bool { + switch strings.ToUpper(strings.TrimSpace(reason)) { + case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "IMAGE_SAFETY", "MALFORMED_RESPONSE", "MALFORMED_FUNCTION_CALL": + return true + default: + return false + } +} + func setOpenAIUsageMetadata(template []byte, usageResult gjson.Result) []byte { cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int() - template, _ = sjson.SetBytes(template, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int()) + template, _ = sjson.SetBytes(template, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int()+usageResult.Get("thoughtsTokenCount").Int()) if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() { template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokenCountResult.Int()) } diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go index 895a921a..32963c8e 100644 --- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go +++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go @@ -51,15 +51,16 @@ func shouldBuildAntigravityResponsesWebSearchRequest(model string, payload []byt AllowsResponsesWebSearchToolChoice(root) } -func buildAntigravityResponsesWebSearchRequest(model string, payload []byte, stream bool) []byte { +func buildAntigravityResponsesWebSearchRequest(model string, payload []byte, stream bool) ([]byte, error) { includedDomains := ExtractResponsesWebSearchAllowedDomains(gjson.ParseBytes(payload)) - rawJSON := ConvertOpenAIResponsesRequestToGemini(model, payload, stream) + rawJSON, errConvert := ConvertOpenAIResponsesRequestToGemini(model, payload, stream) + rawJSON = stripAntigravityServerSideToolInvocations(rawJSON) rawJSON = rewriteOpenAIResponsesReasoningForAntigravityClaude(model, payload, rawJSON) - out := ConvertGeminiRequestToAntigravity(model, rawJSON, stream) + out, _ := ConvertGeminiRequestToAntigravity(model, rawJSON, stream) out, _ = sjson.SetBytes(out, "requestType", "web_search") out = ensureAntigravityResponsesWebSearchTool(out, includedDomains) out = ensureAntigravityResponsesWebSearchSystemInstruction(out) - return enableAntigravityResponsesThinkingSummary(payload, out) + return enableAntigravityResponsesThinkingSummary(payload, out), errConvert } func ensureAntigravityResponsesWebSearchTool(payload []byte, includedDomains []string) []byte { @@ -126,27 +127,27 @@ func ensureAntigravityResponsesWebSearchSystemInstruction(payload []byte) []byte // ConvertOpenAIResponsesRequestToAntigravity translates an OpenAI Responses request // to the Antigravity schema using locally registered Antigravity capabilities. -func ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { req := ConvertOpenAIResponsesRequestEnvelopeToAntigravity(context.Background(), sdktranslator.RequestEnvelope{ Model: modelName, Body: inputRawJSON, Stream: stream, }) - return req.Body + return req.Body, nil } // ConvertOpenAIResponsesRequestEnvelopeToAntigravity translates an OpenAI Responses // request and consumes request-scoped model capabilities from the envelope. func ConvertOpenAIResponsesRequestEnvelopeToAntigravity(_ context.Context, req sdktranslator.RequestEnvelope) sdktranslator.RequestEnvelope { if shouldBuildAntigravityResponsesWebSearchRequest(req.Model, req.Body, req.ModelInfo) { - req.Body = buildAntigravityResponsesWebSearchRequest(req.Model, req.Body, req.Stream) + req.Body, req.Err = buildAntigravityResponsesWebSearchRequest(req.Model, req.Body, req.Stream) return req } inputRawJSON := req.Body - req.Body = ConvertOpenAIResponsesRequestToGemini(req.Model, req.Body, req.Stream) + req.Body, req.Err = ConvertOpenAIResponsesRequestToGemini(req.Model, req.Body, req.Stream) req.Body = stripAntigravityResponsesGoogleSearch(req.Body) req.Body = rewriteOpenAIResponsesReasoningForAntigravityClaude(req.Model, inputRawJSON, req.Body) - req.Body = ConvertGeminiRequestToAntigravity(req.Model, req.Body, req.Stream) + req.Body, _ = ConvertGeminiRequestToAntigravity(req.Model, req.Body, req.Stream) req.Body = stripAntigravityResponsesGoogleSearch(req.Body) req.Body = enableAntigravityResponsesThinkingSummary(inputRawJSON, req.Body) return req @@ -179,6 +180,23 @@ func stripAntigravityResponsesGoogleSearch(payload []byte) []byte { } } } + return stripAntigravityServerSideToolInvocations(payload) +} + +// stripAntigravityServerSideToolInvocations removes includeServerSideToolInvocations +// injected by the shared Gemini Responses translator because Antigravity does not +// support the includeServerSideToolInvocations parameter. +func stripAntigravityServerSideToolInvocations(payload []byte) []byte { + for _, toolConfigPath := range []string{"toolConfig", "request.toolConfig"} { + flagPath := toolConfigPath + ".includeServerSideToolInvocations" + if !gjson.GetBytes(payload, flagPath).Exists() { + continue + } + payload, _ = sjson.DeleteBytes(payload, flagPath) + if tc := gjson.GetBytes(payload, toolConfigPath); tc.IsObject() && len(tc.Map()) == 0 { + payload, _ = sjson.DeleteBytes(payload, toolConfigPath) + } + } return payload } diff --git a/internal/translator/antigravity/openai/responses/init.go b/internal/translator/antigravity/openai/responses/init.go index e4b3a555..0faf8945 100644 --- a/internal/translator/antigravity/openai/responses/init.go +++ b/internal/translator/antigravity/openai/responses/init.go @@ -11,7 +11,7 @@ func init() { translator.Register( OpenaiResponse, Antigravity, - ConvertOpenAIResponsesRequestToAntigravity, + nil, interfaces.TranslateResponse{ Stream: ConvertAntigravityResponseToOpenAIResponses, NonStream: ConvertAntigravityResponseToOpenAIResponsesNonStream, diff --git a/internal/translator/common/antigravity_tools.go b/internal/translator/common/antigravity_tools.go index a4219947..edab65dc 100644 --- a/internal/translator/common/antigravity_tools.go +++ b/internal/translator/common/antigravity_tools.go @@ -19,15 +19,23 @@ var antigravityCollidingToolNames = map[string]struct{}{ } // AntigravityToolNameToUpstream maps a client-facing tool name to the name -// sent to the antigravity Interactions API. Names that do not collide are -// returned unchanged. +// sent to the antigravity Interactions API. Escape the entire prefix chain +// ending in an intrinsic name so real external_ names remain distinguishable. func AntigravityToolNameToUpstream(name string) string { - if _, collides := antigravityCollidingToolNames[name]; collides { + if antigravityIntrinsicBase(name) { return ExternalToolPrefix + name } return name } +func antigravityIntrinsicBase(name string) bool { + for strings.HasPrefix(name, ExternalToolPrefix) { + name = strings.TrimPrefix(name, ExternalToolPrefix) + } + _, collides := antigravityCollidingToolNames[name] + return collides +} + // AntigravityUpstreamToolNameToClient strips the external prefix from an // upstream tool name before it reaches the client, provided that the base // name is an intrinsic tool that was prefixed to avoid collisions. Non-colliding @@ -35,7 +43,7 @@ func AntigravityToolNameToUpstream(name string) string { func AntigravityUpstreamToolNameToClient(name string) string { if strings.HasPrefix(name, ExternalToolPrefix) { base := strings.TrimPrefix(name, ExternalToolPrefix) - if _, collides := antigravityCollidingToolNames[base]; collides { + if antigravityIntrinsicBase(base) { return base } } diff --git a/internal/translator/common/cache_control.go b/internal/translator/common/cache_control.go index 81b6752e..c52d24e5 100644 --- a/internal/translator/common/cache_control.go +++ b/internal/translator/common/cache_control.go @@ -2,6 +2,7 @@ package common import ( "fmt" + "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -16,11 +17,27 @@ func isValidCacheControl(cc gjson.Result) bool { return typ.Type == gjson.String && typ.String() == "ephemeral" } +func claudeBlockCanHostCacheControl(block gjson.Result) bool { + if !block.IsObject() { + return false + } + switch block.Get("type").String() { + case "": + return true + case "thinking", "redacted_thinking": + return false + case "text": + return strings.TrimSpace(block.Get("text").String()) != "" + default: + return true + } +} + // AttachCacheControl copies a Claude-compatible cache_control object from src onto dst. -// Returns dst unchanged when cache_control is missing or not an object. +// Returns dst unchanged when cache_control is missing, not an object, or dst is an empty text block. func AttachCacheControl(dst []byte, src gjson.Result) []byte { cc := src.Get("cache_control") - if !isValidCacheControl(cc) { + if !isValidCacheControl(cc) || !claudeBlockCanHostCacheControl(gjson.ParseBytes(dst)) { return dst } out, err := sjson.SetRawBytes(dst, "cache_control", []byte(cc.Raw)) @@ -30,8 +47,8 @@ func AttachCacheControl(dst []byte, src gjson.Result) []byte { return out } -// AttachMessageCacheControl applies message-level cache_control onto the last content block. -// Part-level cache_control wins when the last block already has one. +// AttachMessageCacheControl applies message-level cache_control onto the last eligible non-empty content block. +// Part-level cache_control wins when the target block already has one. // String content is promoted to a content array so Claude can accept cache_control. func AttachMessageCacheControl(msg []byte, src gjson.Result) []byte { cc := src.Get("cache_control") @@ -42,14 +59,20 @@ func AttachMessageCacheControl(msg []byte, src gjson.Result) []byte { content := gjson.GetBytes(msg, "content") if content.IsArray() { arr := content.Array() - if len(arr) == 0 { + targetIdx := -1 + for i := len(arr) - 1; i >= 0; i-- { + if claudeBlockCanHostCacheControl(arr[i]) { + targetIdx = i + break + } + } + if targetIdx < 0 { return msg } - lastIdx := len(arr) - 1 - if arr[lastIdx].Get("cache_control").Exists() { + if arr[targetIdx].Get("cache_control").Exists() { return msg } - path := fmt.Sprintf("content.%d.cache_control", lastIdx) + path := fmt.Sprintf("content.%d.cache_control", targetIdx) out, err := sjson.SetRawBytes(msg, path, []byte(cc.Raw)) if err != nil { return msg @@ -57,7 +80,7 @@ func AttachMessageCacheControl(msg []byte, src gjson.Result) []byte { return out } - if content.Type != gjson.String { + if content.Type != gjson.String || strings.TrimSpace(content.String()) == "" { return msg } diff --git a/internal/translator/common/claude_native_response.go b/internal/translator/common/claude_native_response.go new file mode 100644 index 00000000..16c9f4f5 --- /dev/null +++ b/internal/translator/common/claude_native_response.go @@ -0,0 +1,81 @@ +package common + +import ( + "bytes" + "encoding/json" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// ClaudeMessagesJSONToSSE adapts a native Messages response for the existing SSE +// aggregators and returns its model. SSE and other payloads pass through unchanged. +func ClaudeMessagesJSONToSSE(rawJSON []byte) ([]byte, string) { + if !gjson.ValidBytes(rawJSON) { + return rawJSON, "" + } + root := gjson.ParseBytes(rawJSON) + if root.Get("type").String() != "message" || !root.Get("content").IsArray() { + return rawJSON, "" + } + + var out bytes.Buffer + emit := func(event map[string]any) { + // All values are JSON primitives or validated raw JSON from the response. + payload, _ := json.Marshal(event) + out.WriteString("data: ") + out.Write(payload) + out.WriteString("\n\n") + } + message, _ := sjson.SetRawBytes(rawJSON, "content", []byte(`[]`)) + message, _ = sjson.SetBytes(message, "stop_reason", nil) + message, _ = sjson.SetBytes(message, "stop_sequence", nil) + emit(map[string]any{"type": "message_start", "message": json.RawMessage(message)}) + + for index, block := range root.Get("content").Array() { + start := []byte(block.Raw) + var delta map[string]any + switch block.Get("type").String() { + case "text": + start, _ = sjson.SetBytes(start, "text", "") + delta = map[string]any{"type": "text_delta", "text": block.Get("text").String()} + case "tool_use": + start, _ = sjson.SetRawBytes(start, "input", []byte(`{}`)) + input := block.Get("input").Raw + if input == "" { + input = "{}" + } + delta = map[string]any{"type": "input_json_delta", "partial_json": input} + case "thinking": + start, _ = sjson.SetBytes(start, "thinking", "") + start, _ = sjson.SetBytes(start, "signature", "") + delta = map[string]any{"type": "thinking_delta", "thinking": block.Get("thinking").String()} + } + emit(map[string]any{"type": "content_block_start", "index": index, "content_block": json.RawMessage(start)}) + if delta != nil { + emit(map[string]any{"type": "content_block_delta", "index": index, "delta": delta}) + } + if block.Get("type").String() == "text" { + for _, citation := range block.Get("citations").Array() { + emit(map[string]any{"type": "content_block_delta", "index": index, "delta": map[string]any{ + "type": "citations_delta", "citation": json.RawMessage(citation.Raw), + }}) + } + } + if block.Get("type").String() == "thinking" && block.Get("signature").Exists() { + emit(map[string]any{"type": "content_block_delta", "index": index, "delta": map[string]any{ + "type": "signature_delta", "signature": block.Get("signature").String(), + }}) + } + emit(map[string]any{"type": "content_block_stop", "index": index}) + } + usage := json.RawMessage(root.Get("usage").Raw) + if len(usage) == 0 { + usage = json.RawMessage(`{}`) + } + emit(map[string]any{"type": "message_delta", "delta": map[string]any{ + "stop_reason": root.Get("stop_reason").Value(), "stop_sequence": root.Get("stop_sequence").Value(), + }, "usage": usage}) + emit(map[string]any{"type": "message_stop"}) + return out.Bytes(), root.Get("model").String() +} diff --git a/internal/translator/common/codex_replay_signature.go b/internal/translator/common/codex_replay_signature.go new file mode 100644 index 00000000..ff290ee8 --- /dev/null +++ b/internal/translator/common/codex_replay_signature.go @@ -0,0 +1,63 @@ +package common + +import ( + "strings" + + sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" + "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" + "github.com/tidwall/gjson" +) + +// ResolveCodexReplayTargetModel returns the model name to use for reasoning +// signature family checks, falling back to the client-supplied "model" field in +// rawPayload when modelName is an unmapped upstream deployment alias for a +// Responses-native model family (Meta Muse, Grok, or GPT). +func ResolveCodexReplayTargetModel(modelName string, rawPayload []byte) string { + if sigcompat.SignatureProviderFromModelName(thinking.ParseSuffix(modelName).ModelName) != sigcompat.SignatureProviderUnknown { + return modelName + } + if len(rawPayload) > 0 { + if rawModel := strings.TrimSpace(gjson.GetBytes(rawPayload, "model").String()); rawModel != "" { + switch sigcompat.SignatureProviderFromModelName(thinking.ParseSuffix(rawModel).ModelName) { + case sigcompat.SignatureProviderMeta, sigcompat.SignatureProviderGrok, sigcompat.SignatureProviderGPT: + return rawModel + } + } + } + return modelName +} + +// CodexReplaySignatureForTarget returns the exact value that may be written as a +// Responses reasoning encrypted_content for the Codex-format upstream serving +// modelName, or false when rawSignature must not replay there. Positively +// detected families must match the target family exactly (an unmapped target +// defaults to the Codex GPT family, mirroring the Codex translators' purpose) +// and go through the shared compatibility normalization, so cache-prefixed +// values such as "gpt#..." are stripped to their native payload instead of +// being forwarded verbatim. Grok has no detectable envelope, so it falls back +// to its shape inspector keyed on the target. +func CodexReplaySignatureForTarget(modelName, rawSignature string) (string, bool) { + rawSignature = strings.TrimSpace(rawSignature) + if rawSignature == "" { + return "", false + } + target := sigcompat.SignatureProviderFromModelName(thinking.ParseSuffix(modelName).ModelName) + detected := sigcompat.DetectSignatureProvider(rawSignature) + if detected != sigcompat.SignatureProviderUnknown { + effectiveTarget := target + if effectiveTarget == sigcompat.SignatureProviderUnknown { + effectiveTarget = sigcompat.SignatureProviderGPT + } + if detected != effectiveTarget { + return "", false + } + normalized, ok := sigcompat.CompatibleSignatureForProvider(effectiveTarget, rawSignature) + return normalized, ok && normalized != "" + } + if target == sigcompat.SignatureProviderGrok { + if _, err := sigcompat.InspectGrokEncryptedContent(rawSignature); err == nil { + return rawSignature, true + } + } + return "", false +} diff --git a/internal/translator/common/parts.go b/internal/translator/common/parts.go new file mode 100644 index 00000000..adfa2829 --- /dev/null +++ b/internal/translator/common/parts.go @@ -0,0 +1,180 @@ +package common + +import ( + "fmt" + "net/url" + "strings" + + "github.com/tidwall/gjson" +) + +// UnsupportedPartError is a request-scoped rejection. The part type was present +// and this translation has no equivalent, so the request must not be sent with +// that part removed. +type UnsupportedPartError struct { + Type string +} + +func (e *UnsupportedPartError) Error() string { + if e == nil || e.Type == "" { + return "unsupported content part" + } + return fmt.Sprintf("unsupported content part: %s", e.Type) +} + +func (e *UnsupportedPartError) StatusCode() int { return 400 } + +func (e *UnsupportedPartError) IsRequestScoped() bool { return true } + +// UserTurnDrops is the shared policy for a part a translation cannot send: text +// or any other sendable part beside it still goes out, but a user turn left with +// nothing to send is refused rather than forwarded empty. The decision is made +// per user turn, so earlier or later turns, system prompts and developer +// prompts can never hide an emptied user turn. +type UserTurnDrops struct { + turn string + first string +} + +// Drop records a part of the current user turn that cannot be sent. The first +// type recorded within a turn is the one reported. +func (d *UserTurnDrops) Drop(partType string) { + if d.turn == "" { + d.turn = partType + } +} + +// EndTurn closes the current user turn. sendable is the number of parts the turn +// contributed to the translated request. A turn that dropped a part and +// contributed nothing is remembered; a later turn never clears it. +func (d *UserTurnDrops) EndTurn(sendable int) { + if d.turn != "" && sendable <= 0 && d.first == "" { + d.first = d.turn + } + d.turn = "" +} + +// Err returns the refusal for the first emptied user turn, or nil. +func (d *UserTurnDrops) Err() error { + if d.first == "" { + return nil + } + return &UnsupportedPartError{Type: d.first} +} + +// UserRun follows the consecutive user content that a target merges into one +// user turn. Add records content that keeps the turn alive, Drop records a part +// that could not be sent, and End closes the turn when model content starts or +// the input ends. Every method is a no-op on a nil receiver, so callers can pass +// nil for content that is not user content. +type UserRun struct { + drops UserTurnDrops + sendable int +} + +// Add records one sendable item in the current user turn. +func (r *UserRun) Add() { + if r != nil { + r.sendable++ + } +} + +// Drop records a part of the current user turn that cannot be sent. +func (r *UserRun) Drop(partType string) { + if r != nil { + r.drops.Drop(partType) + } +} + +// End closes the current user turn. +func (r *UserRun) End() { + if r != nil { + r.drops.EndTurn(r.sendable) + r.sendable = 0 + } +} + +// Err returns the refusal for the first emptied user turn, or nil. +func (r *UserRun) Err() error { + if r == nil { + return nil + } + return r.drops.Err() +} + +// IsInteractionsInstructionStep reports whether an Interactions step carries +// developer or system content rather than user content. The step's own role +// decides, falling back to its type; a step that names neither inherits the +// answer from the wrapper it sits in. Instruction content is still sent, but it +// closes the open user turn and never keeps an emptied one alive. +func IsInteractionsInstructionStep(step gjson.Result, inherited bool) bool { + name := strings.ToLower(strings.TrimSpace(step.Get("role").String())) + if name == "" { + name = strings.ToLower(strings.TrimSpace(step.Get("type").String())) + } + switch name { + case "developer", "system": + return true + case "user", "assistant", "model", "model_output", "thought": + return false + } + return inherited +} + +// InteractionsAttachmentType names the type of an Interactions content part that +// carries an attachment, or returns "" for text and for parts that name nothing. +// It is the type reported when a translation cannot send the part. +func InteractionsAttachmentType(part gjson.Result) string { + if !part.IsObject() { + return "" + } + if partType := strings.ToLower(strings.TrimSpace(part.Get("type").String())); partType != "" { + if partType == "text" { + return "" + } + return partType + } + if part.Get("inlineData").Exists() || part.Get("inline_data").Exists() { + return "inlineData" + } + if part.Get("fileData").Exists() || part.Get("file_data").Exists() { + return "fileData" + } + return "" +} + +// GeminiPartIsSendable reports whether a Gemini part gives the model something to +// read. A text part that is empty or only whitespace does not. +func GeminiPartIsSendable(part []byte) bool { + parsed := gjson.ParseBytes(part) + text := parsed.Get("text") + if !text.Exists() || strings.TrimSpace(text.String()) != "" { + return true + } + for _, key := range []string{"functionCall", "functionResponse", "inlineData", "inline_data", "fileData", "file_data"} { + if parsed.Get(key).Exists() { + return true + } + } + return false +} + +// CountSendableGeminiParts counts the parts for which GeminiPartIsSendable holds. +func CountSendableGeminiParts(parts [][]byte) int { + count := 0 + for _, part := range parts { + if GeminiPartIsSendable(part) { + count++ + } + } + return count +} + +// IsHTTPURL reports whether value is an absolute http or https URL with a host. +func IsHTTPURL(value string) bool { + parsed, errParse := url.Parse(strings.TrimSpace(value)) + if errParse != nil || parsed.Host == "" { + return false + } + return strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https") +} diff --git a/internal/translator/interactions/claude/interactions_claude_request.go b/internal/translator/interactions/claude/interactions_claude_request.go index 07f4ef4b..a47356e8 100644 --- a/internal/translator/interactions/claude/interactions_claude_request.go +++ b/internal/translator/interactions/claude/interactions_claude_request.go @@ -3,23 +3,28 @@ package claude import ( "strings" + sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) -func ConvertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { return convertClaudeRequestToInteractions(modelName, inputRawJSON, stream, false) + } // ConvertClaudeRequestToInteractionsWithCompat preserves empty assistant // thinking blocks for configured compatibility endpoints. -func ConvertClaudeRequestToInteractionsWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte { +func ConvertClaudeRequestToInteractionsWithCompat(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) { return convertClaudeRequestToInteractions(modelName, inputRawJSON, stream, true) } -func convertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) []byte { - root := gjson.ParseBytes(inputRawJSON) +func convertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks bool) ([]byte, error) { + // The internal commentary marker must be restored as assistant text before + // any thinking handling, so it is never dropped or mapped to thought. + restoredRawJSON, _ := sigcompat.RestoreCodexCommentaryMarkers(inputRawJSON) + root := gjson.ParseBytes(restoredRawJSON) out := []byte(`{"model":"","input":[]}`) out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String())) if streamValue, ok := claudeRequestStreamValue(root, stream); ok { @@ -27,9 +32,9 @@ func convertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, s } out = copyClaudeSystemToInteractions(out, root) out = copyClaudeGenerationConfigToInteractions(out, root) - out = appendClaudeMessagesToInteractions(out, root.Get("messages"), preserveEmptyThinkingBlocks) + out, errDrops := appendClaudeMessagesToInteractions(out, root.Get("messages"), preserveEmptyThinkingBlocks) out = copyClaudeToolsToInteractions(out, root) - return out + return out, errDrops } func claudeRequestStreamValue(root gjson.Result, stream bool) (bool, bool) { @@ -122,10 +127,11 @@ func copyClaudeToolChoiceToInteractions(out []byte, toolChoice gjson.Result) []b return out } -func appendClaudeMessagesToInteractions(out []byte, messages gjson.Result, preserveEmptyThinkingBlocks bool) []byte { +func appendClaudeMessagesToInteractions(out []byte, messages gjson.Result, preserveEmptyThinkingBlocks bool) ([]byte, error) { if !messages.Exists() || !messages.IsArray() { - return out + return out, nil } + var drops translatorcommon.UserTurnDrops inputItems := translatorcommon.NewRawArrayItems(messages.Get("#").Int()) var pendingToolUseIDs []string var pendingSystemReminders [][]byte @@ -152,7 +158,10 @@ func appendClaudeMessagesToInteractions(out []byte, messages gjson.Result, prese } pendingToolUseIDs = nil - appendClaudeMessageToInteractions(&inputItems, &pendingToolUseIDs, &pendingSystemReminders, toolNamesByID, role, content, preserveEmptyThinkingBlocks) + sendable := appendClaudeMessageToInteractions(&inputItems, &pendingToolUseIDs, &pendingSystemReminders, toolNamesByID, role, content, preserveEmptyThinkingBlocks, &drops) + if role == "user" { + drops.EndTurn(sendable) + } if len(pendingSystemReminders) > 0 { inputItems = append(inputItems, pendingSystemReminders...) pendingSystemReminders = nil @@ -163,10 +172,10 @@ func appendClaudeMessagesToInteractions(out []byte, messages gjson.Result, prese inputItems = append(inputItems, pendingSystemReminders...) } out = translatorcommon.SetRawArrayItems(out, "input", inputItems) - return out + return out, drops.Err() } -func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]string, pendingSystemReminders *[][]byte, toolNamesByID map[string]string, role string, content gjson.Result, preserveEmptyThinkingBlocks bool) { +func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]string, pendingSystemReminders *[][]byte, toolNamesByID map[string]string, role string, content gjson.Result, preserveEmptyThinkingBlocks bool, drops *translatorcommon.UserTurnDrops) int { defaultStepType := "user_input" if role == "assistant" { defaultStepType = "model_output" @@ -180,11 +189,14 @@ func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]str step, _ = sjson.SetBytes(step, "type", defaultStepType) step, _ = sjson.SetBytes(step, "content.0.text", content.String()) *items = append(*items, step) - return + return 1 } if !content.IsArray() { - return + return 0 } + // Counts only what this turn itself sends, not system reminders flushed beside it. + // Whitespace-only text is forwarded but never keeps an emptied turn alive. + sendable := 0 stepContent := make([][]byte, 0, 4) flushContent := func() { if len(stepContent) == 0 { @@ -209,6 +221,9 @@ func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]str contentPart := []byte(`{"type":"text","text":""}`) contentPart, _ = sjson.SetBytes(contentPart, "text", text) stepContent = append(stepContent, contentPart) + if strings.TrimSpace(text) != "" { + sendable++ + } } case "thinking": flushContent() @@ -217,8 +232,9 @@ func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]str step := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`) step, _ = sjson.SetBytes(step, "content.0.text", text) *items = append(*items, step) + sendable++ } - case "image", "document": + case "image", "document", "container_upload": if mediaPart, ok := claudeMediaPartToInteractions(part, partType); ok { if pendingSystemReminders != nil && len(*pendingSystemReminders) > 0 { flushContent() @@ -226,6 +242,9 @@ func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]str *pendingSystemReminders = nil } stepContent = append(stepContent, mediaPart) + sendable++ + } else if drops != nil && role == "user" { + drops.Drop(partType) } case "tool_use": flushContent() @@ -240,13 +259,16 @@ func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]str } } *items = append(*items, claudeToolUseToInteractions(part)) + sendable++ case "tool_result": flushContent() *items = append(*items, claudeToolResultToInteractions(part, toolNamesByID)) + sendable++ } return true }) flushContent() + return sendable } func claudeMediaPartToInteractions(part gjson.Result, partType string) ([]byte, bool) { @@ -327,7 +349,7 @@ func claudeToolResultToInteractions(part gjson.Result, toolNamesByID map[string] } else if raw := strings.TrimSpace(item.Raw); raw != "" { contentItems = append(contentItems, []byte(raw)) } - case "image", "document": + case "image", "document", "container_upload": if mediaPart, ok := claudeMediaPartToInteractions(item, itemType); ok { contentItems = append(contentItems, mediaPart) } else if raw := strings.TrimSpace(item.Raw); raw != "" { diff --git a/sdk/translator/pipeline.go b/sdk/translator/pipeline.go index 569b9326..5cd2e9ac 100644 --- a/sdk/translator/pipeline.go +++ b/sdk/translator/pipeline.go @@ -15,6 +15,8 @@ type RequestEnvelope struct { ModelInfo *registry.ModelInfo // ConfigurationUpdatesChanged reports that a plugin normalizer modified Responses updates. ConfigurationUpdatesChanged bool + // Err is a request-scoped translation failure. Callers must not send Body upstream when it is set. + Err error } // ResponseEnvelope represents a response in the translation pipeline. diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index b0441161..145606bf 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -36,7 +36,7 @@ func (r *Registry) Register(from, to Format, request RequestTransform, response } if request != nil { r.requests[from][to] = func(_ context.Context, req RequestEnvelope) RequestEnvelope { - req.Body = request(req.Model, req.Body, req.Stream) + req.Body, req.Err = request(req.Model, req.Body, req.Stream) return req } } @@ -247,6 +247,19 @@ func (r *Registry) HasNonStreamResponseTransformer(from, to Format) bool { return false } +// HasTokenCountTransformer indicates whether a token count translator exists. +func (r *Registry) HasTokenCountTransformer(from, to Format) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + if byTarget, ok := r.responses[to]; ok { + if fn, isOk := byTarget[from]; isOk && fn.TokenCount != nil { + return true + } + } + return false +} + // TranslateStream applies the registered streaming response translator. func (r *Registry) TranslateStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { r.mu.RLock() @@ -422,6 +435,11 @@ func HasNonStreamResponseTransformer(from, to Format) bool { return defaultRegistry.HasNonStreamResponseTransformer(from, to) } +// HasTokenCountTransformer inspects the default registry. +func HasTokenCountTransformer(from, to Format) bool { + return defaultRegistry.HasTokenCountTransformer(from, to) +} + // TranslateStream is a helper on the default registry. func TranslateStream(ctx context.Context, from, to Format, model string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { return defaultRegistry.TranslateStream(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, rawJSON, param) diff --git a/sdk/translator/types.go b/sdk/translator/types.go index c4e52a65..81b4dece 100644 --- a/sdk/translator/types.go +++ b/sdk/translator/types.go @@ -3,10 +3,11 @@ package translator import "context" -// RequestTransform is a function type that converts a request payload from a source schema to a target schema. -// It takes the model name, the raw JSON payload of the request, and a boolean indicating if the request is for a streaming response. -// It returns the converted request payload as a byte slice. -type RequestTransform func(model string, rawJSON []byte, stream bool) []byte +// RequestTransform converts a request payload from a source schema to a target schema. +// A non-nil error is a request-scoped refusal. Registry.Register stores it on +// RequestEnvelope.Err so executors can reject the request before calling upstream. +// TranslateRequest still returns only the body for legacy callers. +type RequestTransform func(model string, rawJSON []byte, stream bool) ([]byte, error) // RequestEnvelopeTransform translates a request while preserving request-scoped metadata. type RequestEnvelopeTransform func(ctx context.Context, req RequestEnvelope) RequestEnvelope