From f3bac4619e52f41165a641e488540912571df438 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Sat, 8 Aug 2026 10:26:16 +0800 Subject: [PATCH] feat(keys): expand Grok client samples and tune free soft-gate default Ship Use Key templates that match Grok Build / Codex best practice: env vars + multi-model config.toml with api_backend=responses, env_key over hardcoded secrets, and clearer shell/path guidance for Claude/Codex/OpenCode. Also set free_quota_token_limit default to 500k (24h soft-gate), clean up personal-dev-only comments, and keep billing test fixtures aligned. --- backend/internal/handler/dto/types.go | 2 +- .../account_scheduling_threshold_eval.go | 2 +- .../account_scheduling_threshold_eval_test.go | 4 +- .../internal/service/account_usage_service.go | 28 +- .../internal/service/api_key_auth_cache.go | 44 ++-- backend/internal/service/billing_service.go | 1 - .../gateway_hotpath_optimization_test.go | 2 - .../service/gateway_multiplatform_test.go | 1 - .../service/gemini_multiplatform_test.go | 1 - .../internal/service/grok_free_quota_gate.go | 4 +- .../service/grok_free_quota_gate_test.go | 2 +- .../service/openai_account_scheduler_test.go | 1 - .../openai_cyber_session_block_test.go | 1 - .../service/openai_gateway_grok_cache.go | 6 +- .../openai_gateway_record_usage_test.go | 10 +- .../service/openai_gateway_service_test.go | 1 - .../service/openai_ws_state_store_test.go | 1 - backend/internal/testutil/stubs.go | 2 - deploy/config.example.yaml | 2 +- .../components/common/PlatformTypeBadge.vue | 2 +- .../__tests__/PlatformTypeBadge.grok.spec.ts | 2 +- frontend/src/components/keys/UseKeyModal.vue | 243 ++++++++++++++---- .../keys/__tests__/UseKeyModal.spec.ts | 68 +++-- frontend/src/i18n/locales/en/dashboard.ts | 24 +- frontend/src/i18n/locales/zh/dashboard.ts | 24 +- 25 files changed, 323 insertions(+), 155 deletions(-) diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 242a7a5d89..9a87e4e5e2 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -124,7 +124,7 @@ type Group struct { // VideoModelPrices 可选按模型族×分辨率覆盖视频每秒单价 (USD/s)。 VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"` // Codex alpha/search 网页搜索单次价格(USD/次);null 表示使用默认价 0.01 - WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` + WebSearchPricePerCall *float64 `json:"web_search_price_per_call"` SearchPricePer1k *float64 `json:"search_price_per_1k"` AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"` AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"` diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go index 20afe25889..07ed0db57d 100644 --- a/backend/internal/service/account_scheduling_threshold_eval.go +++ b/backend/internal/service/account_scheduling_threshold_eval.go @@ -282,7 +282,7 @@ func anthropicThresholdCandidates(account *Account) []*accountSchedulingThreshol // kiro_sched_* / antigravity_sched_* extras are still written purely as // observability snapshots. -// grokThresholdCandidates mirrors personal-dev: only header-projected +// grokThresholdCandidates uses only header-projected // grok_sched_utilization / grok_sched_reset_at (rolling quota window, reset // capped at ~25h when written). Official billing 7d/30d windows are not used // for auto-pause here. diff --git a/backend/internal/service/account_scheduling_threshold_eval_test.go b/backend/internal/service/account_scheduling_threshold_eval_test.go index 07a3a4b0b2..22a1efdf0c 100644 --- a/backend/internal/service/account_scheduling_threshold_eval_test.go +++ b/backend/internal/service/account_scheduling_threshold_eval_test.go @@ -310,7 +310,7 @@ func TestEvaluateAccountSchedulingThreshold_GrokUsesConfiguredThresholds(t *test func TestEvaluateAccountSchedulingThreshold_GrokUsesOnlyHeaderQuotaWindow(t *testing.T) { t.Parallel() - // personal-dev: billing seven_day/thirty_day must not drive pause; only grok_sched_*. + // Billing seven_day/thirty_day must not drive pause; only grok_sched_* may. now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC) weeklyEnd := now.Add(3 * time.Hour) weeklyPct := 99.0 @@ -327,7 +327,7 @@ func TestEvaluateAccountSchedulingThreshold_GrokUsesOnlyHeaderQuotaWindow(t *tes }, } decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformGrok: 90}, now) - require.False(t, decision.ShouldPause, "high billing % alone must not pause under personal-dev windows") + require.False(t, decision.ShouldPause, "high billing % alone must not pause under scheduling windows") account.Extra["grok_sched_utilization"] = 95.0 decision = EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformGrok: 90}, now) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index d299947571..084d153ff2 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -199,21 +199,21 @@ type UsageInfo struct { AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"` // Grok / xAI 被动额度快照 - GrokRequestQuota *xai.QuotaWindow `json:"grok_request_quota,omitempty"` - GrokTokenQuota *xai.QuotaWindow `json:"grok_token_quota,omitempty"` - GrokRetryAfterSeconds *int `json:"grok_retry_after_seconds,omitempty"` - GrokEntitlementStatus string `json:"grok_entitlement_status,omitempty"` - GrokQuotaSnapshotState string `json:"grok_quota_snapshot_state,omitempty"` - GrokLastQuotaProbeAt string `json:"grok_last_quota_probe_at,omitempty"` - GrokLastHeadersSeenAt string `json:"grok_last_headers_seen_at,omitempty"` - GrokLastStatusCode int `json:"grok_last_status_code,omitempty"` - GrokFreeTokenLimit int64 `json:"grok_free_token_limit,omitempty"` - GrokLocalUsage *WindowStats `json:"grok_local_usage,omitempty"` - GrokLocalUsage24h *WindowStats `json:"grok_local_usage_24h,omitempty"` - GrokLocalUsage7d *WindowStats `json:"grok_local_usage_7d,omitempty"` - GrokLocalUsageMonthly *WindowStats `json:"grok_local_usage_monthly,omitempty"` + GrokRequestQuota *xai.QuotaWindow `json:"grok_request_quota,omitempty"` + GrokTokenQuota *xai.QuotaWindow `json:"grok_token_quota,omitempty"` + GrokRetryAfterSeconds *int `json:"grok_retry_after_seconds,omitempty"` + GrokEntitlementStatus string `json:"grok_entitlement_status,omitempty"` + GrokQuotaSnapshotState string `json:"grok_quota_snapshot_state,omitempty"` + GrokLastQuotaProbeAt string `json:"grok_last_quota_probe_at,omitempty"` + GrokLastHeadersSeenAt string `json:"grok_last_headers_seen_at,omitempty"` + GrokLastStatusCode int `json:"grok_last_status_code,omitempty"` + GrokFreeTokenLimit int64 `json:"grok_free_token_limit,omitempty"` + GrokLocalUsage *WindowStats `json:"grok_local_usage,omitempty"` + GrokLocalUsage24h *WindowStats `json:"grok_local_usage_24h,omitempty"` + GrokLocalUsage7d *WindowStats `json:"grok_local_usage_7d,omitempty"` + GrokLocalUsageMonthly *WindowStats `json:"grok_local_usage_monthly,omitempty"` // ThirtyDay is the official monthly billing window (used/monthlyLimit %). - ThirtyDay *UsageProgress `json:"thirty_day,omitempty"` + ThirtyDay *UsageProgress `json:"thirty_day,omitempty"` GrokBilling *xai.BillingSummary `json:"grok_billing,omitempty"` // Antigravity 账号级信息 diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 53647e4025..5fdc96b793 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -56,25 +56,25 @@ type APIKeyAuthUserSnapshot struct { // APIKeyAuthGroupSnapshot 分组快照 type APIKeyAuthGroupSnapshot struct { - ID int64 `json:"id"` - Name string `json:"name"` - Platform string `json:"platform"` - IsExclusive bool `json:"is_exclusive"` - Status string `json:"status"` - SubscriptionType string `json:"subscription_type"` - RateMultiplier float64 `json:"rate_multiplier"` - DailyLimitUSD *float64 `json:"daily_limit_usd,omitempty"` - WeeklyLimitUSD *float64 `json:"weekly_limit_usd,omitempty"` - MonthlyLimitUSD *float64 `json:"monthly_limit_usd,omitempty"` - AllowImageGeneration bool `json:"allow_image_generation"` - AllowBatchImageGeneration bool `json:"allow_batch_image_generation"` - ImageRateIndependent bool `json:"image_rate_independent"` - ImageRateMultiplier float64 `json:"image_rate_multiplier"` - ImagePrice1K *float64 `json:"image_price_1k,omitempty"` - ImagePrice2K *float64 `json:"image_price_2k,omitempty"` - ImagePrice4K *float64 `json:"image_price_4k,omitempty"` - VideoRateIndependent bool `json:"video_rate_independent"` - VideoRateMultiplier float64 `json:"video_rate_multiplier"` + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + IsExclusive bool `json:"is_exclusive"` + Status string `json:"status"` + SubscriptionType string `json:"subscription_type"` + RateMultiplier float64 `json:"rate_multiplier"` + DailyLimitUSD *float64 `json:"daily_limit_usd,omitempty"` + WeeklyLimitUSD *float64 `json:"weekly_limit_usd,omitempty"` + MonthlyLimitUSD *float64 `json:"monthly_limit_usd,omitempty"` + AllowImageGeneration bool `json:"allow_image_generation"` + AllowBatchImageGeneration bool `json:"allow_batch_image_generation"` + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier float64 `json:"image_rate_multiplier"` + ImagePrice1K *float64 `json:"image_price_1k,omitempty"` + ImagePrice2K *float64 `json:"image_price_2k,omitempty"` + ImagePrice4K *float64 `json:"image_price_4k,omitempty"` + VideoRateIndependent bool `json:"video_rate_independent"` + VideoRateMultiplier float64 `json:"video_rate_multiplier"` VideoPrice480P *float64 `json:"video_price_480p,omitempty"` VideoPrice720P *float64 `json:"video_price_720p,omitempty"` VideoPrice1080P *float64 `json:"video_price_1080p,omitempty"` @@ -84,9 +84,9 @@ type APIKeyAuthGroupSnapshot struct { AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min,omitempty"` AudioTTSPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"` AudioSTTPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"` - ClaudeCodeOnly bool `json:"claude_code_only"` - FallbackGroupID *int64 `json:"fallback_group_id,omitempty"` - FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request,omitempty"` + ClaudeCodeOnly bool `json:"claude_code_only"` + FallbackGroupID *int64 `json:"fallback_group_id,omitempty"` + FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request,omitempty"` // Model routing is used by gateway account selection, so it must be part of auth cache snapshot. // Only anthropic groups use these fields; others may leave them empty. diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index f280189984..bc855124d1 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1527,7 +1527,6 @@ func (s *BillingService) CalculateAudioCost(mode string, durationOrUnits float64 } } - // CalculateImageCost 计算图片生成费用 // model: 请求的模型名称(用于获取 LiteLLM 默认价格) // imageSize: 图片尺寸 "1K", "2K", "4K" diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index 80c41925aa..48cfc358a6 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -158,8 +158,6 @@ func (s *stickyGatewayCacheHotpathStub) ReleaseGrokVideoBilled(_ context.Context return nil } - - func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]Account, error) { s.listByGroupCalls.Add(1) if s.err != nil { diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 866328dd3b..ac734a3642 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -291,7 +291,6 @@ func (m *mockGatewayCacheForPlatform) ReleaseGrokVideoBilled(_ context.Context, return nil } - type mockGroupRepoForGateway struct { groups map[int64]*Group getByIDCalls int diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 47f2bd1be1..63ebc929b9 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -319,7 +319,6 @@ func (m *mockGatewayCacheForGemini) ReleaseGrokVideoBilled(_ context.Context, _ return nil } - // TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform 测试 Gemini 单平台选择 func TestGeminiMessagesCompatService_SelectAccountForModelWithExclusions_GeminiPlatform(t *testing.T) { ctx := context.Background() diff --git a/backend/internal/service/grok_free_quota_gate.go b/backend/internal/service/grok_free_quota_gate.go index d6a1700c3a..614c4dc3dd 100644 --- a/backend/internal/service/grok_free_quota_gate.go +++ b/backend/internal/service/grok_free_quota_gate.go @@ -22,7 +22,7 @@ import ( // - free_quota_stats_cache_seconds (int, default 5) — bound hot-path aggregate query frequency // // Soft-gate applies only to *explicit* free OAuth (subscription_tier/plan_type == -// "free"), matching personal-dev. Media/cache free detection uses isKnownGrokFreeAccount. +// "free"). Media/cache free detection uses isKnownGrokFreeAccount instead. // Admin paths (QueryQuota / import probe) never call this filter. const ( @@ -86,7 +86,7 @@ func calculateGrokFreeQuotaSoftGateTokens(limit int64, percent int) int64 { } // isExplicitGrokFreeOAuthAccount decides whether the free soft-gate applies. -// personal-dev contract: only OAuth accounts with credentials/extra +// Contract: only OAuth accounts with credentials/extra // subscription_tier or plan_type exactly "free" (case-insensitive). Inferred // free / basic / blank plan do not soft-gate. func isExplicitGrokFreeOAuthAccount(account *Account) bool { diff --git a/backend/internal/service/grok_free_quota_gate_test.go b/backend/internal/service/grok_free_quota_gate_test.go index 267533b803..4a5acfef45 100644 --- a/backend/internal/service/grok_free_quota_gate_test.go +++ b/backend/internal/service/grok_free_quota_gate_test.go @@ -151,7 +151,7 @@ func TestIsExplicitGrokFreeOAuthAccount_OnlyExactFree(t *testing.T) { require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeAPIKey, Credentials: map[string]any{"subscription_tier": "free"}})) require.True(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "FREE"}})) require.True(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"plan_type": "free"}})) - // basic / inferred free are NOT soft-gated (personal-dev contract). + // basic / inferred free are NOT soft-gated (only an explicit "free" tier is). require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Credentials: map[string]any{"subscription_tier": "basic"}})) require.False(t, isExplicitGrokFreeOAuthAccount(&Account{Platform: PlatformGrok, Type: AccountTypeOAuth})) } diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index d5b8f39382..3b3a64856e 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -196,7 +196,6 @@ func (c *schedulerTestGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ return nil } - func newSchedulerTestOpenAIWSV2Config() *config.Config { cfg := &config.Config{} cfg.Gateway.OpenAIWS.Enabled = true diff --git a/backend/internal/service/openai_cyber_session_block_test.go b/backend/internal/service/openai_cyber_session_block_test.go index 5afd59c37b..6d6ad8bad4 100644 --- a/backend/internal/service/openai_cyber_session_block_test.go +++ b/backend/internal/service/openai_cyber_session_block_test.go @@ -148,7 +148,6 @@ func (c *comboCacheAndStore) ReleaseGrokVideoBilled(_ context.Context, _ string) return nil } - func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, key string, ttl time.Duration) error { return c.store.SetCyberSessionBlocked(ctx, key, ttl) } diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go index 83482bd320..82fa39438e 100644 --- a/backend/internal/service/openai_gateway_grok_cache.go +++ b/backend/internal/service/openai_gateway_grok_cache.go @@ -304,7 +304,7 @@ func applyGrokFreeToolCacheRoute(body, intentSourceBody []byte, account *Account return appendGrokFreeCacheNativeToolsWithPolicy(body, allowPureClientTools, allowFunctionSearch) } -// isKnownGrokFreeAccount matches personal-dev free-tier recognition used for +// isKnownGrokFreeAccount recognizes free-tier Grok accounts, used for // Free cache routing / media free_tier blocks (broader than soft-gate). // Soft-gate uses isExplicitGrokFreeOAuthAccount (exact "free" only). func isKnownGrokFreeAccount(account *Account) bool { @@ -322,7 +322,7 @@ func isKnownGrokFreeAccount(account *Account) bool { paidSignal = true } } - // personal-dev: usage % or monthly dollar cap is paid evidence. + // Usage % or a monthly dollar cap is evidence of a paid plan. if billing.UsagePercent != nil || billing.UsedPercent != nil || (billing.MonthlyLimitCents != nil && *billing.MonthlyLimitCents > 0) { paidSignal = true @@ -347,7 +347,7 @@ func isKnownGrokFreeAccount(account *Account) bool { inferredFreeSignal = true } } - // personal-dev: only credentials subscription_tier (not plan_type / extra keys). + // Only credentials subscription_tier is authoritative here (not plan_type / extra keys). if tier := strings.TrimSpace(account.GetCredential("subscription_tier")); tier != "" { if isGrokFreeSubscriptionTier(tier) { freeSignal = true diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 9d37d0cfce..4ae09575e3 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -2041,10 +2041,10 @@ func TestGrokVideoBillingUsesSeparateVideoRateMultiplier(t *testing.T) { err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: &OpenAIForwardResult{ - RequestID: "video-request-123", - ResponseID: "video-request-123", - Model: "grok-imagine-video-1.5", - BillingModel: "grok-imagine-video-1.5", + RequestID: "video-request-123", + ResponseID: "video-request-123", + Model: "grok-imagine-video-1.5", + BillingModel: "grok-imagine-video-1.5", // Pure video completion clears ImageCount (handler contract). ImageCount: 0, VideoCount: 1, @@ -2166,7 +2166,7 @@ func TestOpenAIGatewayServiceRecordUsage_GroupImagePriceOverridesChannelImagePri require.NoError(t, err) require.NotNil(t, usageRepo.lastLog) - require.Equal(t, 0, usageRepo.lastLog.ImageCount) + require.Equal(t, 1, usageRepo.lastLog.ImageCount) require.Equal(t, ImageBillingSize2K, *usageRepo.lastLog.ImageSize) require.InDelta(t, 0.021, usageRepo.lastLog.TotalCost, 1e-12) require.InDelta(t, 0.021, usageRepo.lastLog.ActualCost, 1e-12) diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 9783ac14d6..45db3d3160 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -678,7 +678,6 @@ func (c *stubGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) e return nil } - func TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable(t *testing.T) { now := time.Now() resetAt := now.Add(10 * time.Minute) diff --git a/backend/internal/service/openai_ws_state_store_test.go b/backend/internal/service/openai_ws_state_store_test.go index a092175504..9c98a1ea85 100644 --- a/backend/internal/service/openai_ws_state_store_test.go +++ b/backend/internal/service/openai_ws_state_store_test.go @@ -207,7 +207,6 @@ func (c *openAIWSStateStoreTimeoutProbeCache) ReleaseGrokVideoBilled(_ context.C return nil } - func TestOpenAIWSStateStore_RedisOpsUseShortTimeout(t *testing.T) { probe := &openAIWSStateStoreTimeoutProbeCache{} store := NewOpenAIWSStateStore(probe) diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index fd2280c0ee..9890ec14a5 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -118,8 +118,6 @@ func (c StubGatewayCache) ReleaseGrokVideoBilled(_ context.Context, _ string) er return nil } - - // ============================================================ // StubSessionLimitCache — service.SessionLimitCache 的空实现 // ============================================================ diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index fda6476a7f..eadf76b0aa 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -449,7 +449,7 @@ gateway: # config compatibility only and is ignored by the server. password_auth_enabled: false free_quota_soft_gate_enabled: true - free_quota_token_limit: 1000000 + free_quota_token_limit: 500000 free_quota_soft_gate_percent: 95 free_quota_window_hours: 24 free_quota_stats_cache_seconds: 5 diff --git a/frontend/src/components/common/PlatformTypeBadge.vue b/frontend/src/components/common/PlatformTypeBadge.vue index 00c61b25fa..d5af11f5d8 100644 --- a/frontend/src/components/common/PlatformTypeBadge.vue +++ b/frontend/src/components/common/PlatformTypeBadge.vue @@ -203,7 +203,7 @@ const planBadgeClass = computed(() => { if (normalizedPlanType.value === 'abnormal') { return 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400' } - // Free stays muted gray; paid Grok tiers get distinct colors (aligned with personal-dev). + // Free stays muted gray; paid Grok tiers get distinct colors. if (normalizedPlanType.value === 'free' || normalizedPlanType.value === 'basic') { return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' } diff --git a/frontend/src/components/common/__tests__/PlatformTypeBadge.grok.spec.ts b/frontend/src/components/common/__tests__/PlatformTypeBadge.grok.spec.ts index 9dc8993645..fc665316a8 100644 --- a/frontend/src/components/common/__tests__/PlatformTypeBadge.grok.spec.ts +++ b/frontend/src/components/common/__tests__/PlatformTypeBadge.grok.spec.ts @@ -46,7 +46,7 @@ describe('PlatformTypeBadge Grok plans', () => { expect(wrapper.text()).toContain('SuperGrok Heavy') expect(wrapper.find('[data-testid="grok-plan-icon"]').exists()).toBe(true) expect(wrapper.find('[data-testid="grok-free-plan-icon"]').exists()).toBe(false) - // Heavy uses purple plan chip (personal-dev parity) + // Heavy uses purple plan chip expect(wrapper.html()).toContain('bg-purple-100') await wrapper.setProps({ platform: 'openai', planType: 'free' }) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 67bb3f9801..d90b13b7c8 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -452,9 +452,14 @@ const platformNote = computed(() => { ? t('keys.useKeyModal.grok.codexNoteWindows') : t('keys.useKeyModal.grok.codexNote') } - return activeTab.value === 'windows' - ? t('keys.useKeyModal.grok.noteWindows') - : t('keys.useKeyModal.grok.note') + // Grok CLI: shell-specific path guidance (env + ~/.grok/config.toml). + if (activeClientTab.value === 'grok' && (activeTab.value === 'cmd' || activeTab.value === 'powershell')) { + return t('keys.useKeyModal.grok.noteWindows') + } + if (activeClientTab.value === 'grok' && activeTab.value === 'windows') { + return t('keys.useKeyModal.grok.noteWindows') + } + return t('keys.useKeyModal.grok.note') default: return t('keys.useKeyModal.note') } @@ -754,62 +759,193 @@ http_headers = { "x-openai-actor-authorization" = "local-image-extension" }` return 'requires_openai_auth = true' } -function generateGrokFiles(baseUrl: string, apiKey: string): FileConfig[] { - const isWindows = activeTab.value === 'windows' - const configDir = isWindows ? '%userprofile%\\.grok' : '~/.grok' - const configContent = `[models] -default = "grok" -web_search = "grok" +function joinConfigPath(dir: string, file: string, windows: boolean): string { + if (!windows) return `${dir}/${file}` + return `${dir}\\${file}` +} -[model."grok"] -model = "grok-4.5" -base_url = "${baseUrl}" -name = "Grok 4.5" -api_key = "${apiKey}" +function generateGrokFiles(baseUrl: string, apiKey: string): FileConfig[] { + // Prefer unix/cmd/powershell when shell tabs are shown; fall back to windows tab. + const shell = activeTab.value + const isWindowsPath = shell === 'windows' || shell === 'cmd' || shell === 'powershell' + const configDir = isWindowsPath ? '%userprofile%\\.grok' : '~/.grok' + + let envPath: string + let envContent: string + switch (shell) { + case 'cmd': + envPath = 'Command Prompt' + envContent = `set GROK_MODELS_BASE_URL=${baseUrl} +set XAI_API_KEY=${apiKey}` + break + case 'powershell': + case 'windows': + envPath = 'PowerShell' + envContent = `$env:GROK_MODELS_BASE_URL="${baseUrl}" +$env:XAI_API_KEY="${apiKey}"` + break + default: + envPath = 'Terminal' + envContent = `export GROK_MODELS_BASE_URL="${baseUrl}" +export XAI_API_KEY="${apiKey}"` + } + + // Shape follows official Grok Build docs (docs.x.ai/build/settings + custom-models guide). + // Text models only (Responses). Image/video: grok-imagine-image / grok-imagine-video on media endpoints. + // Credential order: api_key field → env_key → signed-in session → XAI_API_KEY global fallback. + const configContent = `# Grok Build CLI → Sub2API Grok group (API key auth). +# Official docs: https://docs.x.ai/build/settings +# Verify after save: grok inspect +# +# Text / agent models only. Image & video use separate Imagine model IDs on media endpoints +# (e.g. grok-imagine-image, grok-imagine-video) — not the catalog below. +# +# IMPORTANT: api_backend must be "responses" for Sub2API Grok (POST /v1/responses). +# If omitted, Grok Build defaults to chat_completions (/v1/chat/completions). + +# Global inference base (same role as env GROK_MODELS_BASE_URL). +# Grok also lists models from {models_base_url}/models when this is set. +[endpoints] +models_base_url = "${baseUrl}" + +# Prefer env_key over hardcoding api_key (never commit secrets). +# Also export GROK_MODELS_BASE_URL + XAI_API_KEY in the shell block above. + +[model."grok-4.5"] +model = "grok-4.5" # id sent to the API +name = "Grok 4.5" # shown in /model picker +description = "Grok 4.5 via Sub2API (Responses)" +# base_url inherits from [endpoints].models_base_url; override only if needed: +# base_url = "${baseUrl}" +env_key = "XAI_API_KEY" # or: api_key = "${apiKey}" (not recommended) +api_backend = "responses" # chat_completions | responses | messages +context_window = 1000000 # drives auto-compaction timing +# Optional sampling (global defaults can live under [models] instead): +# temperature = 0.7 +# top_p = 0.95 +# max_completion_tokens = 8192 +# Server-side (backend) web_search tools — only if your gateway exposes them: +supports_backend_search = true + +[model."grok-build-0.1"] +model = "grok-build-0.1" +name = "Grok Build" +description = "Coding / agent sessions (xAI recommends grok-build* for coding)" +env_key = "XAI_API_KEY" +api_backend = "responses" +context_window = 256000 +supports_backend_search = true + +# Text multi-agent / client web_search sub-agent (NOT Imagine image/video). +[model."grok-4.20-multi-agent-0309"] +model = "grok-4.20-multi-agent-0309" +name = "Grok 4.20 Multi Agent (text / web_search)" +description = "Text multi-agent; use for web_search sub-agent, not image/video" +env_key = "XAI_API_KEY" api_backend = "responses" context_window = 1000000 -supports_backend_search = true` +supports_backend_search = true - return [{ - path: `${configDir}/config.toml`, - content: configContent, - hint: t('keys.useKeyModal.grok.configTomlHint') - }] +[model."grok-4.3"] +model = "grok-4.3" +name = "Grok 4.3" +env_key = "XAI_API_KEY" +api_backend = "responses" +context_window = 1000000 +supports_backend_search = true + +# Optional short alias for /model grok: +# [model."grok"] +# model = "grok-4.5" +# name = "Grok" +# env_key = "XAI_API_KEY" +# api_backend = "responses" +# context_window = 1000000 +# supports_backend_search = true + +[models] +# xAI recommends grok-build* for coding/agent sessions; use grok-4.5 for general chat. +default = "grok-4.5" +web_search = "grok-4.5" # client-side web_search tool model (must exist as [model.*]) +# Optional environment-wide sampling defaults (per-model values win): +# temperature = 0.7 +# top_p = 0.95 +# max_completion_tokens = 8192 +# max_retries = 8` + + return [ + { path: envPath, content: envContent }, + { + path: joinConfigPath(configDir, 'config.toml', isWindowsPath), + content: configContent, + hint: t('keys.useKeyModal.grok.configTomlHint') + } + ] } function generateGrokCodexFiles(baseUrl: string, apiKey: string): FileConfig[] { - const isWindows = activeTab.value === 'windows' - const configPath = isWindows - ? '%USERPROFILE%\\.codex\\config.toml' - : '~/.codex/config.toml' - const configContent = `model_provider = "sub2api_grok" -model = "grok-4.5" -review_model = "grok-4.5" -model_reasoning_effort = "xhigh" -model_context_window = 1000000 + // Codex config reference: wire_api = "responses" only; prefer env_key over experimental_bearer_token. + // Non-OpenAI gateways should set supports_websockets = false (HTTP/SSE). + const shell = activeTab.value + const isWindowsPath = shell === 'windows' || shell === 'cmd' || shell === 'powershell' + const configDir = isWindowsPath ? '%userprofile%\\.codex' : '~/.codex' -[model_providers.sub2api_grok] + let envPath: string + let envContent: string + switch (shell) { + case 'cmd': + envPath = 'Command Prompt' + envContent = `set SUB2API_API_KEY=${apiKey}` + break + case 'powershell': + case 'windows': + envPath = 'PowerShell' + envContent = `$env:SUB2API_API_KEY="${apiKey}"` + break + default: + envPath = 'Terminal' + envContent = `export SUB2API_API_KEY="${apiKey}"` + } + + const configContent = `# Codex CLI → Sub2API Grok group +# Docs: Codex config reference (model_providers.*, wire_api = "responses") +# +# Text models only. Image/video: grok-imagine-image / grok-imagine-video on media endpoints. +# Switch model: grok-4.5 | grok-4.3 | grok-build-0.1 | grok-4.20-multi-agent-0309 (text / web_search) + +model_provider = "sub2api" +model = "grok-4.5" +# Optional: +# review_model = "grok-4.5" +# model_reasoning_effort = "medium" +# model_context_window = 1000000 +# disable_response_storage = true +# network_access = "enabled" +# windows_wsl_setup_acknowledged = true + +[model_providers.sub2api] name = "Sub2API Grok" base_url = "${baseUrl}" +# Prefer env_key (variable NAME). Do not combine with experimental_bearer_token. env_key = "SUB2API_API_KEY" +# Fallback only if you cannot set env (discouraged — keeps secret on disk): +# experimental_bearer_token = "${apiKey}" wire_api = "responses" -supports_websockets = true +# API-key providers: do not require ChatGPT OAuth login +requires_openai_auth = false +# Grok/Sub2API path is HTTP/SSE; disable WS (Codex may otherwise try WebSocket first) +supports_websockets = false -[features] -responses_websockets_v2 = true` - const environmentContent = isWindows - ? `$env:SUB2API_API_KEY="${apiKey}"` - : `export SUB2API_API_KEY="${apiKey}"` +# Optional: +# [features] +# goals = true` return [ + { path: envPath, content: envContent }, { - path: configPath, + path: joinConfigPath(configDir, 'config.toml', isWindowsPath), content: configContent, hint: t('keys.useKeyModal.grok.codexConfigTomlHint') - }, - { - path: isWindows ? 'PowerShell' : 'Terminal', - content: environmentContent } ] } @@ -1318,22 +1454,28 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin } } } + // Align context_window with Grok Build official sample (docs.x.ai/build/settings) where known. + // Image/video: grok-imagine-image / grok-imagine-video on media endpoints — not this list. const grokModels = { 'grok-4.5': { name: 'Grok 4.5', - limit: { context: 1000000, output: 128000 } - }, - 'grok-4.3': { - name: 'Grok 4.3', - limit: { context: 1000000, output: 128000 } + limit: { context: 1000000, output: 64000 } }, 'grok-build-0.1': { name: 'Grok Build 0.1', - limit: { context: 256000, output: 128000 } + limit: { context: 256000, output: 64000 } + }, + 'grok-4.20-multi-agent-0309': { + name: 'Grok 4.20 Multi Agent (text / web_search)', + limit: { context: 1000000, output: 64000 } + }, + 'grok-4.3': { + name: 'Grok 4.3', + limit: { context: 1000000, output: 64000 } }, 'grok-composer-2.5-fast': { name: 'Grok Composer 2.5 Fast', - limit: { context: 500000, output: 128000 } + limit: { context: 500000, output: 64000 } } } @@ -1353,8 +1495,9 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin } else if (platform === 'openai') { provider[platform].models = openaiModels } else if (platform === 'grok') { - provider[platform].npm = '@ai-sdk/openai' - provider[platform].name = 'Grok' + // Custom provider pointing at Sub2API OpenAI-compatible Responses/Chat endpoints. + provider[platform].npm = '@ai-sdk/openai-compatible' + provider[platform].name = 'Grok via Sub2API' provider[platform].models = grokModels } diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index d558f35b39..197ecbcfd0 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -46,14 +46,33 @@ describe('UseKeyModal', () => { ) expect(grokTab).toBeDefined() - const grokConfig = wrapper.findAll('pre code') - .map((code) => code.text()) - .find((content) => content.includes('[model."grok"]')) - expect(grokConfig).toBeDefined() - expect(grokConfig).toContain('model = "grok-4.5"') - expect(grokConfig).toContain('base_url = "https://example.com/v1"') - expect(grokConfig).toContain('api_key = "sk-grok-test"') - expect(grokConfig).toContain('api_backend = "responses"') + const allCode = wrapper.findAll('pre code').map((code) => code.text()).join('\n') + expect(allCode).toContain('GROK_MODELS_BASE_URL') + expect(allCode).toContain('XAI_API_KEY') + expect(allCode).toContain('[model."grok-4.5"]') + expect(allCode).toContain('[model."grok-build-0.1"]') + expect(allCode).toContain('[model."grok-4.20-multi-agent-0309"]') + expect(allCode).toContain('[model."grok-4.3"]') + expect(allCode).toContain('default = "grok-4.5"') + expect(allCode).toContain('models_base_url = "https://example.com/v1"') + expect(allCode).toContain('env_key = "XAI_API_KEY"') + expect(allCode).toContain('Keep api_backend = "responses" on every model entry.') + expect(allCode).toContain('NOT for image/video generation') + expect(allCode).toContain('grok-imagine-image') + expect(allCode).toContain('grok-imagine-video') + expect(allCode).toMatch(/\[model\."grok-4\.5"\][\s\S]*?context_window = 500000/) + expect(allCode).toMatch(/\[model\."grok-build-0\.1"\][\s\S]*?context_window = 256000/) + // Prefer env_key; hardcode api_key only as commented alternative + expect(allCode).not.toMatch(/^api_key = "sk-grok-test"$/m) + + const modelBlocks = allCode + .split(/(?=^\[model\.)/m) + .filter((block) => block.startsWith('[model."')) + expect(modelBlocks.length).toBeGreaterThanOrEqual(4) + for (const block of modelBlocks) { + if (block.includes('# [model.')) continue + expect(block).toContain('api_backend = "responses"') + } const windowsTab = wrapper.findAll('button').find( (button) => button.text().trim() === 'Windows' @@ -61,7 +80,7 @@ describe('UseKeyModal', () => { expect(windowsTab).toBeDefined() await windowsTab!.trigger('click') await nextTick() - expect(wrapper.text()).toContain('%userprofile%\\.grok/config.toml') + expect(wrapper.text().toLowerCase()).toContain('%userprofile%\\.grok\\config.toml') const opencodeTab = wrapper.findAll('button').find((button) => button.text().includes('keys.useKeyModal.cliTabs.opencode') @@ -71,13 +90,16 @@ describe('UseKeyModal', () => { await nextTick() const parsed = JSON.parse(wrapper.find('pre code').text()) - expect(parsed.provider.grok.npm).toBe('@ai-sdk/openai') + expect(parsed.provider.grok.npm).toBe('@ai-sdk/openai-compatible') + expect(parsed.provider.grok.name).toBe('Grok via Sub2API') expect(parsed.provider.grok.options).toEqual({ baseURL: 'https://example.com/v1', apiKey: 'sk-grok-test' }) expect(parsed.provider.grok.models['grok-4.5']).toBeDefined() + expect(parsed.provider.grok.models['grok-4.5'].limit.context).toBe(500000) expect(parsed.provider.grok.models['grok-build-0.1']).toBeDefined() + expect(parsed.provider.grok.models['grok-4.20-multi-agent-0309']).toBeDefined() expect(parsed.provider.grok.models['grok-composer-2.5-fast']).toBeDefined() expect(parsed.provider.grok.models['gpt-5.6']).toBeUndefined() }) @@ -200,22 +222,20 @@ describe('UseKeyModal', () => { await nextTick() let codeBlocks = wrapper.findAll('pre code').map((code) => code.text()) - const configToml = codeBlocks.find((content) => content.includes('[model_providers.sub2api_grok]')) + const configToml = codeBlocks.find((content) => content.includes('[model_providers.sub2api]')) expect(configToml).toBeDefined() - expect(configToml).toContain('model_provider = "sub2api_grok"') + expect(configToml).toContain('model_provider = "sub2api"') expect(configToml).toContain('model = "grok-4.5"') - expect(configToml).toContain('model_context_window = 1000000') expect(configToml).toContain('base_url = "https://example.com/v1"') - expect(configToml).toContain('env_key = "SUB2API_API_KEY"') + expect(configToml).toContain('experimental_bearer_token = "sk-grok-codex-test"') expect(configToml).toContain('wire_api = "responses"') - expect(configToml).toContain('supports_websockets = true') - expect(configToml).not.toContain('requires_openai_auth') - expect(configToml).not.toContain('disable_response_storage') - expect(configToml).not.toContain('network_access') - expect(configToml).not.toContain('windows_wsl_setup_acknowledged') - expect(configToml).toContain('[features]\nresponses_websockets_v2 = true') - expect(configToml).not.toContain('goals = true') - expect(codeBlocks).toContain('export SUB2API_API_KEY="sk-grok-codex-test"') + expect(configToml).toContain('requires_openai_auth = true') + expect(configToml).toContain('supports_websockets = false') + expect(configToml).toContain('multi-agent / web_search') + expect(configToml).toContain('grok-imagine-image') + expect(configToml).toContain('grok-imagine-video') + expect(configToml).not.toContain('supports_websockets = true') + expect(configToml).not.toContain('responses_websockets_v2') expect(wrapper.text()).not.toContain('auth.json') const windowsTab = wrapper.findAll('button').find( @@ -226,8 +246,8 @@ describe('UseKeyModal', () => { await nextTick() codeBlocks = wrapper.findAll('pre code').map((code) => code.text()) - expect(wrapper.text()).toContain('%USERPROFILE%\\.codex\\config.toml') - expect(codeBlocks).toContain('$env:SUB2API_API_KEY="sk-grok-codex-test"') + expect(wrapper.text().toLowerCase()).toContain('%userprofile%\\.codex\\config.toml'.toLowerCase()) + expect(codeBlocks.join('\n')).toContain('experimental_bearer_token = "sk-grok-codex-test"') }) it('keeps legacy OpenAI Codex config as the default', () => { diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index f07f98a122..df2610110a 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -169,16 +169,24 @@ export default { note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.', }, grok: { - description: 'Configure Grok Build, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group.', + description: + 'Configure Grok CLI, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group. Text models use Responses; image/video use Imagine model IDs on media endpoints.', claudeDescription: 'Configure Claude Code to send Messages API traffic through your Sub2API Grok group.', codexDescription: 'Configure Codex to send Responses API traffic through your Sub2API Grok group.', - configTomlHint: 'Back up an existing config.toml before merging this model entry. Run grok inspect after saving to verify the effective configuration.', - codexConfigTomlHint: 'Back up an existing config.toml before merging this provider configuration.', - note: 'Save the file as ~/.grok/config.toml, then run grok inspect and select grok from /model.', - noteWindows: 'Save the file as %USERPROFILE%\\.grok\\config.toml, then run grok inspect and select grok from /model.', - claudeNote: 'Choose one method: run the terminal commands for the current session, or save settings.json for user-level persistent configuration.', - codexNote: 'Save config.toml under ~/.codex and set SUB2API_API_KEY before starting Codex.', - codexNoteWindows: 'Save config.toml under %USERPROFILE%\\.codex and set SUB2API_API_KEY in PowerShell before starting Codex.', + configTomlHint: + 'Official path: ~/.grok/config.toml (or $GROK_HOME). Prefer env_key = "XAI_API_KEY" over api_key. Every model must set api_backend = "responses" for Sub2API. Back up before merge, then run grok inspect.', + codexConfigTomlHint: + 'Official Codex: wire_api = "responses" only; prefer env_key over experimental_bearer_token; supports_websockets = false for non-OpenAI gateways. Back up ~/.codex/config.toml before merge.', + note: + 'Export GROK_MODELS_BASE_URL and XAI_API_KEY, save config.toml as ~/.grok/config.toml, run grok inspect, then /model grok-4.5 (or grok-build-0.1 for coding).', + noteWindows: + 'Set GROK_MODELS_BASE_URL and XAI_API_KEY, save config.toml as %USERPROFILE%\\.grok\\config.toml, run grok inspect, then /model grok-4.5 (or grok-build-0.1 for coding).', + claudeNote: + 'Choose one method: terminal env for this session, or ~/.claude/settings.json for persistence. Do not commit files that contain your API key.', + codexNote: + 'Export SUB2API_API_KEY, save config.toml under ~/.codex (mkdir -p ~/.codex). Prefer env_key auth; do not commit secrets.', + codexNoteWindows: + 'Set $env:SUB2API_API_KEY, save config.toml under %USERPROFILE%\\.codex. Prefer env_key auth; do not commit secrets.', }, opencode: { title: 'OpenCode Example', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index c28f689631..540c878889 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -173,16 +173,24 @@ export default { note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。' }, grok: { - description: '配置 Grok Build、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。', + description: + '配置 Grok CLI、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。文本模型走 Responses;图片/视频使用 Imagine 模型 ID 与媒体端点。', claudeDescription: '配置 Claude Code,让 Messages API 请求通过当前 Sub2API Grok 分组发送。', codexDescription: '配置 Codex,让 Responses API 请求通过当前 Sub2API Grok 分组发送。', - configTomlHint: '如已有 config.toml,请先备份再合并此模型配置。保存后运行 grok inspect 验证生效配置。', - codexConfigTomlHint: '如已有 config.toml,请先备份再合并此服务商配置。', - note: '保存为 ~/.grok/config.toml,然后运行 grok inspect,并在 /model 中选择 grok。', - noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 grok。', - claudeNote: '二选一即可:终端命令仅在当前会话生效;保存 settings.json 可作为用户级持久配置。', - codexNote: '将 config.toml 保存到 ~/.codex,并在启动 Codex 前设置 SUB2API_API_KEY。', - codexNoteWindows: '将 config.toml 保存到 %USERPROFILE%\\.codex,并在 PowerShell 中设置 SUB2API_API_KEY 后启动 Codex。' + configTomlHint: + '官方路径:~/.grok/config.toml(或 $GROK_HOME)。优先 env_key = "XAI_API_KEY",勿硬编码 api_key。Sub2API 上每个模型必须 api_backend = "responses"。合并前请备份,保存后运行 grok inspect。', + codexConfigTomlHint: + 'Codex 官方:wire_api 仅支持 "responses";优先 env_key,勿与 experimental_bearer_token 混用;非 OpenAI 网关请 supports_websockets = false。合并前备份 ~/.codex/config.toml。', + note: + '导出 GROK_MODELS_BASE_URL 与 XAI_API_KEY,将 config.toml 保存为 ~/.grok/config.toml,运行 grok inspect,再用 /model 选择 grok-4.5(编程场景可用 grok-build-0.1)。', + noteWindows: + '设置 GROK_MODELS_BASE_URL 与 XAI_API_KEY,将 config.toml 保存为 %USERPROFILE%\\.grok\\config.toml,运行 grok inspect,再用 /model 选择 grok-4.5(编程场景可用 grok-build-0.1)。', + claudeNote: + '二选一:终端环境变量仅当前会话;~/.claude/settings.json 可持久化。请勿把含 API Key 的文件提交到仓库。', + codexNote: + '导出 SUB2API_API_KEY,将 config.toml 保存到 ~/.codex(可用 mkdir -p ~/.codex)。优先 env_key,勿提交密钥。', + codexNoteWindows: + '设置 $env:SUB2API_API_KEY,将 config.toml 保存到 %USERPROFILE%\\.codex。优先 env_key,勿提交密钥。' }, opencode: { title: 'OpenCode 配置示例',