mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(grok): resolve recent integration issues
This commit is contained in:
@@ -649,7 +649,7 @@ Sub2API supports Grok subscription accounts through xAI OAuth and forwards OpenA
|
||||
- Public Claude-compatible target: `/v1/messages`, converted to xAI Responses and returned as Anthropic Messages output for Claude CLI style clients
|
||||
- Public Chat Completions targets: `/v1/chat/completions` and `/chat/completions`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/chat/completions`
|
||||
- Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream
|
||||
- Initial text models: `grok-4.3`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
|
||||
- Text models: `grok-4.5`, `grok-4.3`, `grok-build-0.1`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
|
||||
- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation requests require the group image-generation permission.
|
||||
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
|
||||
- Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping
|
||||
@@ -666,6 +666,7 @@ The Grok OAuth flow uses PKCE and does not require committing private secrets. T
|
||||
| `XAI_OAUTH_AUTHORIZE_URL` | `https://auth.x.ai/oauth2/authorize` |
|
||||
| `XAI_OAUTH_TOKEN_URL` | `https://auth.x.ai/oauth2/token` |
|
||||
| `XAI_BASE_URL` | `https://api.x.ai/v1` |
|
||||
| `XAI_GROK_CLI_VERSION` | `0.2.93`; optional override for the client identity sent to `cli-chat-proxy.grok.com` |
|
||||
|
||||
Administrators can create or reauthorize Grok accounts from the dashboard, or use the admin API:
|
||||
|
||||
@@ -678,6 +679,38 @@ Administrators can create or reauthorize Grok accounts from the dashboard, or us
|
||||
|
||||
Credential storage reuses the existing account JSON fields: `access_token`, `refresh_token`, `token_type`, `expires_at`, optional `email`, optional `subscription_tier`, and `entitlement_status`.
|
||||
|
||||
### Grok Build CLI Configuration
|
||||
|
||||
1. In the Sub2API admin dashboard, add a `grok` OAuth account and complete xAI authorization.
|
||||
2. Create a Grok group, attach the account to it, then create a Sub2API API key assigned to that group.
|
||||
3. In the user API-key page, click **Use Key** and select **Grok CLI**. The modal generates the correct file and base URL for macOS/Linux or Windows. It also provides an OpenCode configuration on the **OpenCode** tab.
|
||||
4. If configuring manually, save the following as `~/.grok/config.toml` (Windows: `%USERPROFILE%\.grok\config.toml`):
|
||||
|
||||
```toml
|
||||
[models]
|
||||
default = "sub2api-grok"
|
||||
web_search = "sub2api-grok"
|
||||
|
||||
[model."sub2api-grok"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://your-sub2api.example.com/v1"
|
||||
name = "Grok 4.5 via Sub2API"
|
||||
description = "Grok 4.5 through a Sub2API Grok group"
|
||||
api_key = "sk-your-sub2api-key"
|
||||
api_backend = "responses"
|
||||
context_window = 1000000
|
||||
supports_backend_search = true
|
||||
```
|
||||
|
||||
Back up an existing `config.toml` before merging the entry. The file contains a Sub2API API key, so keep it private and restrict its permissions where supported. Verify the effective configuration and make a smoke request:
|
||||
|
||||
```bash
|
||||
grok inspect
|
||||
grok -p "Reply with sub2api-ok" -m sub2api-grok
|
||||
```
|
||||
|
||||
The `base_url` above is the public Sub2API URL ending in `/v1`, not `api.x.ai` or the internal xAI OAuth proxy URL.
|
||||
|
||||
### Usage And Quota Display
|
||||
|
||||
xAI quota is passive. Sub2API does not invent subscription quota values; it records whitelisted xAI rate-limit headers from successful or rate-limited upstream responses when xAI sends them. Before the first usable upstream response, the dashboard shows quota as unknown and still displays local Sub2API usage stats.
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -57,8 +59,17 @@ const (
|
||||
defaultOpenAIHTTP2FallbackErrorThreshold = 2
|
||||
defaultOpenAIHTTP2FallbackWindow = 60 * time.Second
|
||||
defaultOpenAIHTTP2FallbackTTL = 10 * time.Minute
|
||||
|
||||
// The Grok CLI proxy rejects requests that do not identify a supported
|
||||
// client version. Keep a known-good stable version in the binary while
|
||||
// allowing operators to bump it without waiting for a Sub2API release.
|
||||
grokCLIProxyHost = "cli-chat-proxy.grok.com"
|
||||
grokCLIStableVersion = "0.2.93"
|
||||
grokCLIVersionOverride = "XAI_GROK_CLI_VERSION"
|
||||
)
|
||||
|
||||
var grokCLIVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$`)
|
||||
|
||||
const (
|
||||
upstreamProtocolModeDefault = "default"
|
||||
upstreamProtocolModeOpenAIH1 = "openai_h1"
|
||||
@@ -161,6 +172,7 @@ func NewHTTPUpstream(cfg *config.Config) service.HTTPUpstream {
|
||||
// - 调用方必须关闭 resp.Body,否则会导致 inFlight 计数泄漏
|
||||
// - inFlight > 0 的客户端不会被淘汰,确保活跃请求不被中断
|
||||
func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
if err := s.validateRequestHost(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -207,6 +219,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
|
||||
if profile == nil {
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
upstreamProfile := service.HTTPUpstreamProfileDefault
|
||||
if req != nil {
|
||||
upstreamProfile = service.HTTPUpstreamProfileFromContext(req.Context())
|
||||
@@ -250,6 +263,26 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// applyGrokCLIProxyHeaders applies the official Grok Build client identity at
|
||||
// the final shared transport boundary. Keying this behavior to the exact CLI
|
||||
// proxy host keeps direct api.x.ai traffic unchanged and automatically covers
|
||||
// Responses, Chat Completions, media, quota probes, and account tests.
|
||||
func applyGrokCLIProxyHeaders(req *http.Request) {
|
||||
if req == nil || req.URL == nil || !strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), grokCLIProxyHost) {
|
||||
return
|
||||
}
|
||||
if req.Header == nil {
|
||||
req.Header = make(http.Header)
|
||||
}
|
||||
version := strings.TrimSpace(os.Getenv(grokCLIVersionOverride))
|
||||
if !grokCLIVersionPattern.MatchString(version) {
|
||||
version = grokCLIStableVersion
|
||||
}
|
||||
req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli")
|
||||
req.Header.Set("x-grok-client-version", version)
|
||||
req.Header.Set("User-Agent", "xai-grok-workspace/"+version)
|
||||
}
|
||||
|
||||
// acquireClientWithTLS 获取或创建带 TLS 指纹的客户端
|
||||
func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile, upstreamProfile service.HTTPUpstreamProfile) (*upstreamClientEntry, error) {
|
||||
return s.getClientEntryWithTLS(proxyURL, accountID, accountConcurrency, profile, upstreamProfile, true, true)
|
||||
|
||||
@@ -15,6 +15,56 @@ import (
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
func TestApplyGrokCLIProxyHeaders(t *testing.T) {
|
||||
t.Run("uses pinned stable version for the CLI proxy", func(t *testing.T) {
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "")
|
||||
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("User-Agent", "sub2api-grok/1.0")
|
||||
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
|
||||
require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
|
||||
})
|
||||
|
||||
t.Run("accepts a valid operator override", func(t *testing.T) {
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95-alpha.1")
|
||||
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
|
||||
require.Equal(t, "0.2.95-alpha.1", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.95-alpha.1", req.Header.Get("User-Agent"))
|
||||
})
|
||||
|
||||
t.Run("rejects an unsafe override", func(t *testing.T) {
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95\r\nX-Injected: true")
|
||||
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
|
||||
require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
|
||||
require.Empty(t, req.Header.Get("X-Injected"))
|
||||
})
|
||||
|
||||
t.Run("leaves direct xAI API requests unchanged", func(t *testing.T) {
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95")
|
||||
req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("User-Agent", "sub2api-grok/1.0")
|
||||
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
|
||||
require.Empty(t, req.Header.Get("x-grok-client-version"))
|
||||
require.Empty(t, req.Header.Get("X-XAI-Token-Auth"))
|
||||
require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent"))
|
||||
})
|
||||
}
|
||||
|
||||
// HTTPUpstreamSuite HTTP 上游服务测试套件
|
||||
// 使用 testify/suite 组织测试,支持 SetupTest 初始化
|
||||
type HTTPUpstreamSuite struct {
|
||||
|
||||
@@ -563,15 +563,19 @@ func (s *BillingService) initFallbackPricing() {
|
||||
s.fallbackPrices["grok-4.3"] = &ModelPricing{
|
||||
InputPricePerToken: 1.25e-6,
|
||||
OutputPricePerToken: 2.5e-6,
|
||||
CacheReadPricePerToken: 0,
|
||||
CacheReadPricePerToken: 0.2e-6,
|
||||
SupportsCacheBreakdown: false,
|
||||
LongContextInputThreshold: 1000000,
|
||||
LongContextInputMultiplier: 1,
|
||||
}
|
||||
// xAI Grok Build 0.1 (official docs: $1 input / $2 output per MTok)
|
||||
// xAI Grok Build 0.1 (official docs: $1 input / $0.20 cached input /
|
||||
// $2 output per MTok). Composer is available only through Grok Build and
|
||||
// has no standalone public API rate card, so its aliases use this coding
|
||||
// model rate instead of silently billing at zero.
|
||||
s.fallbackPrices["grok-build-0.1"] = &ModelPricing{
|
||||
InputPricePerToken: 1e-6,
|
||||
OutputPricePerToken: 2e-6,
|
||||
CacheReadPricePerToken: 0.2e-6,
|
||||
SupportsCacheBreakdown: false,
|
||||
}
|
||||
}
|
||||
@@ -745,9 +749,14 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
|
||||
switch modelLower {
|
||||
case "grok", "grok-latest", "grok-4.5", "grok-4.5-latest", "grok-build-latest":
|
||||
return s.fallbackPrices["grok-4.5"]
|
||||
case "grok-4.3":
|
||||
case "grok-4.3",
|
||||
"grok-4.20-0309-reasoning",
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"grok-4.20-multi-agent-0309",
|
||||
"grok-4.20-reasoning",
|
||||
"grok-4.20-non-reasoning":
|
||||
return s.fallbackPrices["grok-4.3"]
|
||||
case "grok-build", "grok-build-0.1":
|
||||
case "grok-build", "grok-build-0.1", "grok-composer", "grok-composer-2.5-fast", "composer-2.5":
|
||||
return s.fallbackPrices["grok-build-0.1"]
|
||||
}
|
||||
|
||||
|
||||
@@ -1039,6 +1039,58 @@ func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetModelPricing_GrokCatalogFallbacks(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
models []string
|
||||
input float64
|
||||
cacheRead float64
|
||||
output float64
|
||||
}{
|
||||
{
|
||||
name: "Grok 4.3 family",
|
||||
models: []string{
|
||||
"grok-4.3",
|
||||
"grok-4.20-0309-reasoning",
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"grok-4.20-multi-agent-0309",
|
||||
"grok-4.20-reasoning",
|
||||
"grok-4.20-non-reasoning",
|
||||
},
|
||||
input: 1.25e-6,
|
||||
cacheRead: 0.2e-6,
|
||||
output: 2.5e-6,
|
||||
},
|
||||
{
|
||||
name: "Grok coding and Composer family",
|
||||
models: []string{
|
||||
"grok-build",
|
||||
"grok-build-0.1",
|
||||
"grok-composer",
|
||||
"grok-composer-2.5-fast",
|
||||
"composer-2.5",
|
||||
},
|
||||
input: 1e-6,
|
||||
cacheRead: 0.2e-6,
|
||||
output: 2e-6,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, model := range tt.models {
|
||||
pricing, err := svc.GetModelPricing(model)
|
||||
require.NoError(t, err, "model %s", model)
|
||||
require.InDelta(t, tt.input, pricing.InputPricePerToken, 1e-12, "model %s input", model)
|
||||
require.InDelta(t, tt.cacheRead, pricing.CacheReadPricePerToken, 1e-12, "model %s cached input", model)
|
||||
require.InDelta(t, tt.output, pricing.OutputPricePerToken, 1e-12, "model %s output", model)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) {
|
||||
svc := &BillingService{
|
||||
cfg: &config.Config{},
|
||||
|
||||
@@ -176,6 +176,10 @@ func patchGrokResponsesBody(body []byte, upstreamModel string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = sanitizeGrokResponsesInput(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = sanitizeGrokResponsesTools(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -231,6 +235,38 @@ func deleteJSONFields(value any, fields map[string]struct{}) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// additional_tools is a Codex/Responses Lite private input carrier. xAI's
|
||||
// Responses schema accepts ordinary message/function-call input items but
|
||||
// rejects this carrier before inference with a ModelInput deserialization
|
||||
// error. Top-level supported tools remain available through the separate
|
||||
// sanitizeGrokResponsesTools path.
|
||||
func sanitizeGrokResponsesInput(body []byte) ([]byte, error) {
|
||||
if !bytes.Contains(body, []byte(`"additional_tools"`)) {
|
||||
return body, nil
|
||||
}
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if !input.Exists() || !input.IsArray() {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
rawItems := input.Array()
|
||||
filtered := make([]json.RawMessage, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
if strings.TrimSpace(item.Get("type").String()) == "additional_tools" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, json.RawMessage(item.Raw))
|
||||
}
|
||||
if len(filtered) == len(rawItems) {
|
||||
return body, nil
|
||||
}
|
||||
encoded, err := json.Marshal(filtered)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sjson.SetRawBytes(body, "input", encoded)
|
||||
}
|
||||
|
||||
var grokResponsesSupportedToolTypes = map[string]struct{}{
|
||||
"code_execution": {},
|
||||
"code_interpreter": {},
|
||||
|
||||
@@ -162,6 +162,45 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test
|
||||
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
|
||||
}
|
||||
|
||||
func TestPatchGrokResponsesBodyDropsCodexAdditionalToolsInputItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := []byte(`{
|
||||
"model": "grok",
|
||||
"input": [
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"role": "developer",
|
||||
"tools": [
|
||||
{"type": "namespace", "name": "image_gen"},
|
||||
{"type": "function", "name": "wait"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{"type": "input_text", "text": "system prompt"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
patched, err := patchGrokResponsesBody(body, "grok-4.5")
|
||||
require.NoError(t, err)
|
||||
require.True(t, json.Valid(patched))
|
||||
require.Equal(t, "grok-4.5", gjson.GetBytes(patched, "model").String())
|
||||
require.Equal(t, 2, len(gjson.GetBytes(patched, "input").Array()))
|
||||
require.False(t, gjson.GetBytes(patched, `input.#(type=="additional_tools")`).Exists())
|
||||
require.Equal(t, "developer", gjson.GetBytes(patched, "input.0.role").String())
|
||||
require.Equal(t, "system prompt", gjson.GetBytes(patched, "input.0.content.0.text").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(patched, "input.1.role").String())
|
||||
require.Equal(t, "hello", gjson.GetBytes(patched, "input.1.content.0.text").String())
|
||||
}
|
||||
|
||||
func TestBuildGrokResponsesRequestUsesAccountBaseURLAndBearerToken(t *testing.T) {
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
|
||||
|
||||
|
||||
@@ -181,6 +181,8 @@ const defaultClientTab = computed(() => {
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
return 'codex'
|
||||
case 'grok':
|
||||
return 'grok'
|
||||
case 'gemini':
|
||||
return 'gemini'
|
||||
case 'antigravity':
|
||||
@@ -288,6 +290,11 @@ const clientTabs = computed((): TabConfig[] => {
|
||||
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
case 'grok':
|
||||
return [
|
||||
{ id: 'grok', label: t('keys.useKeyModal.cliTabs.grokCli'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
default:
|
||||
return [
|
||||
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||
@@ -313,7 +320,7 @@ const showShellTabs = computed(() => activeClientTab.value !== 'opencode')
|
||||
|
||||
const currentTabs = computed(() => {
|
||||
if (!showShellTabs.value) return []
|
||||
if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws') {
|
||||
if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws' || activeClientTab.value === 'grok') {
|
||||
return openaiTabs
|
||||
}
|
||||
return shellTabs
|
||||
@@ -330,6 +337,8 @@ const platformDescription = computed(() => {
|
||||
return t('keys.useKeyModal.gemini.description')
|
||||
case 'antigravity':
|
||||
return t('keys.useKeyModal.antigravity.description')
|
||||
case 'grok':
|
||||
return t('keys.useKeyModal.grok.description')
|
||||
default:
|
||||
return t('keys.useKeyModal.description')
|
||||
}
|
||||
@@ -350,6 +359,10 @@ const platformNote = computed(() => {
|
||||
return activeClientTab.value === 'claude'
|
||||
? t('keys.useKeyModal.antigravity.claudeNote')
|
||||
: t('keys.useKeyModal.antigravity.geminiNote')
|
||||
case 'grok':
|
||||
return activeTab.value === 'windows'
|
||||
? t('keys.useKeyModal.grok.noteWindows')
|
||||
: t('keys.useKeyModal.grok.note')
|
||||
default:
|
||||
return t('keys.useKeyModal.note')
|
||||
}
|
||||
@@ -407,6 +420,8 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
|
||||
generateOpenCodeConfig('antigravity-gemini', antigravityGeminiBase, apiKey, 'opencode.json (Gemini)')
|
||||
]
|
||||
case 'grok':
|
||||
return [generateOpenCodeConfig('grok', apiBase, apiKey)]
|
||||
default:
|
||||
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
||||
}
|
||||
@@ -428,6 +443,8 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
|
||||
}
|
||||
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
|
||||
case 'grok':
|
||||
return generateGrokFiles(apiBase, apiKey)
|
||||
default:
|
||||
return generateAnthropicFiles(baseUrl, apiKey)
|
||||
}
|
||||
@@ -568,6 +585,30 @@ goals = true`
|
||||
]
|
||||
}
|
||||
|
||||
function generateGrokFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configDir = isWindows ? '%userprofile%\\.grok' : '~/.grok'
|
||||
const configContent = `[models]
|
||||
default = "sub2api-grok"
|
||||
web_search = "sub2api-grok"
|
||||
|
||||
[model."sub2api-grok"]
|
||||
model = "grok-4.5"
|
||||
base_url = "${baseUrl}"
|
||||
name = "Grok 4.5 via Sub2API"
|
||||
description = "Grok 4.5 through a Sub2API Grok group"
|
||||
api_key = "${apiKey}"
|
||||
api_backend = "responses"
|
||||
context_window = 1000000
|
||||
supports_backend_search = true`
|
||||
|
||||
return [{
|
||||
path: `${configDir}/config.toml`,
|
||||
content: configContent,
|
||||
hint: t('keys.useKeyModal.grok.configTomlHint')
|
||||
}]
|
||||
}
|
||||
|
||||
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
||||
@@ -1072,6 +1113,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
}
|
||||
}
|
||||
}
|
||||
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 }
|
||||
},
|
||||
'grok-build-0.1': {
|
||||
name: 'Grok Build 0.1',
|
||||
limit: { context: 256000, output: 128000 }
|
||||
},
|
||||
'grok-composer-2.5-fast': {
|
||||
name: 'Grok Composer 2.5 Fast',
|
||||
limit: { context: 500000, output: 128000 }
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'gemini') {
|
||||
provider[platform].npm = '@ai-sdk/google'
|
||||
@@ -1088,6 +1147,10 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
provider[platform].models = antigravityGeminiModels
|
||||
} else if (platform === 'openai') {
|
||||
provider[platform].models = openaiModels
|
||||
} else if (platform === 'grok') {
|
||||
provider[platform].npm = '@ai-sdk/openai-compatible'
|
||||
provider[platform].name = 'Grok via Sub2API'
|
||||
provider[platform].models = grokModels
|
||||
}
|
||||
|
||||
const agent =
|
||||
|
||||
@@ -17,6 +17,59 @@ vi.mock('@/composables/useClipboard', () => ({
|
||||
import UseKeyModal from '../UseKeyModal.vue'
|
||||
|
||||
describe('UseKeyModal', () => {
|
||||
it('renders Grok Build and OpenCode setup for Grok groups', async () => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apiKey: 'sk-grok-test',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
platform: 'grok'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
Icon: {
|
||||
template: '<span />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const grokTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.grokCli')
|
||||
)
|
||||
expect(grokTab).toBeDefined()
|
||||
|
||||
const grokConfig = wrapper.findAll('pre code')
|
||||
.map((code) => code.text())
|
||||
.find((content) => content.includes('[model."sub2api-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 opencodeTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.opencode')
|
||||
)
|
||||
expect(opencodeTab).toBeDefined()
|
||||
await opencodeTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const parsed = JSON.parse(wrapper.find('pre code').text())
|
||||
expect(parsed.provider.grok.npm).toBe('@ai-sdk/openai-compatible')
|
||||
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-build-0.1']).toBeDefined()
|
||||
expect(parsed.provider.grok.models['grok-composer-2.5-fast']).toBeDefined()
|
||||
expect(parsed.provider.grok.models['gpt-5.6']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders GPT-5.5 and goals feature in OpenAI Codex config', () => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
|
||||
@@ -146,6 +146,7 @@ export default {
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
codexCliWs: 'Codex CLI (WebSocket)',
|
||||
grokCli: 'Grok CLI',
|
||||
opencode: 'OpenCode',
|
||||
},
|
||||
antigravity: {
|
||||
@@ -160,6 +161,12 @@ export default {
|
||||
modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview',
|
||||
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 or OpenCode 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.',
|
||||
note: 'Save the file as ~/.grok/config.toml, then run grok inspect and select sub2api-grok from /model.',
|
||||
noteWindows: 'Save the file as %USERPROFILE%\\.grok\\config.toml, then run grok inspect and select sub2api-grok from /model.',
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode Example',
|
||||
subtitle: 'opencode.json',
|
||||
|
||||
@@ -147,6 +147,7 @@ export default {
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
codexCliWs: 'Codex CLI (WebSocket)',
|
||||
grokCli: 'Grok CLI',
|
||||
opencode: 'OpenCode'
|
||||
},
|
||||
antigravity: {
|
||||
@@ -164,6 +165,12 @@ export default {
|
||||
modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
|
||||
},
|
||||
grok: {
|
||||
description: '配置 Grok Build 或 OpenCode,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
configTomlHint: '如已有 config.toml,请先备份再合并此模型配置。保存后运行 grok inspect 验证生效配置。',
|
||||
note: '保存为 ~/.grok/config.toml,然后运行 grok inspect,并在 /model 中选择 sub2api-grok。',
|
||||
noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 sub2api-grok。'
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode 配置示例',
|
||||
subtitle: 'opencode.json',
|
||||
|
||||
Reference in New Issue
Block a user