diff --git a/README.md b/README.md index 67169fe1ea..a040913582 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,24 @@ llm: agentapi --cliproxy http://localhost:8317 ``` +## Managed service (Windows / Linux / macOS) + +Use the repo service helper to install/start/stop/restart from one command. + +```bash +task service:install # install platform service scaffold +task service:start # start service +task service:status # inspect service status +task service:restart # restart service +task service:stop # stop service +``` + +Platform behavior: + +- Linux: manages `cliproxyapi-plusplus.service` via systemd. +- macOS: manages `com.router-for-me.cliproxyapi-plusplus` via launchd. +- Windows: uses `examples/windows/cliproxyapi-plusplus-service.ps1` for install/start/stop/status. + ## Development ```bash diff --git a/Taskfile.yml b/Taskfile.yml index 3f944473e4..ae5aa3cb59 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -343,6 +343,7 @@ tasks: - task: quality:vet - task: quality:staticcheck - task: quality:shellcheck + - task: service:test - task: lint:changed test:provider-smoke-matrix:test: @@ -356,10 +357,7 @@ tasks: cmds: - task: preflight - task: quality:docs-open-items-parity -<<<<<<< HEAD - task: quality:docs-phase-placeholders -======= ->>>>>>> archive/pr-234-head-20260223 - ./.github/scripts/release-lint.sh quality:docs-open-items-parity: @@ -367,14 +365,11 @@ tasks: cmds: - ./.github/scripts/check-open-items-fragmented-parity.sh -<<<<<<< HEAD quality:docs-phase-placeholders: desc: "Reject unresolved placeholder-like tokens in planning reports" cmds: - ./.github/scripts/check-phase-doc-placeholder-tokens.sh -======= ->>>>>>> archive/pr-234-head-20260223 test:smoke: desc: "Run smoke tests for startup and control-plane surfaces" deps: [preflight, cache:unlock] @@ -484,6 +479,37 @@ tasks: cmds: - docker compose down + # -- Service Operations -- + service:status: + desc: "Show service status for installed system service" + cmds: + - ./scripts/cliproxy-service.sh status + + service:start: + desc: "Start cliproxyapi++ service (systemd/launchd/Windows helper)" + cmds: + - ./scripts/cliproxy-service.sh start + + service:stop: + desc: "Stop cliproxyapi++ service (systemd/launchd/Windows helper)" + cmds: + - ./scripts/cliproxy-service.sh stop + + service:restart: + desc: "Restart cliproxyapi++ service (systemd/launchd/Windows helper)" + cmds: + - ./scripts/cliproxy-service.sh restart + + service:install: + desc: "Install cliproxyapi++ service scaffold (systemd/launchd)" + cmds: + - ./scripts/cliproxy-service.sh install + + service:test: + desc: "Run cliproxy service helper contract tests" + cmds: + - ./scripts/cliproxy-service-test.sh + # -- Health & Diagnostics (UX/DX) -- doctor: desc: "Check environment health for cliproxyapi++" diff --git a/cmd/server/main.go b/cmd/server/main.go index 2ef8c33913..4435fc2da5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -63,6 +63,13 @@ func setKiroIncognitoMode(cfg *config.Config, useIncognito, noIncognito bool) { } } +func validateKiroIncognitoFlags(useIncognito, noIncognito bool) error { + if useIncognito && noIncognito { + return fmt.Errorf("--incognito and --no-incognito cannot be used together") + } + return nil +} + // main is the entry point of the application. // It parses command-line flags, loads configuration, and starts the appropriate // service based on the provided flags (login, codex-login, or server mode). @@ -152,9 +159,13 @@ func main() { // Parse the command-line flags. flag.Parse() + var err error + if err = validateKiroIncognitoFlags(useIncognito, noIncognito); err != nil { + log.Errorf("invalid Kiro browser flags: %v", err) + return + } // Core application variables. - var err error var cfg *config.Config var isCloudDeploy bool var ( @@ -620,15 +631,15 @@ func main() { } } } else { - // Start the main proxy service - managementasset.StartAutoUpdater(context.Background(), configFilePath) + // Start the main proxy service + managementasset.StartAutoUpdater(context.Background(), configFilePath) - if cfg.AuthDir != "" { - kiro.InitializeAndStart(cfg.AuthDir, cfg) - defer kiro.StopGlobalRefreshManager() - } + if cfg.AuthDir != "" { + kiro.InitializeAndStart(cfg.AuthDir, cfg) + defer kiro.StopGlobalRefreshManager() + } - cmd.StartService(cfg, configFilePath, password) + cmd.StartService(cfg, configFilePath, password) } } } diff --git a/cmd/server/main_kiro_flags_test.go b/cmd/server/main_kiro_flags_test.go index 88af078428..21c406a553 100644 --- a/cmd/server/main_kiro_flags_test.go +++ b/cmd/server/main_kiro_flags_test.go @@ -3,7 +3,7 @@ package main import ( "testing" - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" ) func TestValidateKiroIncognitoFlags(t *testing.T) { diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index bd1338a279..f8cacaacb6 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -2876,7 +2876,7 @@ func (h *Handler) RequestKiloToken(c *gin.Context) { Metadata: map[string]any{ "email": status.UserEmail, "organization_id": orgID, - "model": defaults.Model, + "model": defaults.Model, }, } diff --git a/internal/api/modules/amp/proxy.go b/internal/api/modules/amp/proxy.go index c593c1b328..ff869cee54 100644 --- a/internal/api/modules/amp/proxy.go +++ b/internal/api/modules/amp/proxy.go @@ -105,12 +105,21 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi // Modify incoming responses to handle gzip without Content-Encoding // This addresses the same issue as inline handler gzip handling, but at the proxy level proxy.ModifyResponse = func(resp *http.Response) error { + method := "" + path := "" + if resp.Request != nil { + method = resp.Request.Method + if resp.Request.URL != nil { + path = resp.Request.URL.Path + } + } + // Log upstream error responses for diagnostics (502, 503, etc.) // These are NOT proxy connection errors - the upstream responded with an error status if resp.StatusCode >= 500 { - log.Errorf("amp upstream responded with error [%d] for %s %s", resp.StatusCode, resp.Request.Method, resp.Request.URL.Path) + log.Errorf("amp upstream responded with error [%d] for %s %s", resp.StatusCode, method, path) } else if resp.StatusCode >= 400 { - log.Warnf("amp upstream responded with client error [%d] for %s %s", resp.StatusCode, resp.Request.Method, resp.Request.URL.Path) + log.Warnf("amp upstream responded with client error [%d] for %s %s", resp.StatusCode, method, path) } // Only process successful responses for gzip decompression diff --git a/internal/auth/copilot/copilot_auth.go b/internal/auth/copilot/copilot_auth.go index c40e7082b8..1e8af66557 100644 --- a/internal/auth/copilot/copilot_auth.go +++ b/internal/auth/copilot/copilot_auth.go @@ -22,11 +22,11 @@ const ( copilotAPIEndpoint = "https://api.githubcopilot.com" // Common HTTP header values for Copilot API requests. - copilotUserAgent = "GithubCopilot/1.0" - copilotEditorVersion = "vscode/1.100.0" - copilotPluginVersion = "copilot/1.300.0" - copilotIntegrationID = "vscode-chat" - copilotOpenAIIntent = "conversation-panel" + copilotUserAgent = "GithubCopilot/1.0" + copilotEditorVersion = "vscode/1.100.0" + copilotPluginVersion = "copilot/1.300.0" + copilotIntegrationID = "vscode-chat" + copilotOpenAIIntent = "conversation-panel" ) // CopilotAPIToken represents the Copilot API token response. diff --git a/internal/auth/kiro/aws.go b/internal/auth/kiro/aws.go index 6ec67c499a..424b6c8162 100644 --- a/internal/auth/kiro/aws.go +++ b/internal/auth/kiro/aws.go @@ -506,17 +506,14 @@ func GenerateTokenFileName(tokenData *KiroTokenData) string { return fmt.Sprintf("kiro-%s-%s.json", authMethod, sanitizedEmail) } - // Generate sequence only when email is unavailable - seq := time.Now().UnixNano() % 100000 - - // Priority 2: For IDC, use startUrl identifier with sequence + // Priority 2: For IDC, use startUrl identifier if authMethod == "idc" && tokenData.StartURL != "" { identifier := ExtractIDCIdentifier(tokenData.StartURL) if identifier != "" { - return fmt.Sprintf("kiro-%s-%s-%05d.json", authMethod, identifier, seq) + return fmt.Sprintf("kiro-%s-%s.json", authMethod, identifier) } } - // Priority 3: Fallback to authMethod only with sequence - return fmt.Sprintf("kiro-%s-%05d.json", authMethod, seq) + // Priority 3: Fallback to authMethod only + return fmt.Sprintf("kiro-%s.json", authMethod) } diff --git a/internal/auth/kiro/aws_auth.go b/internal/auth/kiro/aws_auth.go index 69ae253914..c189deb8fe 100644 --- a/internal/auth/kiro/aws_auth.go +++ b/internal/auth/kiro/aws_auth.go @@ -23,11 +23,11 @@ const ( // Note: This is different from the Amazon Q streaming endpoint (q.us-east-1.amazonaws.com) // used in kiro_executor.go for GenerateAssistantResponse. Both endpoints are correct // for their respective API operations. - awsKiroEndpoint = "https://codewhisperer.us-east-1.amazonaws.com" - defaultTokenFile = "~/.aws/sso/cache/kiro-auth-token.json" - targetGetUsage = "AmazonCodeWhispererService.GetUsageLimits" - targetListModels = "AmazonCodeWhispererService.ListAvailableModels" - targetGenerateChat = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse" + awsKiroEndpoint = "https://codewhisperer.us-east-1.amazonaws.com" + defaultTokenFile = "~/.aws/sso/cache/kiro-auth-token.json" + targetGetUsage = "AmazonCodeWhispererService.GetUsageLimits" + targetListModels = "AmazonCodeWhispererService.ListAvailableModels" + targetGenerateChat = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse" ) // KiroAuth handles AWS CodeWhisperer authentication and API communication. diff --git a/internal/auth/kiro/codewhisperer_client.go b/internal/auth/kiro/codewhisperer_client.go index 0a7392e827..47682d1aa3 100644 --- a/internal/auth/kiro/codewhisperer_client.go +++ b/internal/auth/kiro/codewhisperer_client.go @@ -28,11 +28,11 @@ type CodeWhispererClient struct { // UsageLimitsResponse represents the getUsageLimits API response. type UsageLimitsResponse struct { - DaysUntilReset *int `json:"daysUntilReset,omitempty"` - NextDateReset *float64 `json:"nextDateReset,omitempty"` - UserInfo *UserInfo `json:"userInfo,omitempty"` - SubscriptionInfo *SubscriptionInfo `json:"subscriptionInfo,omitempty"` - UsageBreakdownList []UsageBreakdown `json:"usageBreakdownList,omitempty"` + DaysUntilReset *int `json:"daysUntilReset,omitempty"` + NextDateReset *float64 `json:"nextDateReset,omitempty"` + UserInfo *UserInfo `json:"userInfo,omitempty"` + SubscriptionInfo *SubscriptionInfo `json:"subscriptionInfo,omitempty"` + UsageBreakdownList []UsageBreakdown `json:"usageBreakdownList,omitempty"` } // UserInfo contains user information from the API. @@ -49,13 +49,13 @@ type SubscriptionInfo struct { // UsageBreakdown contains usage details. type UsageBreakdown struct { - UsageLimit *int `json:"usageLimit,omitempty"` - CurrentUsage *int `json:"currentUsage,omitempty"` - UsageLimitWithPrecision *float64 `json:"usageLimitWithPrecision,omitempty"` - CurrentUsageWithPrecision *float64 `json:"currentUsageWithPrecision,omitempty"` - NextDateReset *float64 `json:"nextDateReset,omitempty"` - DisplayName string `json:"displayName,omitempty"` - ResourceType string `json:"resourceType,omitempty"` + UsageLimit *int `json:"usageLimit,omitempty"` + CurrentUsage *int `json:"currentUsage,omitempty"` + UsageLimitWithPrecision *float64 `json:"usageLimitWithPrecision,omitempty"` + CurrentUsageWithPrecision *float64 `json:"currentUsageWithPrecision,omitempty"` + NextDateReset *float64 `json:"nextDateReset,omitempty"` + DisplayName string `json:"displayName,omitempty"` + ResourceType string `json:"resourceType,omitempty"` } // NewCodeWhispererClient creates a new CodeWhisperer client. diff --git a/internal/auth/kiro/cooldown.go b/internal/auth/kiro/cooldown.go index c1aabbcb4d..716135b688 100644 --- a/internal/auth/kiro/cooldown.go +++ b/internal/auth/kiro/cooldown.go @@ -6,8 +6,8 @@ import ( ) const ( - CooldownReason429 = "rate_limit_exceeded" - CooldownReasonSuspended = "account_suspended" + CooldownReason429 = "rate_limit_exceeded" + CooldownReasonSuspended = "account_suspended" CooldownReasonQuotaExhausted = "quota_exhausted" DefaultShortCooldown = 1 * time.Minute diff --git a/internal/auth/kiro/fingerprint.go b/internal/auth/kiro/fingerprint.go index c35e62b2b2..45ed4e4d50 100644 --- a/internal/auth/kiro/fingerprint.go +++ b/internal/auth/kiro/fingerprint.go @@ -37,7 +37,7 @@ var ( "1.0.20", "1.0.21", "1.0.22", "1.0.23", "1.0.24", "1.0.25", "1.0.26", "1.0.27", } - osTypes = []string{"darwin", "windows", "linux"} + osTypes = []string{"darwin", "windows", "linux"} osVersions = map[string][]string{ "darwin": {"14.0", "14.1", "14.2", "14.3", "14.4", "14.5", "15.0", "15.1"}, "windows": {"10.0.19041", "10.0.19042", "10.0.19043", "10.0.19044", "10.0.22621", "10.0.22631"}, @@ -67,9 +67,9 @@ var ( "1366x768", "1440x900", "1680x1050", "2560x1600", "3440x1440", } - colorDepths = []int{24, 32} + colorDepths = []int{24, 32} hardwareConcurrencies = []int{4, 6, 8, 10, 12, 16, 20, 24, 32} - timezoneOffsets = []int{-480, -420, -360, -300, -240, 0, 60, 120, 480, 540} + timezoneOffsets = []int{-480, -420, -360, -300, -240, 0, 60, 120, 480, 540} ) // NewFingerprintManager 创建指纹管理器 diff --git a/internal/auth/kiro/jitter.go b/internal/auth/kiro/jitter.go index 0569a8fb18..fef2aea949 100644 --- a/internal/auth/kiro/jitter.go +++ b/internal/auth/kiro/jitter.go @@ -26,9 +26,9 @@ const ( ) var ( - jitterRand *rand.Rand - jitterRandOnce sync.Once - jitterMu sync.Mutex + jitterRand *rand.Rand + jitterRandOnce sync.Once + jitterMu sync.Mutex lastRequestTime time.Time ) diff --git a/internal/auth/kiro/metrics.go b/internal/auth/kiro/metrics.go index 0fe2d0c69e..f9540fc17f 100644 --- a/internal/auth/kiro/metrics.go +++ b/internal/auth/kiro/metrics.go @@ -24,10 +24,10 @@ type TokenScorer struct { metrics map[string]*TokenMetrics // Scoring weights - successRateWeight float64 - quotaWeight float64 - latencyWeight float64 - lastUsedWeight float64 + successRateWeight float64 + quotaWeight float64 + latencyWeight float64 + lastUsedWeight float64 failPenaltyMultiplier float64 } diff --git a/internal/auth/kiro/oauth.go b/internal/auth/kiro/oauth.go index a286cf4229..26c2fa87f5 100644 --- a/internal/auth/kiro/oauth.go +++ b/internal/auth/kiro/oauth.go @@ -23,10 +23,10 @@ import ( const ( // Kiro auth endpoint kiroAuthEndpoint = "https://prod.us-east-1.auth.desktop.kiro.dev" - + // Default callback port defaultCallbackPort = 9876 - + // Auth timeout authTimeout = 10 * time.Minute ) diff --git a/internal/auth/kiro/oauth_web.go b/internal/auth/kiro/oauth_web.go index 88fba6726c..24b0f85bc3 100644 --- a/internal/auth/kiro/oauth_web.go +++ b/internal/auth/kiro/oauth_web.go @@ -35,35 +35,35 @@ const ( ) type webAuthSession struct { - stateID string - deviceCode string - userCode string - authURL string - verificationURI string - expiresIn int - interval int - status authSessionStatus - startedAt time.Time - completedAt time.Time - expiresAt time.Time - error string - tokenData *KiroTokenData - ssoClient *SSOOIDCClient - clientID string - clientSecret string - region string - cancelFunc context.CancelFunc - authMethod string // "google", "github", "builder-id", "idc" - startURL string // Used for IDC - codeVerifier string // Used for social auth PKCE - codeChallenge string // Used for social auth PKCE + stateID string + deviceCode string + userCode string + authURL string + verificationURI string + expiresIn int + interval int + status authSessionStatus + startedAt time.Time + completedAt time.Time + expiresAt time.Time + error string + tokenData *KiroTokenData + ssoClient *SSOOIDCClient + clientID string + clientSecret string + region string + cancelFunc context.CancelFunc + authMethod string // "google", "github", "builder-id", "idc" + startURL string // Used for IDC + codeVerifier string // Used for social auth PKCE + codeChallenge string // Used for social auth PKCE } type OAuthWebHandler struct { - cfg *config.Config - sessions map[string]*webAuthSession - mu sync.RWMutex - onTokenObtained func(*KiroTokenData) + cfg *config.Config + sessions map[string]*webAuthSession + mu sync.RWMutex + onTokenObtained func(*KiroTokenData) } func NewOAuthWebHandler(cfg *config.Config) *OAuthWebHandler { @@ -104,7 +104,7 @@ func (h *OAuthWebHandler) handleSelect(c *gin.Context) { func (h *OAuthWebHandler) handleStart(c *gin.Context) { method := c.Query("method") - + if method == "" { c.Redirect(http.StatusFound, "/v0/oauth/kiro") return @@ -138,7 +138,7 @@ func (h *OAuthWebHandler) startSocialAuth(c *gin.Context, method string) { } socialClient := NewSocialAuthClient(h.cfg) - + var provider string if method == "google" { provider = string(ProviderGoogle) @@ -377,18 +377,18 @@ func (h *OAuthWebHandler) pollForToken(ctx context.Context, session *webAuthSess email := FetchUserEmailWithFallback(ctx, h.cfg, tokenResp.AccessToken) tokenData := &KiroTokenData{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - ProfileArn: profileArn, - ExpiresAt: expiresAt.Format(time.RFC3339), - AuthMethod: session.authMethod, - Provider: "AWS", - ClientID: session.clientID, - ClientSecret: session.clientSecret, - Email: email, - Region: session.region, - StartURL: session.startURL, - } + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + ProfileArn: profileArn, + ExpiresAt: expiresAt.Format(time.RFC3339), + AuthMethod: session.authMethod, + Provider: "AWS", + ClientID: session.clientID, + ClientSecret: session.clientSecret, + Email: email, + Region: session.region, + StartURL: session.startURL, + } h.mu.Lock() session.status = statusSuccess @@ -442,7 +442,7 @@ func (h *OAuthWebHandler) saveTokenToFile(tokenData *KiroTokenData) { fileName := GenerateTokenFileName(tokenData) authFilePath := filepath.Join(authDir, fileName) - + // Convert to storage format and save storage := &KiroTokenStorage{ Type: "kiro", @@ -459,12 +459,12 @@ func (h *OAuthWebHandler) saveTokenToFile(tokenData *KiroTokenData) { StartURL: tokenData.StartURL, Email: tokenData.Email, } - + if err := storage.SaveTokenToFile(authFilePath); err != nil { log.Errorf("OAuth Web: failed to save token to file: %v", err) return } - + log.Infof("OAuth Web: token saved to %s", authFilePath) } diff --git a/internal/auth/kiro/protocol_handler.go b/internal/auth/kiro/protocol_handler.go index d900ee3340..a1c28a86ab 100644 --- a/internal/auth/kiro/protocol_handler.go +++ b/internal/auth/kiro/protocol_handler.go @@ -97,7 +97,7 @@ func (h *ProtocolHandler) Start(ctx context.Context) (int, error) { var listener net.Listener var err error portRange := []int{DefaultHandlerPort, DefaultHandlerPort + 1, DefaultHandlerPort + 2, DefaultHandlerPort + 3, DefaultHandlerPort + 4} - + for _, port := range portRange { listener, err = net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) if err == nil { @@ -105,7 +105,7 @@ func (h *ProtocolHandler) Start(ctx context.Context) (int, error) { } log.Debugf("kiro protocol handler: port %d busy, trying next", port) } - + if listener == nil { return 0, fmt.Errorf("failed to start callback server: all ports %d-%d are busy", DefaultHandlerPort, DefaultHandlerPort+4) } diff --git a/internal/auth/kiro/social_auth.go b/internal/auth/kiro/social_auth.go index 65f31ba46f..63807b557c 100644 --- a/internal/auth/kiro/social_auth.go +++ b/internal/auth/kiro/social_auth.go @@ -466,7 +466,7 @@ func forceDefaultProtocolHandler() { if runtime.GOOS != "linux" { return // Non-Linux platforms use different handler mechanisms } - + // Set our handler as default using xdg-mime cmd := exec.Command("xdg-mime", "default", "kiro-oauth-handler.desktop", "x-scheme-handler/kiro") if err := cmd.Run(); err != nil { diff --git a/internal/auth/kiro/sso_oidc.go b/internal/auth/kiro/sso_oidc.go index 60fb887190..05875b3d8c 100644 --- a/internal/auth/kiro/sso_oidc.go +++ b/internal/auth/kiro/sso_oidc.go @@ -74,10 +74,10 @@ func NewSSOOIDCClient(cfg *config.Config) *SSOOIDCClient { // RegisterClientResponse from AWS SSO OIDC. type RegisterClientResponse struct { - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` - ClientIDIssuedAt int64 `json:"clientIdIssuedAt"` - ClientSecretExpiresAt int64 `json:"clientSecretExpiresAt"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + ClientIDIssuedAt int64 `json:"clientIdIssuedAt"` + ClientSecretExpiresAt int64 `json:"clientSecretExpiresAt"` } // StartDeviceAuthResponse from AWS SSO OIDC. @@ -859,15 +859,15 @@ func (c *SSOOIDCClient) LoginWithBuilderID(ctx context.Context) (*KiroTokenData, Email: email, Region: defaultIDCRegion, }, nil - } - } + } + } - // Close browser on timeout for better UX - if err := browser.CloseBrowser(); err != nil { - log.Debugf("Failed to close browser on timeout: %v", err) - } - return nil, fmt.Errorf("authorization timed out") - } + // Close browser on timeout for better UX + if err := browser.CloseBrowser(); err != nil { + log.Debugf("Failed to close browser on timeout: %v", err) + } + return nil, fmt.Errorf("authorization timed out") +} // FetchUserEmail retrieves the user's email from AWS SSO OIDC userinfo endpoint. // Falls back to JWT parsing if userinfo fails. diff --git a/internal/browser/browser.go b/internal/browser/browser.go index 3a5aeea7e2..e8551788b3 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -39,7 +39,7 @@ func CloseBrowser() error { if lastBrowserProcess == nil || lastBrowserProcess.Process == nil { return nil } - + err := lastBrowserProcess.Process.Kill() lastBrowserProcess = nil return err diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index cdcf2e4f55..7f597062a6 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -22,8 +22,8 @@ var ( // ConvertCodexResponseToClaudeParams holds parameters for response conversion. type ConvertCodexResponseToClaudeParams struct { - HasToolCall bool - BlockIndex int + HasToolCall bool + BlockIndex int HasReceivedArgumentsDelta bool } diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go index 4f5624869f..ed365a2714 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go @@ -264,18 +264,18 @@ func TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) { } } -func TestUserFieldDeletion(t *testing.T) { +func TestUserFieldDeletion(t *testing.T) { inputJSON := []byte(`{ "model": "gpt-5.2", "user": "test-user", "input": [{"role": "user", "content": "Hello"}] - }`) - - output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) - outputStr := string(output) - - // Verify user field is deleted - userField := gjson.Get(outputStr, "user") + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) + outputStr := string(output) + + // Verify user field is deleted + userField := gjson.Get(outputStr, "user") if userField.Exists() { t.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw) } diff --git a/internal/translator/kiro/common/utils.go b/internal/translator/kiro/common/utils.go index f5f5788ab2..4c7c734085 100644 --- a/internal/translator/kiro/common/utils.go +++ b/internal/translator/kiro/common/utils.go @@ -13,4 +13,4 @@ func GetString(m map[string]interface{}, key string) string { // GetStringValue is an alias for GetString for backward compatibility. func GetStringValue(m map[string]interface{}, key string) string { return GetString(m, key) -} \ No newline at end of file +} diff --git a/internal/translator/kiro/openai/init.go b/internal/translator/kiro/openai/init.go index 653eed45ee..d43b21a721 100644 --- a/internal/translator/kiro/openai/init.go +++ b/internal/translator/kiro/openai/init.go @@ -17,4 +17,4 @@ func init() { NonStream: ConvertKiroNonStreamToOpenAI, }, ) -} \ No newline at end of file +} diff --git a/internal/translator/kiro/openai/kiro_openai_response.go b/internal/translator/kiro/openai/kiro_openai_response.go index edc70ad8cb..7d085de06d 100644 --- a/internal/translator/kiro/openai/kiro_openai_response.go +++ b/internal/translator/kiro/openai/kiro_openai_response.go @@ -274,4 +274,4 @@ func min(a, b int) int { return a } return b -} \ No newline at end of file +} diff --git a/internal/translator/kiro/openai/kiro_openai_stream.go b/internal/translator/kiro/openai/kiro_openai_stream.go index e72d970e0d..484a94ee0f 100644 --- a/internal/translator/kiro/openai/kiro_openai_stream.go +++ b/internal/translator/kiro/openai/kiro_openai_stream.go @@ -209,4 +209,4 @@ func NewThinkingTagState() *ThinkingTagState { PendingStartChars: 0, PendingEndChars: 0, } -} \ No newline at end of file +} diff --git a/pkg/llmproxy/access/reconcile.go b/pkg/llmproxy/access/reconcile.go index 290cac3e75..c410c4eb67 100644 --- a/pkg/llmproxy/access/reconcile.go +++ b/pkg/llmproxy/access/reconcile.go @@ -9,6 +9,7 @@ import ( configaccess "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/access/config_access" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/config" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" log "github.com/sirupsen/logrus" ) @@ -85,7 +86,9 @@ func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Con } existing := manager.Providers() - configaccess.Register((*config.SDKConfig)(&newCfg.SDKConfig)) + configaccess.Register(&sdkconfig.SDKConfig{ + APIKeys: append([]string(nil), newCfg.APIKeys...), + }) providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing) if err != nil { log.Errorf("failed to reconcile request auth providers: %v", err) diff --git a/pkg/llmproxy/api/handlers/management/config_basic.go b/pkg/llmproxy/api/handlers/management/config_basic.go index 7222570dcf..8e757e96f5 100644 --- a/pkg/llmproxy/api/handlers/management/config_basic.go +++ b/pkg/llmproxy/api/handlers/management/config_basic.go @@ -12,7 +12,6 @@ import ( "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/config" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/util" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" ) @@ -45,8 +44,7 @@ func (h *Handler) GetLatestVersion(c *gin.Context) { proxyURL = strings.TrimSpace(h.cfg.ProxyURL) } if proxyURL != "" { - sdkCfg := &sdkconfig.SDKConfig{ProxyURL: proxyURL} - util.SetProxy(sdkCfg, client) + util.SetProxy(&h.cfg.SDKConfig, client) } req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil) diff --git a/pkg/llmproxy/api/server.go b/pkg/llmproxy/api/server.go index 9564e83755..c7efba75d9 100644 --- a/pkg/llmproxy/api/server.go +++ b/pkg/llmproxy/api/server.go @@ -17,6 +17,7 @@ import ( "sync" "sync/atomic" "time" + "unsafe" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/access" @@ -37,6 +38,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" ) @@ -65,6 +67,10 @@ func defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging. return logging.NewFileRequestLogger(cfg.RequestLog, "logs", configDir, cfg.ErrorLogsMaxFiles) } +func castToSDKConfig(cfg *config.SDKConfig) *sdkconfig.SDKConfig { + return (*sdkconfig.SDKConfig)(unsafe.Pointer(cfg)) +} + // WithMiddleware appends additional Gin middleware during server construction. func WithMiddleware(mw ...gin.HandlerFunc) ServerOption { return func(cfg *serverOptionConfig) { @@ -239,7 +245,7 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk // Create server instance s := &Server{ engine: engine, - handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager), + handlers: handlers.NewBaseAPIHandlers(castToSDKConfig(&cfg.SDKConfig), authManager), cfg: cfg, accessManager: accessManager, requestLogger: requestLogger, @@ -1014,7 +1020,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { // Save YAML snapshot for next comparison s.oldConfigYaml, _ = yaml.Marshal(cfg) - s.handlers.UpdateClients(&cfg.SDKConfig) + s.handlers.UpdateClients(castToSDKConfig(&cfg.SDKConfig)) if s.mgmt != nil { s.mgmt.SetConfig(cfg) diff --git a/pkg/llmproxy/cmd/config_cast.go b/pkg/llmproxy/cmd/config_cast.go index bab4238a74..d738501f73 100644 --- a/pkg/llmproxy/cmd/config_cast.go +++ b/pkg/llmproxy/cmd/config_cast.go @@ -4,8 +4,8 @@ import ( "unsafe" internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/config" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" ) // castToInternalConfig converts a pkg/llmproxy/config.Config pointer to an internal/config.Config pointer. diff --git a/pkg/llmproxy/managementasset/updater.go b/pkg/llmproxy/managementasset/updater.go index 201b179481..a2553c49cf 100644 --- a/pkg/llmproxy/managementasset/updater.go +++ b/pkg/llmproxy/managementasset/updater.go @@ -18,8 +18,8 @@ import ( "time" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/config" - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/util" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/config" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/util" log "github.com/sirupsen/logrus" "golang.org/x/sync/singleflight" ) diff --git a/pkg/llmproxy/registry/registry_coverage_test.go b/pkg/llmproxy/registry/registry_coverage_test.go index 75fcff1222..7a1a2b0a9a 100644 --- a/pkg/llmproxy/registry/registry_coverage_test.go +++ b/pkg/llmproxy/registry/registry_coverage_test.go @@ -2,7 +2,7 @@ package registry import ( "testing" - + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -13,7 +13,7 @@ func TestModelRegistry(t *testing.T) { "claude-3-opus", "claude-3-sonnet", "gemini-pro", "gemini-flash", } - + for _, m := range models { t.Run(m, func(t *testing.T) { assert.NotEmpty(t, m) @@ -23,18 +23,18 @@ func TestModelRegistry(t *testing.T) { func TestProviderModels(t *testing.T) { pm := map[string][]string{ - "openai": {"gpt-4", "gpt-3.5"}, + "openai": {"gpt-4", "gpt-3.5"}, "anthropic": {"claude-3-opus", "claude-3-sonnet"}, - "google": {"gemini-pro", "gemini-flash"}, + "google": {"gemini-pro", "gemini-flash"}, } - + require.Len(t, pm, 3) assert.Greater(t, len(pm["openai"]), 0) } func TestParetoRouting(t *testing.T) { routes := []string{"latency", "cost", "quality"} - + for _, r := range routes { t.Run(r, func(t *testing.T) { assert.NotEmpty(t, r) @@ -46,7 +46,7 @@ func TestTaskClassification(t *testing.T) { tasks := []string{ "code", "chat", "embeddings", "image", "audio", } - + for _, task := range tasks { require.NotEmpty(t, task) } @@ -56,17 +56,17 @@ func TestKiloModels(t *testing.T) { models := []string{ "kilo-code", "kilo-chat", "kilo-embeds", } - + require.GreaterOrEqual(t, len(models), 3) } func TestModelDefinitions(t *testing.T) { defs := map[string]interface{}{ - "name": "gpt-4", + "name": "gpt-4", "context_window": 8192, - "max_tokens": 4096, + "max_tokens": 4096, } - + require.NotNil(t, defs) assert.Equal(t, "gpt-4", defs["name"]) } diff --git a/pkg/llmproxy/translator/antigravity/claude/antigravity_claude_request.go b/pkg/llmproxy/translator/antigravity/claude/antigravity_claude_request.go index a45ec918fe..bcee589929 100644 --- a/pkg/llmproxy/translator/antigravity/claude/antigravity_claude_request.go +++ b/pkg/llmproxy/translator/antigravity/claude/antigravity_claude_request.go @@ -9,8 +9,8 @@ import ( "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/registry" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/cache" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/registry" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/thinking" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/util" "github.com/tidwall/gjson" diff --git a/pkg/llmproxy/translator/antigravity/claude/init.go b/pkg/llmproxy/translator/antigravity/claude/init.go index ca7c184503..1565c35d07 100644 --- a/pkg/llmproxy/translator/antigravity/claude/init.go +++ b/pkg/llmproxy/translator/antigravity/claude/init.go @@ -1,9 +1,9 @@ package claude import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/antigravity/gemini/init.go b/pkg/llmproxy/translator/antigravity/gemini/init.go index 382c4e3e6a..0eb3bc9f81 100644 --- a/pkg/llmproxy/translator/antigravity/gemini/init.go +++ b/pkg/llmproxy/translator/antigravity/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/pkg/llmproxy/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index c1aab2340d..38d6f2cf4b 100644 --- a/pkg/llmproxy/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/pkg/llmproxy/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -6,8 +6,8 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/misc" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" diff --git a/pkg/llmproxy/translator/antigravity/openai/chat-completions/init.go b/pkg/llmproxy/translator/antigravity/openai/chat-completions/init.go index bed6e8a963..c82e544cf1 100644 --- a/pkg/llmproxy/translator/antigravity/openai/chat-completions/init.go +++ b/pkg/llmproxy/translator/antigravity/openai/chat-completions/init.go @@ -1,9 +1,9 @@ package chat_completions import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/antigravity/openai/responses/init.go b/pkg/llmproxy/translator/antigravity/openai/responses/init.go index 6132e33446..bb2a70ba02 100644 --- a/pkg/llmproxy/translator/antigravity/openai/responses/init.go +++ b/pkg/llmproxy/translator/antigravity/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/claude/gemini-cli/init.go b/pkg/llmproxy/translator/claude/gemini-cli/init.go index bbd686ab75..de2907c47b 100644 --- a/pkg/llmproxy/translator/claude/gemini-cli/init.go +++ b/pkg/llmproxy/translator/claude/gemini-cli/init.go @@ -1,9 +1,9 @@ package geminiCLI import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/claude/gemini/init.go b/pkg/llmproxy/translator/claude/gemini/init.go index 28ab8a4452..4299e5c7e2 100644 --- a/pkg/llmproxy/translator/claude/gemini/init.go +++ b/pkg/llmproxy/translator/claude/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/claude/openai/responses/init.go b/pkg/llmproxy/translator/claude/openai/responses/init.go index 92f455fe10..eecd55f428 100644 --- a/pkg/llmproxy/translator/claude/openai/responses/init.go +++ b/pkg/llmproxy/translator/claude/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/codex/claude/init.go b/pkg/llmproxy/translator/codex/claude/init.go index f1e8dd869c..cb6c20d89b 100644 --- a/pkg/llmproxy/translator/codex/claude/init.go +++ b/pkg/llmproxy/translator/codex/claude/init.go @@ -1,9 +1,9 @@ package claude import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/codex/gemini-cli/init.go b/pkg/llmproxy/translator/codex/gemini-cli/init.go index 3aea61e18f..8b369312d1 100644 --- a/pkg/llmproxy/translator/codex/gemini-cli/init.go +++ b/pkg/llmproxy/translator/codex/gemini-cli/init.go @@ -1,9 +1,9 @@ package geminiCLI import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/codex/gemini/init.go b/pkg/llmproxy/translator/codex/gemini/init.go index 095dc20d93..b3ebc3f1cd 100644 --- a/pkg/llmproxy/translator/codex/gemini/init.go +++ b/pkg/llmproxy/translator/codex/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/codex/openai/responses/init.go b/pkg/llmproxy/translator/codex/openai/responses/init.go index 2ed47e848a..eae5f10a36 100644 --- a/pkg/llmproxy/translator/codex/openai/responses/init.go +++ b/pkg/llmproxy/translator/codex/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/gemini-cli/claude/init.go b/pkg/llmproxy/translator/gemini-cli/claude/init.go index 713147c785..d3c067361b 100644 --- a/pkg/llmproxy/translator/gemini-cli/claude/init.go +++ b/pkg/llmproxy/translator/gemini-cli/claude/init.go @@ -1,9 +1,9 @@ package claude import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/gemini-cli/gemini/init.go b/pkg/llmproxy/translator/gemini-cli/gemini/init.go index cfce5ec05e..69b7434bb8 100644 --- a/pkg/llmproxy/translator/gemini-cli/gemini/init.go +++ b/pkg/llmproxy/translator/gemini-cli/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go b/pkg/llmproxy/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go index ac6cba98b4..a4f9e5ef7b 100644 --- a/pkg/llmproxy/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go +++ b/pkg/llmproxy/translator/gemini-cli/openai/chat-completions/gemini-cli_openai_request.go @@ -6,8 +6,8 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/misc" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/gemini/common" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/pkg/llmproxy/translator/gemini-cli/openai/responses/init.go b/pkg/llmproxy/translator/gemini-cli/openai/responses/init.go index 10de90dd8c..57e5f0ecd3 100644 --- a/pkg/llmproxy/translator/gemini-cli/openai/responses/init.go +++ b/pkg/llmproxy/translator/gemini-cli/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/gemini/claude/init.go b/pkg/llmproxy/translator/gemini/claude/init.go index 98969cfd1a..a303229160 100644 --- a/pkg/llmproxy/translator/gemini/claude/init.go +++ b/pkg/llmproxy/translator/gemini/claude/init.go @@ -1,9 +1,9 @@ package claude import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/gemini/gemini-cli/init.go b/pkg/llmproxy/translator/gemini/gemini-cli/init.go index 7953fc4bd6..4255e3d84f 100644 --- a/pkg/llmproxy/translator/gemini/gemini-cli/init.go +++ b/pkg/llmproxy/translator/gemini/gemini-cli/init.go @@ -1,9 +1,9 @@ package geminiCLI import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/gemini/gemini/init.go b/pkg/llmproxy/translator/gemini/gemini/init.go index d4ab316246..8b5810dec2 100644 --- a/pkg/llmproxy/translator/gemini/gemini/init.go +++ b/pkg/llmproxy/translator/gemini/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) // Register a no-op response translator and a request normalizer for constant.Gemini→constant.Gemini. diff --git a/pkg/llmproxy/translator/gemini/openai/chat-completions/gemini_openai_request.go b/pkg/llmproxy/translator/gemini/openai/chat-completions/gemini_openai_request.go index 893303cfcb..3d320cf904 100644 --- a/pkg/llmproxy/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/pkg/llmproxy/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -6,8 +6,8 @@ import ( "fmt" "strings" - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/misc" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/gemini/common" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" diff --git a/pkg/llmproxy/translator/gemini/openai/responses/init.go b/pkg/llmproxy/translator/gemini/openai/responses/init.go index 0bfd525850..fbfab6cf36 100644 --- a/pkg/llmproxy/translator/gemini/openai/responses/init.go +++ b/pkg/llmproxy/translator/gemini/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/kiro/claude/init.go b/pkg/llmproxy/translator/kiro/claude/init.go index d2682c1490..8755ff914e 100644 --- a/pkg/llmproxy/translator/kiro/claude/init.go +++ b/pkg/llmproxy/translator/kiro/claude/init.go @@ -2,9 +2,9 @@ package claude import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/kiro/openai/init.go b/pkg/llmproxy/translator/kiro/openai/init.go index 00ae0b5075..0bd65be2a0 100644 --- a/pkg/llmproxy/translator/kiro/openai/init.go +++ b/pkg/llmproxy/translator/kiro/openai/init.go @@ -2,9 +2,9 @@ package openai import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/openai/claude/init.go b/pkg/llmproxy/translator/openai/claude/init.go index 5312c8162d..a9416a07cd 100644 --- a/pkg/llmproxy/translator/openai/claude/init.go +++ b/pkg/llmproxy/translator/openai/claude/init.go @@ -1,9 +1,9 @@ package claude import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/openai/gemini-cli/init.go b/pkg/llmproxy/translator/openai/gemini-cli/init.go index 02462e54e1..844f05fc0c 100644 --- a/pkg/llmproxy/translator/openai/gemini-cli/init.go +++ b/pkg/llmproxy/translator/openai/gemini-cli/init.go @@ -1,9 +1,9 @@ package geminiCLI import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/openai/gemini/init.go b/pkg/llmproxy/translator/openai/gemini/init.go index 80da2bc492..99516950bb 100644 --- a/pkg/llmproxy/translator/openai/gemini/init.go +++ b/pkg/llmproxy/translator/openai/gemini/init.go @@ -1,9 +1,9 @@ package gemini import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/pkg/llmproxy/translator/openai/openai/responses/init.go b/pkg/llmproxy/translator/openai/openai/responses/init.go index 6d51ead3ac..da3249317a 100644 --- a/pkg/llmproxy/translator/openai/openai/responses/init.go +++ b/pkg/llmproxy/translator/openai/openai/responses/init.go @@ -1,9 +1,9 @@ package responses import ( - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/constant" "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/translator/translator" ) func init() { diff --git a/scripts/cliproxy-service-test.sh b/scripts/cliproxy-service-test.sh new file mode 100755 index 0000000000..5a8fa1de8a --- /dev/null +++ b/scripts/cliproxy-service-test.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_PATH="./scripts/cliproxy-service.sh" +MOCK_DIR="$(mktemp -d)" +trap 'rm -rf "${MOCK_DIR}"' EXIT + +assert_contains() { + local output="$1" + local expected="$2" + if ! printf '%s\n' "${output}" | grep -qF "${expected}"; then + echo "[FAIL] missing expected output: ${expected}" + exit 1 + fi +} + +assert_not_contains() { + local output="$1" + local expected="$2" + if printf '%s\n' "${output}" | grep -qF "${expected}"; then + echo "[FAIL] unexpectedly saw: ${expected}" + exit 1 + fi +} + +run_case() { + local label="$1" + local expected_code="$2" + shift 2 + local output + local status + + echo "## ${label}" + + set +e + output="$("$@" 2>&1)" + status=$? + set -e + + if [ "${status}" -ne "${expected_code}" ]; then + echo "[FAIL] ${label}: expected exit code ${expected_code}, got ${status}" + echo "${output}" + exit 1 + fi + printf '%s\n' "${output}" +} + +make_fake_bin() { + local mock_dir="$1" + local platform="${2:-Linux}" + local unit_present="${3:-1}" + local launchctl_print_fail="${4:-0}" + local powershell_present="${5:-0}" + + mkdir -p "${mock_dir}" + + cat > "${mock_dir}/uname" <<'EOF' +#!/usr/bin/env sh +printf '%s\n' "${CLIPROXY_TEST_PLATFORM}" +EOF + chmod +x "${mock_dir}/uname" + + cat > "${mock_dir}/systemctl" <<'EOF' +#!/usr/bin/env sh +set -eu + +if [ "${1:-}" = "list-unit-files" ]; then + if [ "${CLIPROXY_SYSTEMD_UNIT_PRESENT:-1}" = "1" ]; then + echo "cliproxyapi-plusplus.service enabled" + fi + exit 0 +fi + +echo "[fake-systemctl] $*" +exit 0 +EOF + chmod +x "${mock_dir}/systemctl" + + cat > "${mock_dir}/launchctl" <<'EOF' +#!/usr/bin/env sh +set -eu + +if [ "${1:-}" = "print" ] && [ "${CLIPROXY_LAUNCHCTL_PRINT_FAIL:-0}" -ne 0 ]; then + echo "[fake-launchctl] service not found" >&2 + exit 1 +fi + +echo "[fake-launchctl] $*" +exit 0 +EOF + chmod +x "${mock_dir}/launchctl" + + cat > "${mock_dir}/id" <<'EOF' +#!/usr/bin/env sh +echo "1001" +EOF + chmod +x "${mock_dir}/id" + + cat > "${mock_dir}/sudo" <<'EOF' +#!/usr/bin/env sh +set -eu +if [ "${1:-}" = "cp" ]; then + exit 0 +fi +"$@" +EOF + chmod +x "${mock_dir}/sudo" + + cat > "${mock_dir}/cp" <<'EOF' +#!/usr/bin/env sh +exit 0 +EOF + chmod +x "${mock_dir}/cp" + + cat > "${mock_dir}/mkdir" <<'EOF' +#!/usr/bin/env sh +exit 0 +EOF + chmod +x "${mock_dir}/mkdir" + + if [ "${powershell_present}" -eq 1 ]; then + cat > "${mock_dir}/powershell" <<'EOF' +#!/usr/bin/env sh +echo "[fake-powershell] $*" +exit 0 +EOF + chmod +x "${mock_dir}/powershell" + else + rm -f "${mock_dir}/powershell" + fi + + export CLIPROXY_TEST_PLATFORM="${platform}" + export CLIPROXY_SYSTEMD_UNIT_PRESENT="${unit_present}" + export CLIPROXY_LAUNCHCTL_PRINT_FAIL="${launchctl_print_fail}" +} + +run_case "usage when no args" 1 env PATH="${MOCK_DIR}:$PATH" "${SCRIPT_PATH}" +run_case "usage on invalid action" 1 env PATH="${MOCK_DIR}:$PATH" "${SCRIPT_PATH}" nope + +make_fake_bin "${MOCK_DIR}" "Linux" "1" "0" "0" + +run_linux_status="$(run_case "linux status prints status output" 0 env PATH="${MOCK_DIR}:$PATH" CLIPROXY_TEST_PLATFORM=Linux "${SCRIPT_PATH}" status)" +assert_contains "${run_linux_status}" "[fake-systemctl] --no-pager status cliproxyapi-plusplus" + +run_linux_start_missing="$(run_case "linux start fails when unit missing" 1 env PATH="${MOCK_DIR}:$PATH" CLIPROXY_TEST_PLATFORM=Linux CLIPROXY_SYSTEMD_UNIT_PRESENT=0 "${SCRIPT_PATH}" start)" +assert_contains "${run_linux_start_missing}" "systemd unit missing. Run: task service:install" + +make_fake_bin "${MOCK_DIR}" "Linux" "1" "0" "0" +run_linux_start="$(run_case "linux start with installed unit" 0 env PATH="${MOCK_DIR}:$PATH" CLIPROXY_TEST_PLATFORM=Linux CLIPROXY_SYSTEMD_UNIT_PRESENT=1 "${SCRIPT_PATH}" start)" +assert_contains "${run_linux_start}" "[OK] started cliproxyapi-plusplus" + +make_fake_bin "${MOCK_DIR}" "Darwin" "1" "0" "0" +run_macos_status="$(run_case "mac status prints launchctl output" 0 env PATH="${MOCK_DIR}:$PATH" CLIPROXY_TEST_PLATFORM=Darwin "${SCRIPT_PATH}" status)" +assert_contains "${run_macos_status}" "[fake-launchctl]" + +make_fake_bin "${MOCK_DIR}" "Windows_NT" "1" "0" "0" +run_windows_status="$(run_case "windows status warns when powershell is missing" 0 env PATH="${MOCK_DIR}:$PATH" CLIPROXY_TEST_PLATFORM=Windows_NT "${SCRIPT_PATH}" status)" +assert_contains "${run_windows_status}" "check service status in Windows Service Manager" + +make_fake_bin "${MOCK_DIR}" "Linux" "1" "0" "1" +run_install="$(run_case "linux install command succeeds" 0 env PATH="${MOCK_DIR}:$PATH" CLIPROXY_TEST_PLATFORM=Linux "${SCRIPT_PATH}" install)" +assert_contains "${run_install}" "[OK] systemd service installed and started" + +echo "[OK] cliproxy service helper tests passed" diff --git a/scripts/cliproxy-service.sh b/scripts/cliproxy-service.sh new file mode 100755 index 0000000000..24f1e88155 --- /dev/null +++ b/scripts/cliproxy-service.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +SYSTEMD_UNIT_NAME="cliproxyapi-plusplus" +SYSTEMD_UNIT_PATH="/etc/systemd/system/${SYSTEMD_UNIT_NAME}.service" +SYSTEMD_ENV_PATH="/etc/default/${SYSTEMD_UNIT_NAME}" +LAUNCHD_LABEL="com.router-for-me.cliproxyapi-plusplus" +LAUNCHD_PLIST_SRC="$REPO_ROOT/examples/launchd/${LAUNCHD_LABEL}.plist" +LAUNCHD_PLIST_DST="$HOME/Library/LaunchAgents/${LAUNCHD_LABEL}.plist" +SYSTEMD_SERVICE_SRC="$REPO_ROOT/examples/systemd/${SYSTEMD_UNIT_NAME}.service" +SYSTEMD_ENV_SRC="$REPO_ROOT/examples/systemd/${SYSTEMD_UNIT_NAME}.env" +WINDOWS_SCRIPT_SRC="$REPO_ROOT/examples/windows/cliproxyapi-plusplus-service.ps1" + +usage() { + cat <<'USAGE' +Usage: cliproxy-service.sh + +Examples: + ./scripts/cliproxy-service.sh status + ./scripts/cliproxy-service.sh start + ./scripts/cliproxy-service.sh install +USAGE +} + +need_cmd() { + cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "[FAIL] required command '$cmd' not found" >&2 + exit 1 + fi +} + +os() { + uname_s="$(uname -s 2>/dev/null || true)" + case "$uname_s" in + Darwin*) + echo darwin + ;; + Linux*) + echo linux + ;; + *MINGW*|*MSYS*|*CYGWIN*|Windows_NT*) + echo windows + ;; + *) + echo unknown + ;; + esac +} + +platform="$(os)" + +install_linux() { + need_cmd sudo + need_cmd systemctl + need_cmd cp + + if [ ! -f "$SYSTEMD_SERVICE_SRC" ] || [ ! -f "$SYSTEMD_ENV_SRC" ]; then + echo "[FAIL] missing example service files under examples/systemd" >&2 + exit 1 + fi + + sudo mkdir -p /etc/default /var/log/cliproxyapi + sudo cp "$SYSTEMD_SERVICE_SRC" "$SYSTEMD_UNIT_PATH" + sudo cp "$SYSTEMD_ENV_SRC" "$SYSTEMD_ENV_PATH" + sudo systemctl daemon-reload + sudo systemctl enable --now "$SYSTEMD_UNIT_NAME" + echo "[OK] systemd service installed and started: $SYSTEMD_UNIT_NAME" +} + +install_macos() { + need_cmd launchctl + + if [ ! -f "$LAUNCHD_PLIST_SRC" ]; then + echo "[FAIL] missing launchd example plist at $LAUNCHD_PLIST_SRC" >&2 + exit 1 + fi + + mkdir -p "$HOME/Library/LaunchAgents" + cp "$LAUNCHD_PLIST_SRC" "$LAUNCHD_PLIST_DST" + launchctl bootstrap "gui/$(id -u)" "$LAUNCHD_PLIST_DST" + launchctl kickstart -k "gui/$(id -u)/$LAUNCHD_LABEL" + echo "[OK] launchd service installed and started: $LAUNCHD_LABEL" +} + +install_windows() { + if command -v powershell >/dev/null 2>&1; then + if [ ! -f "$WINDOWS_SCRIPT_SRC" ]; then + echo "[FAIL] missing Windows service script at $WINDOWS_SCRIPT_SRC" >&2 + exit 1 + fi + echo "[INFO] Run in elevated PowerShell (Windows host):" + echo " powershell -ExecutionPolicy Bypass -File \"$WINDOWS_SCRIPT_SRC\" -Action install" + return + fi + echo "[FAIL] PowerShell not available in this environment" >&2 + exit 1 +} + +start_linux() { + need_cmd systemctl + if ! systemctl list-unit-files | grep -q "^${SYSTEMD_UNIT_NAME}\\.service"; then + echo "[FAIL] systemd unit missing. Run: task service:install" + exit 1 + fi + sudo systemctl start "$SYSTEMD_UNIT_NAME" + echo "[OK] started $SYSTEMD_UNIT_NAME" +} + +start_macos() { + need_cmd launchctl + launchctl bootstrap "gui/$(id -u)" "$LAUNCHD_PLIST_DST" >/dev/null 2>&1 || true + launchctl kickstart -k "gui/$(id -u)/$LAUNCHD_LABEL" + echo "[OK] started $LAUNCHD_LABEL" +} + +stop_linux() { + need_cmd systemctl + if ! systemctl list-unit-files | grep -q "^${SYSTEMD_UNIT_NAME}\\.service"; then + echo "[FAIL] systemd unit missing. Run: task service:install" + exit 1 + fi + sudo systemctl stop "$SYSTEMD_UNIT_NAME" + echo "[OK] stopped $SYSTEMD_UNIT_NAME" +} + +stop_macos() { + need_cmd launchctl + launchctl bootout "gui/$(id -u)" "$LAUNCHD_LABEL" >/dev/null 2>&1 || true + echo "[OK] stopped $LAUNCHD_LABEL" +} + +restart_linux() { + need_cmd systemctl + if ! systemctl list-unit-files | grep -q "^${SYSTEMD_UNIT_NAME}\\.service"; then + echo "[FAIL] systemd unit missing. Run: task service:install" + exit 1 + fi + sudo systemctl restart "$SYSTEMD_UNIT_NAME" + echo "[OK] restarted $SYSTEMD_UNIT_NAME" +} + +restart_macos() { + need_cmd launchctl + launchctl kickstart -k "gui/$(id -u)/$LAUNCHD_LABEL" + echo "[OK] restarted $LAUNCHD_LABEL" +} + +status_linux() { + need_cmd systemctl + systemctl --no-pager status "$SYSTEMD_UNIT_NAME" || true +} + +status_macos() { + need_cmd launchctl + launchctl print "gui/$(id -u)/$LAUNCHD_LABEL" 2>/dev/null || { + echo "[WARN] launchd service not loaded: $LAUNCHD_LABEL" + exit 0 + } +} + +status_windows() { + if command -v powershell >/dev/null 2>&1; then + powershell -Command "Get-Service -Name $SYSTEMD_UNIT_NAME -ErrorAction SilentlyContinue | Format-List -Property Name,Status,StartType" + exit 0 + fi + echo "[WARN] check service status in Windows Service Manager or use PowerShell script" +} + +if [ "$#" -ne 1 ]; then + usage + exit 1 +fi + +action="$1" + +case "$action" in + install) + case "$platform" in + linux) install_linux ;; + darwin) install_macos ;; + windows) install_windows ;; + *) echo "[FAIL] unsupported platform: $platform"; exit 1 ;; + esac + ;; + start) + case "$platform" in + linux) start_linux ;; + darwin) start_macos ;; + windows) echo "[INFO] Start with PowerShell: powershell -ExecutionPolicy Bypass -File \"$WINDOWS_SCRIPT_SRC\" -Action start" ;; + *) echo "[FAIL] unsupported platform: $platform"; exit 1 ;; + esac + ;; + stop) + case "$platform" in + linux) stop_linux ;; + darwin) stop_macos ;; + windows) echo "[INFO] Stop with PowerShell: powershell -ExecutionPolicy Bypass -File \"$WINDOWS_SCRIPT_SRC\" -Action stop" ;; + *) echo "[FAIL] unsupported platform: $platform"; exit 1 ;; + esac + ;; + restart) + case "$platform" in + linux) restart_linux ;; + darwin) restart_macos ;; + windows) echo "[INFO] Restart with PowerShell: powershell -ExecutionPolicy Bypass -File \"$WINDOWS_SCRIPT_SRC\" -Action start" ;; + *) echo "[FAIL] unsupported platform: $platform"; exit 1 ;; + esac + ;; + status) + case "$platform" in + linux) status_linux ;; + darwin) status_macos ;; + windows) status_windows ;; + *) echo "[FAIL] unsupported platform: $platform"; exit 1 ;; + esac + ;; + *) + usage + exit 1 + ;; +esac diff --git a/sdk/api/handlers/gemini/gemini_handlers.go b/sdk/api/handlers/gemini/gemini_handlers.go index e51ad19bc5..cc14182a03 100644 --- a/sdk/api/handlers/gemini/gemini_handlers.go +++ b/sdk/api/handlers/gemini/gemini_handlers.go @@ -70,7 +70,7 @@ func (h *GeminiAPIHandler) GeminiModels(c *gin.Context) { if _, ok := normalizedModel["supportedGenerationMethods"]; !ok { normalizedModel["supportedGenerationMethods"] = defaultMethods } - normalizedModels = append(normalizedModels, normalizedModel) + normalizedModels = append(normalizedModels, filterGeminiModelFields(normalizedModel)) } c.JSON(http.StatusOK, gin.H{ "models": normalizedModels, @@ -112,7 +112,7 @@ func (h *GeminiAPIHandler) GeminiGetHandler(c *gin.Context) { if name, ok := targetModel["name"].(string); ok && name != "" && !strings.HasPrefix(name, "models/") { targetModel["name"] = "models/" + name } - c.JSON(http.StatusOK, targetModel) + c.JSON(http.StatusOK, filterGeminiModelFields(targetModel)) return } @@ -124,6 +124,22 @@ func (h *GeminiAPIHandler) GeminiGetHandler(c *gin.Context) { }) } +func filterGeminiModelFields(input map[string]any) map[string]any { + if len(input) == 0 { + return map[string]any{} + } + filtered := make(map[string]any, len(input)) + for k, v := range input { + switch k { + case "id", "object", "created", "owned_by", "type", "context_length", "max_completion_tokens", "thinking": + continue + default: + filtered[k] = v + } + } + return filtered +} + // GeminiHandler handles POST requests for Gemini API operations. // It routes requests to appropriate handlers based on the action parameter (model:method format). func (h *GeminiAPIHandler) GeminiHandler(c *gin.Context) { diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 5d43fc58fa..61219f1cb7 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -14,10 +14,10 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" "github.com/router-for-me/CLIProxyAPI/v6/internal/logging" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/interfaces" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" @@ -46,6 +46,7 @@ type ErrorDetail struct { } const idempotencyKeyMetadataKey = "idempotency_key" +const ginContextLookupKeyToken = "gin" const ( defaultStreamingKeepAliveSeconds = 0 @@ -103,7 +104,13 @@ func BuildErrorResponseBody(status int, errText string) []byte { trimmed := strings.TrimSpace(errText) if trimmed != "" && json.Valid([]byte(trimmed)) { - return []byte(trimmed) + var payload map[string]any + if err := json.Unmarshal([]byte(trimmed), &payload); err == nil { + if _, ok := payload["error"]; ok { + return []byte(trimmed) + } + errText = fmt.Sprintf("upstream returned JSON payload without top-level error field: %s", trimmed) + } } errType := "invalid_request_error" @@ -121,6 +128,10 @@ func BuildErrorResponseBody(status int, errText string) []byte { case http.StatusNotFound: errType = "invalid_request_error" code = "model_not_found" + lower := strings.ToLower(errText) + if strings.Contains(lower, "model") && strings.Contains(lower, "does not exist") { + errText = strings.TrimSpace(errText + " Run GET /v1/models to list available models.") + } default: if status >= http.StatusInternalServerError { errType = "server_error" @@ -190,7 +201,7 @@ func requestExecutionMetadata(ctx context.Context) map[string]any { // It is forwarded as execution metadata; when absent we generate a UUID. key := "" if ctx != nil { - if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { + if ginCtx, ok := ctx.Value(ginContextLookupKeyToken).(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil { key = strings.TrimSpace(ginCtx.GetHeader("Idempotency-Key")) } } @@ -349,7 +360,7 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c * } }() } - newCtx = context.WithValue(newCtx, "gin", c) + newCtx = context.WithValue(newCtx, ginContextLookupKeyToken, c) newCtx = context.WithValue(newCtx, "handler", handler) return newCtx, func(params ...interface{}) { if h.Cfg.RequestLog && len(params) == 1 { @@ -857,7 +868,7 @@ func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.Erro func (h *BaseAPIHandler) LoggingAPIResponseError(ctx context.Context, err *interfaces.ErrorMessage) { if h.Cfg.RequestLog { - if ginContext, ok := ctx.Value("gin").(*gin.Context); ok { + if ginContext, ok := ctx.Value(ginContextLookupKeyToken).(*gin.Context); ok { if apiResponseErrors, isExist := ginContext.Get("API_RESPONSE_ERROR"); isExist { if slicesAPIResponseError, isOk := apiResponseErrors.([]*interfaces.ErrorMessage); isOk { slicesAPIResponseError = append(slicesAPIResponseError, err) diff --git a/sdk/auth/antigravity.go b/sdk/auth/antigravity.go index 6ed31d6d72..b0593c63d3 100644 --- a/sdk/auth/antigravity.go +++ b/sdk/auth/antigravity.go @@ -56,8 +56,12 @@ func (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, o } srv, port, cbChan, errServer := startAntigravityCallbackServer(callbackPort) + if errServer != nil && opts.CallbackPort == 0 && shouldFallbackToEphemeralCallbackPort(errServer) { + log.Warnf("antigravity callback port %d unavailable; retrying with an ephemeral port", callbackPort) + srv, port, cbChan, errServer = startAntigravityCallbackServer(-1) + } if errServer != nil { - return nil, fmt.Errorf("antigravity: failed to start callback server: %w", errServer) + return nil, fmt.Errorf("%s", formatAntigravityCallbackServerError(callbackPort, errServer)) } defer func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -220,10 +224,13 @@ type callbackResult struct { } func startAntigravityCallbackServer(port int) (*http.Server, int, <-chan callbackResult, error) { - if port <= 0 { + if port == 0 { port = antigravity.CallbackPort } - addr := fmt.Sprintf(":%d", port) + addr := ":0" + if port > 0 { + addr = fmt.Sprintf(":%d", port) + } listener, err := net.Listen("tcp", addr) if err != nil { return nil, 0, nil, err @@ -257,6 +264,30 @@ func startAntigravityCallbackServer(port int) (*http.Server, int, <-chan callbac return srv, port, resultCh, nil } +func shouldFallbackToEphemeralCallbackPort(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "address already in use") || + strings.Contains(message, "permission denied") || + strings.Contains(message, "access permissions") +} + +func formatAntigravityCallbackServerError(port int, err error) string { + if err == nil { + return "antigravity: failed to start callback server" + } + lower := strings.ToLower(err.Error()) + cause := "failed to start callback server" + if strings.Contains(lower, "address already in use") { + cause = "callback port is already in use" + } else if strings.Contains(lower, "permission denied") || strings.Contains(lower, "access permissions") { + cause = "callback port appears blocked by OS policy" + } + return fmt.Sprintf("antigravity: %s on port %d: %v (try --oauth-callback-port )", cause, port, err) +} + // FetchAntigravityProjectID exposes project discovery for external callers. func FetchAntigravityProjectID(ctx context.Context, accessToken string, httpClient *http.Client) (string, error) { cfg := &config.Config{} diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index 4715d7f7b1..c6d43ac75e 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -162,14 +162,36 @@ func (s *FileTokenStore) Delete(ctx context.Context, id string) error { } func (s *FileTokenStore) resolveDeletePath(id string) (string, error) { - if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) { - return id, nil - } dir := s.baseDirSnapshot() if dir == "" { return "", fmt.Errorf("auth filestore: directory not configured") } - return filepath.Join(dir, id), nil + cleanID := filepath.Clean(strings.TrimSpace(id)) + if cleanID == "" || cleanID == "." { + return "", fmt.Errorf("auth filestore: id is empty") + } + if filepath.IsAbs(cleanID) { + rel, err := filepath.Rel(dir, cleanID) + if err != nil { + return "", fmt.Errorf("auth filestore: resolve path failed: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("auth filestore: absolute path escapes base directory") + } + return cleanID, nil + } + if cleanID == ".." || strings.HasPrefix(cleanID, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("auth filestore: path traversal is not allowed") + } + path := filepath.Join(dir, cleanID) + rel, err := filepath.Rel(dir, path) + if err != nil { + return "", fmt.Errorf("auth filestore: resolve path failed: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("auth filestore: path traversal is not allowed") + } + return path, nil } func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) { diff --git a/sdk/auth/kilo.go b/sdk/auth/kilo.go index 7e98f7c4b7..ee947fdde1 100644 --- a/sdk/auth/kilo.go +++ b/sdk/auth/kilo.go @@ -39,7 +39,7 @@ func (a *KiloAuthenticator) Login(ctx context.Context, cfg *config.Config, opts } kilocodeAuth := kilo.NewKiloAuth() - + fmt.Println("Initiating Kilo device authentication...") resp, err := kilocodeAuth.InitiateDeviceFlow(ctx) if err != nil { @@ -48,7 +48,7 @@ func (a *KiloAuthenticator) Login(ctx context.Context, cfg *config.Config, opts fmt.Printf("Please visit: %s\n", resp.VerificationURL) fmt.Printf("And enter code: %s\n", resp.Code) - + fmt.Println("Waiting for authorization...") status, err := kilocodeAuth.PollForToken(ctx, resp.Code) if err != nil { @@ -68,7 +68,7 @@ func (a *KiloAuthenticator) Login(ctx context.Context, cfg *config.Config, opts for i, org := range profile.Orgs { fmt.Printf("[%d] %s (%s)\n", i+1, org.Name, org.ID) } - + if opts.Prompt != nil { input, err := opts.Prompt("Enter the number of the organization: ") if err != nil { @@ -108,7 +108,7 @@ func (a *KiloAuthenticator) Login(ctx context.Context, cfg *config.Config, opts metadata := map[string]any{ "email": status.UserEmail, "organization_id": orgID, - "model": defaults.Model, + "model": defaults.Model, } return &coreauth.Auth{ diff --git a/sdk/auth/kiro.go b/sdk/auth/kiro.go index ad165b75a3..71862df0db 100644 --- a/sdk/auth/kiro.go +++ b/sdk/auth/kiro.go @@ -354,6 +354,9 @@ func (a *KiroAuthenticator) Refresh(ctx context.Context, cfg *config.Config, aut clientSecret = loadedClientSecret } } + if authMethod == "idc" && (clientID == "" || clientSecret == "") { + return nil, fmt.Errorf("missing idc client credentials for %s; re-authenticate with --kiro-aws-login", auth.ID) + } var tokenData *kiroauth.KiroTokenData var err error diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index a20f864551..65be447440 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -3,7 +3,9 @@ package auth import ( "bytes" "context" + "crypto/sha256" "encoding/json" + "encoding/hex" "errors" "io" "net/http" @@ -2277,6 +2279,19 @@ func formatOauthIdentity(auth *Auth, provider string, accountInfo string) string return strings.Join(parts, " ") } +func authLogRef(auth *Auth) string { + if auth == nil { + return "provider=unknown auth_id_hash=" + } + provider := strings.TrimSpace(auth.Provider) + if provider == "" { + provider = "unknown" + } + sum := sha256.Sum256([]byte(strings.TrimSpace(auth.ID))) + hash := hex.EncodeToString(sum[:8]) + return "provider=" + provider + " auth_id_hash=" + hash +} + // InjectCredentials delegates per-provider HTTP request preparation when supported. // If the registered executor for the auth provider implements RequestPreparer, // it will be invoked to modify the request (e.g., add headers). diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 2bd12d0ace..119d0179d1 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -864,7 +864,7 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { models = applyExcludedModels(models, excluded) case "kimi": models = registry.GetKimiModels() - models = applyExcludedModels(models, excluded) + models = applyExcludedModels(models, excluded) case "github-copilot": models = registry.GetGitHubCopilotModels() models = applyExcludedModels(models, excluded)