Merge pull request #4393 from superman2003/fix/grok-codex-compatibility

fix(grok): improve Codex compatibility and key setup
This commit is contained in:
Wesley Liddick
2026-07-16 09:33:20 +08:00
committed by GitHub
10 changed files with 613 additions and 19 deletions
@@ -85,7 +85,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
requestCtx := c.Request.Context()
if service.IsImageGenerationIntent("/v1/responses", reqModel, body) {
if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(apiKey)) {
requestCtx = service.WithOpenAIImageGenerationIntent(requestCtx)
}
@@ -276,7 +276,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
}
imageIntent := service.IsImageGenerationIntent("/v1/responses", reqModel, body)
imageIntent := service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(apiKey))
if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) {
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
return
@@ -1469,7 +1469,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return
}
if service.IsImageGenerationIntent("/v1/responses", reqModel, firstMessage) && !service.GroupAllowsImageGeneration(apiKey.Group) {
if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, firstMessage, openAICompatibleRequestPlatform(apiKey)) && !service.GroupAllowsImageGeneration(apiKey.Group) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage())
return
}
@@ -0,0 +1,101 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestOpenAIGatewayHandlerResponses_GrokPassiveImageToolDeclarationBypassesPermissionGate(t *testing.T) {
body := `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}`
rec := runOpenAIResponsesImagePermissionGateTest(t, service.PlatformGrok, body)
require.NotEqual(t, http.StatusForbidden, rec.Code)
require.NotContains(t, rec.Body.String(), service.ImageGenerationPermissionMessage())
}
func TestOpenAIGatewayHandlerResponses_GrokResponsesLiteImageToolDeclarationBypassesPermissionGate(t *testing.T) {
body := `{"model":"grok-4.5","tool_choice":"auto","input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}]},{"type":"message","role":"user","content":"write code"}]}`
rec := runOpenAIResponsesImagePermissionGateTest(t, service.PlatformGrok, body)
require.NotEqual(t, http.StatusForbidden, rec.Code)
require.NotContains(t, rec.Body.String(), service.ImageGenerationPermissionMessage())
}
func TestOpenAIGatewayHandlerResponses_ImagePermissionHardSignalsStillRejected(t *testing.T) {
passiveNamespace := `{"model":"gpt-5.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}`
tests := []struct {
name string
platform string
body string
}{
{
name: "OpenAI keeps declaration semantics",
platform: service.PlatformOpenAI,
body: passiveNamespace,
},
{
name: "Grok native image_generation declaration",
platform: service.PlatformGrok,
body: `{"model":"grok-4.5","tools":[{"type":"image_generation"}],"input":"draw"}`,
},
{
name: "Grok explicit image_gen tool choice",
platform: service.PlatformGrok,
body: `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen"}],"tool_choice":{"type":"namespace","name":"image_gen"},"input":"draw"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := runOpenAIResponsesImagePermissionGateTest(t, tt.platform, tt.body)
require.Equal(t, http.StatusForbidden, rec.Code)
require.Contains(t, rec.Body.String(), service.ImageGenerationPermissionMessage())
})
}
}
func runOpenAIResponsesImagePermissionGateTest(t *testing.T, platform string, body string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
groupID := int64(6301)
userID := int64(6302)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
ID: 6303,
GroupID: &groupID,
Group: &service.Group{
ID: groupID,
Platform: platform,
AllowImageGeneration: false,
},
User: &service.User{ID: userID, Status: service.StatusActive},
})
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID, Concurrency: 1})
h := &OpenAIGatewayHandler{
gatewayService: &service.OpenAIGatewayService{},
billingCacheService: service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil),
apiKeyService: &service.APIKeyService{},
concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService(
&helperConcurrencyCacheStub{userSeq: []bool{true}},
)},
cfg: &config.Config{},
imageLimiter: &imageConcurrencyLimiter{},
}
h.Responses(c)
return rec
}
@@ -79,6 +79,56 @@ func IsImageGenerationIntent(endpoint string, requestedModel string, body []byte
return imageIntent
}
// IsImageGenerationIntentForPlatform applies platform-specific intent rules.
//
// Codex advertises the image_gen namespace on ordinary Responses requests so
// that it is available if the model needs it. Grok strips namespace and
// Responses Lite additional_tools declarations before forwarding, so those
// declarations alone must not turn every Codex request into an image request.
// Native image_generation tools, explicit image selection and image models
// remain image intent. Other platforms retain the original declaration rule.
func IsImageGenerationIntentForPlatform(endpoint string, requestedModel string, body []byte, platform string) bool {
if !strings.EqualFold(strings.TrimSpace(platform), PlatformGrok) {
return IsImageGenerationIntent(endpoint, requestedModel, body)
}
return isExplicitGrokImageGenerationIntent(endpoint, requestedModel, body)
}
func isExplicitGrokImageGenerationIntent(endpoint string, requestedModel string, body []byte) bool {
if IsImageGenerationEndpoint(endpoint) || isOpenAIImageGenerationModel(requestedModel) {
return true
}
if len(body) == 0 || !gjson.ValidBytes(body) {
return false
}
var modelSeen, toolsSeen, toolChoiceSeen bool
imageIntent := false
parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool {
switch key.Str {
case "model":
if !modelSeen {
modelSeen = true
imageIntent = isOpenAIImageGenerationModel(strings.TrimSpace(value.String()))
}
case "tools":
if !toolsSeen {
toolsSeen = true
// Grok removes namespace catalogs before forwarding. Native
// image_generation remains an explicit capability request.
imageIntent = openAIJSONToolsContainNativeImageGeneration(value)
}
case "tool_choice":
if !toolChoiceSeen {
toolChoiceSeen = true
imageIntent = openAIJSONToolChoiceSelectsExplicitImageGeneration(value)
}
}
return !imageIntent && (!modelSeen || !toolsSeen || !toolChoiceSeen)
})
return imageIntent
}
// IsImageGenerationIntentMap is the map-backed variant used after service-side request mutation.
func IsImageGenerationIntentMap(endpoint string, requestedModel string, reqBody map[string]any) bool {
if IsImageGenerationEndpoint(endpoint) {
@@ -140,6 +190,18 @@ func openAIJSONToolsContainImageGeneration(tools gjson.Result) bool {
return found
}
func openAIJSONToolsContainNativeImageGeneration(tools gjson.Result) bool {
if !tools.IsArray() {
return false
}
found := false
tools.ForEach(func(_, item gjson.Result) bool {
found = isOpenAIImageGenerationType(openAIJSONString(item.Get("type")))
return !found
})
return found
}
func isOpenAIImageGenerationType(value string) bool {
return strings.TrimSpace(value) == "image_generation"
}
@@ -148,9 +210,8 @@ func isOpenAIImageGenNamespaceName(value string) bool {
return strings.TrimSpace(value) == "image_gen"
}
// isImageGenNamespaceTool detects the Codex namespace-style image generation
// tool declaration: { "type": "namespace", "name": "image_gen", ... }.
// Codex /image uses this instead of the flat { "type": "image_generation" }.
// isImageGenNamespaceTool detects the namespace advertised by Codex's built-in
// image-generation extension instead of a hosted image_generation tool.
func isImageGenNamespaceTool(tool gjson.Result) bool {
return openAIJSONString(tool.Get("type")) == "namespace" &&
isOpenAIImageGenNamespaceName(openAIJSONString(tool.Get("name")))
@@ -235,6 +296,45 @@ func openAIJSONToolChoiceSelectsImageGeneration(choice gjson.Result) bool {
return false
}
func openAIJSONToolChoiceSelectsExplicitImageGeneration(choice gjson.Result) bool {
if openAIJSONToolChoiceSelectsImageGeneration(choice) {
return true
}
if !choice.IsObject() {
return false
}
if tool := choice.Get("tool"); tool.IsObject() && openAIJSONToolChoiceSelectsExplicitImageGeneration(tool) {
return true
}
if isOpenAIImageGenFunctionReference(
openAIJSONString(choice.Get("namespace")),
openAIJSONString(choice.Get("name")),
) {
return true
}
if fn := choice.Get("function"); fn.IsObject() {
return isOpenAIImageGenFunctionReference(
openAIJSONString(fn.Get("namespace")),
openAIJSONString(fn.Get("name")),
)
}
return false
}
func isOpenAIImageGenFunctionReference(namespace string, name string) bool {
namespace = strings.TrimSpace(namespace)
name = strings.TrimSpace(name)
if namespace == "image_gen" && name == "imagegen" {
return true
}
switch name {
case "image_gen.imagegen", "image_gen__imagegen":
return true
default:
return false
}
}
func openAIAnyToolChoiceSelectsImageGeneration(choice any) bool {
switch v := choice.(type) {
case string:
@@ -0,0 +1,108 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestIsImageGenerationIntentForPlatform_GrokCodexDeclarations(t *testing.T) {
tests := []struct {
name string
body string
want bool
}{
{
name: "top-level image_gen namespace is passive",
body: `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"tool_choice":"auto","input":"write code"}`,
},
{
name: "responses lite additional_tools image_gen is passive",
body: `{"model":"grok-4.5","tool_choice":"auto","input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}]},{"type":"message","role":"user","content":"write code"}]}`,
},
{
name: "legacy adapter flattened image_gen function declaration is passive",
body: `{"model":"grok-4.5","tools":[{"type":"function","name":"image_gen.imagegen"}],"input":"write code"}`,
},
{
name: "native image generation declaration remains explicit",
body: `{"model":"grok-4.5","tools":[{"type":"image_generation"}],"input":"draw a cat"}`,
want: true,
},
{
name: "native image generation tool choice is explicit",
body: `{"model":"grok-4.5","tools":[{"type":"image_generation"}],"tool_choice":{"type":"image_generation"},"input":"draw a cat"}`,
want: true,
},
{
name: "image_gen namespace tool choice is explicit",
body: `{"model":"grok-4.5","tools":[{"type":"namespace","name":"image_gen"}],"tool_choice":{"type":"namespace","name":"image_gen"},"input":"draw a cat"}`,
want: true,
},
{
name: "flattened image_gen function tool choice is explicit",
body: `{"model":"grok-4.5","tools":[{"type":"function","name":"image_gen.imagegen"}],"tool_choice":{"type":"function","name":"image_gen.imagegen"},"input":"draw a cat"}`,
want: true,
},
{
name: "wrapped image_gen function tool choice is explicit",
body: `{"model":"grok-4.5","tools":[{"type":"function","name":"image_gen.imagegen"}],"tool_choice":{"tool":{"type":"function","name":"image_gen.imagegen"}},"input":"draw a cat"}`,
want: true,
},
{
name: "vision input is not image generation",
body: `{"model":"grok-4.5","input":[{"type":"message","role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}]}`,
},
{
name: "ordinary function declaration is not image generation",
body: `{"model":"grok-4.5","tools":[{"type":"function","name":"lookup"}],"tool_choice":"auto","input":"write code"}`,
},
{
name: "image_gen function call history is not current intent",
body: `{"model":"grok-4.5","input":[{"type":"function_call","namespace":"image_gen","name":"imagegen","arguments":"{}"}]}`,
},
{
name: "image generation call history is not current intent",
body: `{"model":"grok-4.5","input":[{"type":"image_generation_call","id":"ig_1"}]}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, IsImageGenerationIntentForPlatform(
openAIResponsesEndpoint,
"grok-4.5",
[]byte(tt.body),
PlatformGrok,
))
})
}
}
func TestIsImageGenerationIntentForPlatform_GrokPreservesHardSignals(t *testing.T) {
require.True(t, IsImageGenerationIntentForPlatform(
"/v1/images/generations",
"grok-4.5",
[]byte(`{"input":"draw"}`),
PlatformGrok,
))
require.True(t, IsImageGenerationIntentForPlatform(
openAIResponsesEndpoint,
"gpt-image-2",
[]byte(`{"input":"draw"}`),
PlatformGrok,
))
require.True(t, IsImageGenerationIntentForPlatform(
openAIResponsesEndpoint,
"grok-4.5",
[]byte(`{"model":"gpt-image-2","input":"draw"}`),
PlatformGrok,
))
}
func TestIsImageGenerationIntentForPlatform_OtherPlatformsKeepDeclarationSemantics(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen"}]}],"input":"write code"}`)
require.True(t, IsImageGenerationIntentForPlatform(openAIResponsesEndpoint, "gpt-5.5", body, PlatformOpenAI))
require.True(t, IsImageGenerationIntentForPlatform(openAIResponsesEndpoint, "gpt-5.5", body, PlatformAnthropic))
}
@@ -305,7 +305,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
normalized = stripped
logOpenAIWSModeInfo("ingress_ws_codex_spark_image_tool_stripped account_id=%d", account.ID)
}
imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, originalModel, normalized)
imageIntent := IsImageGenerationIntentForPlatform(openAIResponsesEndpoint, originalModel, normalized, account.Platform)
if imageIntent && !imageGenerationAllowed {
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, ImageGenerationPermissionMessage(), nil)
}
+128 -8
View File
@@ -29,11 +29,12 @@
</p>
<!-- Client Tabs -->
<div v-if="clientTabs.length" class="border-b border-gray-200 dark:border-dark-700">
<nav class="-mb-px flex space-x-6" aria-label="Client">
<div v-if="clientTabs.length" class="overflow-x-auto border-b border-gray-200 dark:border-dark-700">
<nav class="-mb-px flex min-w-max gap-4 sm:gap-6" aria-label="Client">
<button
v-for="tab in clientTabs"
:key="tab.id"
type="button"
@click="activeClientTab = tab.id"
:class="[
'whitespace-nowrap py-2.5 px-1 border-b-2 font-medium text-sm transition-colors',
@@ -102,11 +103,12 @@
</div>
<!-- OS/Shell Tabs -->
<div v-if="showShellTabs" class="border-b border-gray-200 dark:border-dark-700">
<nav class="-mb-px flex space-x-4" aria-label="Tabs">
<div v-if="showShellTabs" class="overflow-x-auto border-b border-gray-200 dark:border-dark-700">
<nav class="-mb-px flex min-w-max gap-4" aria-label="Tabs">
<button
v-for="tab in currentTabs"
:key="tab.id"
type="button"
@click="activeTab = tab.id"
:class="[
'whitespace-nowrap py-2.5 px-1 border-b-2 font-medium text-sm transition-colors',
@@ -138,10 +140,11 @@
<div class="bg-gray-900 dark:bg-dark-900 rounded-xl overflow-hidden">
<!-- Code Header -->
<div class="flex items-center justify-between px-4 py-2 bg-gray-800 dark:bg-dark-800 border-b border-gray-700 dark:border-dark-700">
<span class="text-xs text-gray-400 font-mono">{{ file.path }}</span>
<span class="min-w-0 truncate text-xs text-gray-400 font-mono">{{ file.path }}</span>
<button
type="button"
@click="copyContent(file.content, index)"
class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors"
class="flex flex-shrink-0 items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors"
:class="copiedIndex === index
? 'bg-green-500/20 text-green-400'
: 'bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white'"
@@ -353,6 +356,8 @@ const clientTabs = computed((): TabConfig[] => {
case 'grok':
return [
{ id: 'grok', label: t('keys.useKeyModal.cliTabs.grokCli'), icon: TerminalIcon },
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
default:
@@ -403,6 +408,12 @@ const platformDescription = computed(() => {
case 'antigravity':
return t('keys.useKeyModal.antigravity.description')
case 'grok':
if (activeClientTab.value === 'claude') {
return t('keys.useKeyModal.grok.claudeDescription')
}
if (activeClientTab.value === 'codex') {
return t('keys.useKeyModal.grok.codexDescription')
}
return t('keys.useKeyModal.grok.description')
default:
return t('keys.useKeyModal.description')
@@ -425,6 +436,14 @@ const platformNote = computed(() => {
? t('keys.useKeyModal.antigravity.claudeNote')
: t('keys.useKeyModal.antigravity.geminiNote')
case 'grok':
if (activeClientTab.value === 'claude') {
return t('keys.useKeyModal.grok.claudeNote')
}
if (activeClientTab.value === 'codex') {
return activeTab.value === 'windows'
? t('keys.useKeyModal.grok.codexNoteWindows')
: t('keys.useKeyModal.grok.codexNote')
}
return activeTab.value === 'windows'
? t('keys.useKeyModal.grok.noteWindows')
: t('keys.useKeyModal.grok.note')
@@ -509,6 +528,12 @@ const currentFiles = computed((): FileConfig[] => {
}
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
case 'grok':
if (activeClientTab.value === 'claude') {
return generateGrokClaudeFiles(baseRoot, apiKey)
}
if (activeClientTab.value === 'codex') {
return generateGrokCodexFiles(apiBase, apiKey)
}
return generateGrokFiles(apiBase, apiKey)
default:
return generateAnthropicFiles(baseUrl, apiKey)
@@ -548,9 +573,10 @@ $env:CLAUDE_CODE_ATTRIBUTION_HEADER=0`
const vscodeSettingsPath = activeTab.value === 'unix'
? '~/.claude/settings.json'
: '%userprofile%\\.claude\\settings.json'
: '%USERPROFILE%\\.claude\\settings.json'
const vscodeContent = `{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"ANTHROPIC_BASE_URL": "${baseUrl}",
"ANTHROPIC_AUTH_TOKEN": "${apiKey}",
@@ -561,7 +587,68 @@ $env:CLAUDE_CODE_ATTRIBUTION_HEADER=0`
return [
{ path, content },
{ path: vscodeSettingsPath, content: vscodeContent, hint: 'VSCode Claude Code' }
{
path: vscodeSettingsPath,
content: vscodeContent,
hint: t('keys.useKeyModal.claudeSettingsHint')
}
]
}
function generateGrokClaudeFiles(baseUrl: string, apiKey: string): FileConfig[] {
const environment = {
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_MODEL: 'grok-4.5',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'grok-4.5',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'grok-4.5',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'grok-4.5',
ANTHROPIC_DEFAULT_FABLE_MODEL: 'grok-4.5',
CLAUDE_CODE_SUBAGENT_MODEL: 'grok-4.5',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
CLAUDE_CODE_ATTRIBUTION_HEADER: '0'
}
let path: string
let content: string
switch (activeTab.value) {
case 'unix':
path = 'Terminal'
content = Object.entries(environment)
.map(([name, value]) => `export ${name}="${value}"`)
.join('\n')
break
case 'cmd':
path = 'Command Prompt'
content = Object.entries(environment)
.map(([name, value]) => `set ${name}=${value}`)
.join('\n')
break
case 'powershell':
path = 'PowerShell'
content = Object.entries(environment)
.map(([name, value]) => `$env:${name}="${value}"`)
.join('\n')
break
default:
path = 'Terminal'
content = ''
}
const settingsPath = activeTab.value === 'unix'
? '~/.claude/settings.json'
: '%USERPROFILE%\\.claude\\settings.json'
return [
{ path, content },
{
path: settingsPath,
content: JSON.stringify({
$schema: 'https://json.schemastore.org/claude-code-settings.json',
env: environment
}, null, 2),
hint: t('keys.useKeyModal.claudeSettingsHint')
}
]
}
@@ -682,6 +769,39 @@ supports_backend_search = true`
}]
}
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
[model_providers.sub2api_grok]
name = "Sub2API Grok"
base_url = "${baseUrl}"
env_key = "SUB2API_API_KEY"
wire_api = "responses"`
const environmentContent = isWindows
? `$env:SUB2API_API_KEY="${apiKey}"`
: `export SUB2API_API_KEY="${apiKey}"`
return [
{
path: configPath,
content: configContent,
hint: t('keys.useKeyModal.grok.codexConfigTomlHint')
},
{
path: isWindows ? 'PowerShell' : 'Terminal',
content: environmentContent
}
]
}
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
@@ -2,6 +2,10 @@ import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
const { copyToClipboardMock } = vi.hoisted(() => ({
copyToClipboardMock: vi.fn().mockResolvedValue(true)
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
@@ -10,7 +14,7 @@ vi.mock('vue-i18n', () => ({
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard: vi.fn().mockResolvedValue(true)
copyToClipboard: copyToClipboardMock
})
}))
@@ -78,6 +82,153 @@ describe('UseKeyModal', () => {
expect(parsed.provider.grok.models['gpt-5.6']).toBeUndefined()
})
it('renders copyable Claude Code setup through the Grok Messages gateway', async () => {
copyToClipboardMock.mockClear()
const wrapper = mount(UseKeyModal, {
props: {
show: true,
apiKey: 'sk-grok-claude-test',
baseUrl: 'https://example.com/v1',
platform: 'grok'
},
global: {
stubs: {
BaseDialog: {
template: '<div><slot /><slot name="footer" /></div>'
},
Icon: {
template: '<span />'
}
}
}
})
const claudeTab = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.cliTabs.claudeCode')
)
expect(claudeTab).toBeDefined()
await claudeTab!.trigger('click')
await nextTick()
let codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
expect(codeBlocks.join('\n')).toContain('ANTHROPIC_BASE_URL="https://example.com"')
expect(codeBlocks.join('\n')).toContain('ANTHROPIC_AUTH_TOKEN="sk-grok-claude-test"')
const unixConfig = codeBlocks.find((content) => content.startsWith('export ANTHROPIC_BASE_URL'))
expect(unixConfig).toBeDefined()
for (const name of [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_FABLE_MODEL',
'CLAUDE_CODE_SUBAGENT_MODEL'
]) {
expect(unixConfig).toContain(`export ${name}="grok-4.5"`)
}
const settingsConfig = codeBlocks.find((content) => content.includes('"$schema"'))
expect(settingsConfig).toBeDefined()
const parsedSettings = JSON.parse(settingsConfig!)
expect(parsedSettings.$schema).toBe('https://json.schemastore.org/claude-code-settings.json')
expect(parsedSettings.env.ANTHROPIC_MODEL).toBe('grok-4.5')
expect(wrapper.text()).toContain('keys.useKeyModal.claudeSettingsHint')
expect(wrapper.text()).toContain('keys.useKeyModal.grok.claudeNote')
expect(wrapper.find('nav[aria-label="Client"]').classes()).toContain('min-w-max')
expect(wrapper.find('nav[aria-label="Client"]').element.parentElement?.classList.contains('overflow-x-auto')).toBe(true)
const cmdTab = wrapper.findAll('button').find(
(button) => button.text().trim() === 'Windows CMD'
)
expect(cmdTab).toBeDefined()
await cmdTab!.trigger('click')
await nextTick()
codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
expect(codeBlocks.join('\n')).toContain('set ANTHROPIC_MODEL=grok-4.5')
expect(codeBlocks.join('\n')).toContain('set ANTHROPIC_DEFAULT_FABLE_MODEL=grok-4.5')
expect(codeBlocks.join('\n')).toContain('set CLAUDE_CODE_SUBAGENT_MODEL=grok-4.5')
const powershellTab = wrapper.findAll('button').find(
(button) => button.text().trim() === 'PowerShell'
)
expect(powershellTab).toBeDefined()
await powershellTab!.trigger('click')
await nextTick()
codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
expect(codeBlocks.join('\n')).toContain('$env:ANTHROPIC_BASE_URL="https://example.com"')
expect(codeBlocks.join('\n')).toContain('$env:ANTHROPIC_MODEL="grok-4.5"')
expect(codeBlocks.join('\n')).toContain('$env:ANTHROPIC_DEFAULT_FABLE_MODEL="grok-4.5"')
expect(codeBlocks.join('\n')).toContain('$env:CLAUDE_CODE_SUBAGENT_MODEL="grok-4.5"')
expect(wrapper.text()).toContain('%USERPROFILE%\\.claude\\settings.json')
const copyButton = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.copy')
)
expect(copyButton).toBeDefined()
await copyButton!.trigger('click')
expect(copyToClipboardMock).toHaveBeenCalledWith(
expect.stringContaining('ANTHROPIC_AUTH_TOKEN="sk-grok-claude-test"'),
'keys.copied'
)
})
it('renders Codex custom provider setup through the Grok Responses gateway', async () => {
const wrapper = mount(UseKeyModal, {
props: {
show: true,
apiKey: 'sk-grok-codex-test',
baseUrl: 'https://example.com/v1',
platform: 'grok'
},
global: {
stubs: {
BaseDialog: {
template: '<div><slot /><slot name="footer" /></div>'
},
Icon: {
template: '<span />'
}
}
}
})
const codexTab = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.cliTabs.codexCli')
)
expect(codexTab).toBeDefined()
await codexTab!.trigger('click')
await nextTick()
let codeBlocks = wrapper.findAll('pre code').map((code) => code.text())
const configToml = codeBlocks.find((content) => content.includes('[model_providers.sub2api_grok]'))
expect(configToml).toBeDefined()
expect(configToml).toContain('model_provider = "sub2api_grok"')
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('wire_api = "responses"')
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).not.toContain('[features]')
expect(configToml).not.toContain('goals = true')
expect(codeBlocks).toContain('export SUB2API_API_KEY="sk-grok-codex-test"')
expect(wrapper.text()).not.toContain('auth.json')
const windowsTab = wrapper.findAll('button').find(
(button) => button.text().trim() === 'Windows'
)
expect(windowsTab).toBeDefined()
await windowsTab!.trigger('click')
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"')
})
it('keeps legacy OpenAI Codex config as the default', () => {
const wrapper = mount(UseKeyModal, {
props: {
+8 -1
View File
@@ -134,6 +134,7 @@ export default {
copy: 'Copy',
copied: 'Copied',
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.',
claudeSettingsHint: 'User-level persistent configuration. Do not commit this file containing your API key to a project repository.',
noGroupTitle: 'Please assign a group first',
noGroupDescription: 'This API key has not been assigned to a group. Please click the group column in the key list to assign one before viewing the configuration.',
openai: {
@@ -167,10 +168,16 @@ 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 or OpenCode to send Responses API traffic through your Sub2API Grok group.',
description: 'Configure Grok Build, Claude Code, Codex, or OpenCode to send requests through your Sub2API Grok group.',
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.',
},
opencode: {
title: 'OpenCode Example',
+9 -2
View File
@@ -133,6 +133,7 @@ export default {
copy: '复制',
copied: '已复制',
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
claudeSettingsHint: '用户级持久配置。此文件包含 API 密钥,请勿提交到项目仓库。',
noGroupTitle: '请先分配分组',
noGroupDescription:
'此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。',
@@ -171,10 +172,16 @@ export default {
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
},
grok: {
description: '配置 Grok Build 或 OpenCode,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
description: '配置 Grok Build、Claude Code、Codex 或 OpenCode,让请求通过当前 Sub2API Grok 分组发送。',
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。'
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。'
},
opencode: {
title: 'OpenCode 配置示例',