feat(grok): admin account test modes with real media preview

Add mode-first connectivity probes for text/image/video/search/tts/stt/realtime,
standalone voice and web-search paths, media upload options, and in-browser
image/audio/video preview (including ZDR-safe b64 images and edit validation).
This commit is contained in:
IanShaw027
2026-08-08 01:07:08 +08:00
parent 72a56f862c
commit d0767eab9d
7 changed files with 2145 additions and 86 deletions
@@ -1065,6 +1065,12 @@ type TestAccountRequest struct {
ModelID string `json:"model_id"`
Prompt string `json:"prompt"`
Mode string `json:"mode"`
// Optional media for Grok (and future) real generation tests.
// ImageDataURL / AudioDataURL are data:<mime>;base64,... payloads.
// VideoUploadURL is a public HTTPS PUT URL for ZDR video output.
ImageDataURL string `json:"image_data_url"`
AudioDataURL string `json:"audio_data_url"`
VideoUploadURL string `json:"video_upload_url"`
}
type SyncFromCRSRequest struct {
@@ -1094,8 +1100,14 @@ func (h *AccountHandler) Test(c *gin.Context) {
// Allow empty body, model_id is optional
_ = c.ShouldBindJSON(&req)
opts := service.AccountTestOptions{
ImageDataURL: req.ImageDataURL,
AudioDataURL: req.AudioDataURL,
VideoUploadURL: req.VideoUploadURL,
}
// Use AccountTestService to test the account with SSE streaming
if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode); err != nil {
if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode, opts); err != nil {
// Error already sent via SSE, just log
return
}
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,13 @@
package service
import (
"bytes"
"context"
"encoding/base64"
"errors"
"image"
"image/color"
"image/png"
"io"
"net/http"
"net/http/httptest"
@@ -199,3 +205,389 @@ func TestAccountTestService_Grok429WithoutQuotaHeadersUsesFallback(t *testing.T)
require.Equal(t, 1, repo.rateLimitedCalls)
require.WithinDuration(t, before.Add(grokRateLimitFallbackCooldown), repo.resetAt, time.Second)
}
func TestAccountTestService_GrokImageModelUsesImagesGenerations(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 17, Name: "grok-oauth-image", Platform: PlatformGrok,
Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1,
Credentials: map[string]any{
"access_token": "grok-access-token",
"refresh_token": "grok-refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
"base_url": "https://cli-chat-proxy.grok.com/v1",
},
}
repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"data":[{"b64_json":"QUJD","mime_type":"image/jpeg"}]}`,
)),
}}
svc := &AccountTestService{
accountRepo: repo,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
httpUpstream: upstream,
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/17/test", nil)
err := svc.TestAccountConnection(c, account.ID, "grok-imagine-image", "a red apple", AccountTestModeDefault)
require.NoError(t, err)
require.Equal(t, "https://api.x.ai/v1/images/generations", upstream.lastReq.URL.String())
require.Equal(t, "grok-imagine-image", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "a red apple", gjson.GetBytes(upstream.lastBody, "prompt").String())
require.Equal(t, "b64_json", gjson.GetBytes(upstream.lastBody, "response_format").String())
require.Contains(t, rec.Body.String(), `"type":"image"`)
require.Contains(t, rec.Body.String(), "data:image/jpeg;base64,QUJD")
require.Contains(t, rec.Body.String(), `"type":"test_complete"`)
}
func TestAccountTestService_GrokWebSearchModeUsesResponsesWebSearchTool(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 18, Name: "grok-oauth-search", Platform: PlatformGrok,
Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1,
Credentials: map[string]any{
"access_token": "grok-access-token",
"refresh_token": "grok-refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
},
}
repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`{"id":"r1","output":[{"type":"web_search_call","id":"ws1","status":"completed"},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Grok is built by xAI."}]}]}`,
)),
}}
svc := &AccountTestService{
accountRepo: repo,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
httpUpstream: upstream,
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/18/test", nil)
err := svc.TestAccountConnection(c, account.ID, "grok-4.5", "xAI Grok", AccountTestModeGrokSearch)
require.NoError(t, err)
require.Equal(t, "https://cli-chat-proxy.grok.com/v1/responses", upstream.lastReq.URL.String())
// Standalone web_search wraps the query in the gateway-style prompt.
require.Contains(t, gjson.GetBytes(upstream.lastBody, "input").String(), "xAI Grok")
require.Equal(t, "web_search", gjson.GetBytes(upstream.lastBody, "tools.0.type").String())
require.Equal(t, "web_search_call.action.sources", gjson.GetBytes(upstream.lastBody, "include.0").String())
require.Contains(t, rec.Body.String(), "web_search ok")
require.Contains(t, rec.Body.String(), `"type":"test_complete"`)
}
func TestAccountTestService_GrokTTSIncludesLanguage(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 19, Name: "grok-oauth-tts", Platform: PlatformGrok,
Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1,
Credentials: map[string]any{
"access_token": "grok-access-token",
"refresh_token": "grok-refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
},
}
repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"audio/mpeg"}},
Body: io.NopCloser(strings.NewReader("ID3fakeaudio")),
}}
svc := &AccountTestService{
accountRepo: repo,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
httpUpstream: upstream,
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/19/test", nil)
err := svc.TestAccountConnection(c, account.ID, "", "hello voice", AccountTestModeGrokTTS)
require.NoError(t, err)
require.Equal(t, "https://api.x.ai/v1/tts", upstream.lastReq.URL.String())
require.Equal(t, "hello voice", gjson.GetBytes(upstream.lastBody, "text").String())
require.Equal(t, "en", gjson.GetBytes(upstream.lastBody, "language").String())
require.Contains(t, rec.Body.String(), "tts ok")
require.Contains(t, rec.Body.String(), `"type":"audio"`)
require.Contains(t, rec.Body.String(), "data:audio/mpeg;base64,")
require.Contains(t, rec.Body.String(), `"type":"test_complete"`)
}
func TestAccountTestService_GrokImageEditUsesUploadedImage(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 24, Name: "grok-oauth-image-edit", Platform: PlatformGrok,
Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1,
Credentials: map[string]any{
"access_token": "grok-access-token",
"refresh_token": "grok-refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
},
}
repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"data":[{"b64_json":"QUJD","mime_type":"image/png"}]}`,
)),
}}
svc := &AccountTestService{
accountRepo: repo,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
httpUpstream: upstream,
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/24/test", nil)
// 8x8 solid PNG (xAI min dimension is 8px).
src := minimalAccountTestPNGDataURL(8, 8)
err := svc.TestAccountConnection(c, account.ID, "grok-imagine-image", "edit me", AccountTestModeGrokImage, AccountTestOptions{
ImageDataURL: src,
})
require.NoError(t, err)
require.Equal(t, "https://api.x.ai/v1/images/edits", upstream.lastReq.URL.String())
require.True(t, strings.HasPrefix(gjson.GetBytes(upstream.lastBody, "image.url").String(), "data:image/png;base64,"))
require.Equal(t, "image_url", gjson.GetBytes(upstream.lastBody, "image.type").String())
require.Equal(t, "b64_json", gjson.GetBytes(upstream.lastBody, "response_format").String())
// concrete image model ids pass through; only bare "grok-imagine" is aliased.
require.Equal(t, "grok-imagine-image", gjson.GetBytes(upstream.lastBody, "model").String())
require.Contains(t, rec.Body.String(), `"type":"image"`)
}
func TestAccountTestService_GrokImageEditRejectsTinySource(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 25, Name: "grok-oauth-image-tiny", Platform: PlatformGrok,
Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1,
Credentials: map[string]any{
"access_token": "grok-access-token",
"refresh_token": "grok-refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
},
}
repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}
svc := &AccountTestService{
accountRepo: repo,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
httpUpstream: &httpUpstreamRecorder{},
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/25/test", nil)
// 1x1 PNG data URL
tiny := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
err := svc.TestAccountConnection(c, account.ID, "grok-imagine-image-quality", "edit", AccountTestModeGrokImage, AccountTestOptions{
ImageDataURL: tiny,
})
require.Error(t, err)
require.Contains(t, rec.Body.String(), "too small")
}
// minimalAccountTestPNGDataURL builds a solid RGBA PNG as a data URL for tests.
func minimalAccountTestPNGDataURL(w, h int) string {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: 200, G: 40, B: 40, A: 255})
}
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
func TestAccountTestService_GrokExplicitImageModeDefaultsModel(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 20, Name: "grok-oauth-image-mode", Platform: PlatformGrok,
Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1,
Credentials: map[string]any{
"access_token": "grok-access-token",
"refresh_token": "grok-refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
},
}
repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"data":[{"b64_json":"QUJD","mime_type":"image/jpeg"}]}`,
)),
}}
svc := &AccountTestService{
accountRepo: repo,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
httpUpstream: upstream,
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/20/test", nil)
err := svc.TestAccountConnection(c, account.ID, "", "", AccountTestModeGrokImage)
require.NoError(t, err)
require.Equal(t, "https://api.x.ai/v1/images/generations", upstream.lastReq.URL.String())
require.Equal(t, "grok-imagine-image", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "b64_json", gjson.GetBytes(upstream.lastBody, "response_format").String())
require.Contains(t, rec.Body.String(), `"type":"test_complete"`)
}
func TestAccountTestService_GrokVideoZDRUploadURLRequiredCountsAsConnectivityOK(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 21, Name: "grok-oauth-video-zdr", Platform: PlatformGrok,
Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1,
Credentials: map[string]any{
"access_token": "grok-access-token",
"refresh_token": "grok-refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
},
}
repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"code":"invalid-argument","error":"Zero Data Retention teams must provide output.upload_url for video generation."}`,
)),
}}
svc := &AccountTestService{
accountRepo: repo,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
httpUpstream: upstream,
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/21/test", nil)
err := svc.TestAccountConnection(c, account.ID, "grok-imagine-video", "bounce ball", AccountTestModeGrokVideo)
require.NoError(t, err)
require.Equal(t, "https://api.x.ai/v1/videos/generations", upstream.lastReq.URL.String())
require.Contains(t, rec.Body.String(), "NO VIDEO GENERATED")
require.Contains(t, rec.Body.String(), "output.upload_url")
require.Contains(t, rec.Body.String(), "Connectivity OK")
require.Contains(t, rec.Body.String(), `"type":"test_complete"`)
require.Contains(t, rec.Body.String(), `"success":true`)
require.NotContains(t, rec.Body.String(), `"type":"error"`)
}
type grokRealtimeTestConn struct {
msg []byte
}
func (c *grokRealtimeTestConn) WriteJSON(context.Context, any) error { return nil }
func (c *grokRealtimeTestConn) ReadMessage(context.Context) ([]byte, error) {
if c == nil || len(c.msg) == 0 {
return nil, context.DeadlineExceeded
}
return c.msg, nil
}
func (c *grokRealtimeTestConn) Ping(context.Context) error { return nil }
func (c *grokRealtimeTestConn) Close() error { return nil }
type grokRealtimeTestDialer struct {
lastURL string
lastAuth string
lastProxy string
conn openAIWSClientConn
err error
status int
}
func (d *grokRealtimeTestDialer) Dial(_ context.Context, wsURL string, headers http.Header, proxyURL string) (openAIWSClientConn, int, http.Header, error) {
d.lastURL = wsURL
d.lastAuth = headers.Get("Authorization")
d.lastProxy = proxyURL
if d.err != nil {
return nil, d.status, nil, d.err
}
if d.conn == nil {
d.conn = &grokRealtimeTestConn{}
}
return d.conn, 0, nil, nil
}
func TestAccountTestService_GrokRealtimeModeDialsWS(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 22, Name: "grok-oauth-realtime", Platform: PlatformGrok,
Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1,
Credentials: map[string]any{
"access_token": "grok-access-token",
"refresh_token": "grok-refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
},
}
repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}
dialer := &grokRealtimeTestDialer{
conn: &grokRealtimeTestConn{msg: []byte(`{"type":"session.created","session":{"id":"sess_1"}}`)},
}
svc := &AccountTestService{
accountRepo: repo,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
grokWSDialer: dialer,
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/22/test", nil)
err := svc.TestAccountConnection(c, account.ID, "", "", AccountTestModeGrokRealtime)
require.NoError(t, err)
require.Contains(t, dialer.lastURL, "wss://api.x.ai/v1/realtime")
require.Contains(t, dialer.lastURL, "model=grok-voice-latest")
require.Equal(t, "Bearer grok-access-token", dialer.lastAuth)
require.Contains(t, rec.Body.String(), "realtime ws handshake ok")
require.Contains(t, rec.Body.String(), "session.created")
require.Contains(t, rec.Body.String(), `"type":"test_complete"`)
require.Contains(t, rec.Body.String(), `"success":true`)
}
func TestAccountTestService_GrokRealtimeModeDialFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &Account{
ID: 23, Name: "grok-oauth-realtime-fail", Platform: PlatformGrok,
Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1,
Credentials: map[string]any{
"access_token": "grok-access-token",
"refresh_token": "grok-refresh-token",
"expires_at": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339),
},
}
repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}
dialer := &grokRealtimeTestDialer{
status: 401,
err: &openAIWSHandshakeError{Body: []byte(`{"error":"unauthorized"}`), Err: errors.New("websocket handshake failed")},
}
svc := &AccountTestService{
accountRepo: repo,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
grokWSDialer: dialer,
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/23/test", nil)
err := svc.TestAccountConnection(c, account.ID, "", "", AccountTestModeGrokRealtime)
require.Error(t, err)
require.Contains(t, rec.Body.String(), `"type":"error"`)
require.Contains(t, rec.Body.String(), "Realtime")
}
@@ -41,13 +41,28 @@
</span>
</div>
<div class="space-y-1.5">
<!-- Grok: mode first, then optional model / mode params -->
<div v-if="isGrokAccount" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.grok.testMode') }}
</label>
<Select
v-model="grokTestMode"
:options="grokTestModeOptions"
:disabled="status === 'connecting'"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.grok.testModeHint') }}
</p>
</div>
<div v-if="showModelSelect" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }}
</label>
<Select
v-model="selectedModelId"
:options="availableModels"
:options="modelOptionsForMode"
:disabled="loadingModels || status === 'connecting'"
value-key="id"
label-key="display_name"
@@ -66,16 +81,69 @@
/>
</div>
<div v-if="supportsImageTest" class="space-y-1.5">
<div v-if="supportsPromptInput" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.imagePromptLabel')"
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
:hint="t('admin.accounts.imageTestHint')"
:label="promptInputLabel"
:placeholder="promptInputPlaceholder"
:hint="promptInputHint"
:disabled="status === 'connecting'"
rows="3"
/>
</div>
<p
v-else-if="isGrokAccount && promptInputHint"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ promptInputHint }}
</p>
<!-- Optional media uploads for real generation / transcription -->
<div v-if="supportsImageUpload" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ imageUploadLabel }}
</label>
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
class="block w-full text-sm text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-primary-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-300 dark:file:bg-primary-500/20 dark:file:text-primary-300"
:disabled="status === 'connecting'"
@change="onImageFileChange"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.imageUploadHint') }}</p>
<div v-if="uploadImagePreview" class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-500">
<img :src="uploadImagePreview" alt="upload preview" class="max-h-40 w-full object-contain bg-gray-50 dark:bg-dark-700" />
</div>
</div>
<div v-if="supportsAudioUpload" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.grok.audioUploadLabel') }}
</label>
<input
type="file"
accept="audio/*,.wav,.mp3,.m4a,.ogg,.webm"
class="block w-full text-sm text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-primary-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-300 dark:file:bg-primary-500/20 dark:file:text-primary-300"
:disabled="status === 'connecting'"
@change="onAudioFileChange"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.audioUploadHint') }}</p>
<p v-if="uploadAudioName" class="text-xs text-gray-600 dark:text-gray-300">{{ uploadAudioName }}</p>
</div>
<div v-if="supportsVideoUploadURL" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.grok.videoUploadUrlLabel') }}
</label>
<input
v-model="videoUploadURL"
type="url"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100"
:placeholder="t('admin.accounts.grok.videoUploadUrlPlaceholder')"
:disabled="status === 'connecting'"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.videoUploadUrlHint') }}</p>
</div>
<!-- Terminal Output -->
<div class="group relative">
@@ -153,6 +221,36 @@
</div>
</div>
<div v-if="generatedAudios.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.audioPreview') }}
</div>
<div
v-for="(audio, index) in generatedAudios"
:key="`audio-${index}`"
class="rounded-xl border border-gray-200 bg-white p-3 dark:border-dark-500 dark:bg-dark-700"
>
<audio :src="audio.url" controls class="w-full" :type="audio.mimeType" />
<div class="mt-1 text-xs text-gray-500 dark:text-gray-300">{{ audio.mimeType || 'audio/*' }}</div>
</div>
</div>
<div v-if="generatedVideos.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.videoPreview') }}
</div>
<div
v-for="(video, index) in generatedVideos"
:key="`video-${index}`"
class="overflow-hidden rounded-xl border border-gray-200 bg-black dark:border-dark-500"
>
<video :src="video.url" controls class="max-h-[360px] w-full" :type="video.mimeType" />
<div class="border-t border-gray-100 bg-white px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-300">
{{ video.mimeType || 'video/*' }}
</div>
</div>
</div>
<!-- Image Lightbox -->
<Teleport to="body">
<Transition name="fade">
@@ -186,11 +284,7 @@
</div>
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{
supportsImageTest
? t('admin.accounts.imageTestMode')
: t('admin.accounts.testPrompt')
}}
{{ testModeSummary }}
</span>
</div>
</div>
@@ -205,10 +299,10 @@
</button>
<button
@click="startTest"
:disabled="status === 'connecting' || !selectedModelId"
:disabled="!canStartTest"
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId
!canStartTest
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
@@ -262,7 +356,7 @@ interface OutputLine {
class: string
}
interface PreviewImage {
interface PreviewMedia {
url: string
mimeType?: string
}
@@ -286,14 +380,32 @@ const selectedModelId = ref('')
const testPrompt = ref('')
const loadingModels = ref(false)
let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([])
const generatedImages = ref<PreviewMedia[]>([])
const generatedAudios = ref<PreviewMedia[]>([])
const generatedVideos = ref<PreviewMedia[]>([])
const previewImageUrl = ref('')
const testMode = ref<'default' | 'compact'>('default')
const grokTestMode = ref<'text' | 'image' | 'video' | 'search' | 'tts' | 'stt' | 'realtime'>('text')
const uploadImageDataURL = ref('')
const uploadImagePreview = ref('')
const uploadAudioDataURL = ref('')
const uploadAudioName = ref('')
const videoUploadURL = ref('')
const isOpenAIAccount = computed(() => props.account?.platform === 'openai')
const isGrokAccount = computed(() => props.account?.platform === 'grok')
const openAITestModeOptions = computed(() => [
{ value: 'default', label: t('admin.accounts.openai.testModeDefault') },
{ value: 'compact', label: t('admin.accounts.openai.testModeCompact') }
])
const grokTestModeOptions = computed(() => [
{ value: 'text', label: t('admin.accounts.grok.testModeText') },
{ value: 'image', label: t('admin.accounts.grok.testModeImage') },
{ value: 'video', label: t('admin.accounts.grok.testModeVideo') },
{ value: 'search', label: t('admin.accounts.grok.testModeSearch') },
{ value: 'tts', label: t('admin.accounts.grok.testModeTTS') },
{ value: 'stt', label: t('admin.accounts.grok.testModeSTT') },
{ value: 'realtime', label: t('admin.accounts.grok.testModeRealtime') }
])
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-3.5-flash', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
const modelID = selectedModelId.value.toLowerCase()
@@ -308,7 +420,229 @@ const supportsOpenAIImageTest = computed(() => {
return props.account?.platform === 'openai'
})
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
const isGrokImageModel = (id: string) => {
const modelID = id.toLowerCase()
return (
modelID === 'grok-imagine' ||
modelID === 'grok-imagine-edit' ||
modelID.startsWith('grok-imagine-image')
)
}
const isGrokVideoModel = (id: string) => {
const modelID = id.toLowerCase()
return modelID.startsWith('grok-imagine-video') || modelID.startsWith('grok-video')
}
const isGrokTextModel = (id: string) => !isGrokImageModel(id) && !isGrokVideoModel(id)
const supportsGrokImageTest = computed(
() => isGrokAccount.value && grokTestMode.value === 'image'
)
const supportsGrokVideoTest = computed(
() => isGrokAccount.value && grokTestMode.value === 'video'
)
const supportsImageTest = computed(
() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value || supportsGrokImageTest.value
)
// Model select only when the mode needs a model.
const showModelSelect = computed(() => {
if (!isGrokAccount.value) return true
return grokTestMode.value === 'text' || grokTestMode.value === 'image' || grokTestMode.value === 'video'
})
const modelOptionsForMode = computed(() => {
if (!isGrokAccount.value) return availableModels.value
if (grokTestMode.value === 'image') {
return availableModels.value.filter((m) => isGrokImageModel(m.id))
}
if (grokTestMode.value === 'video') {
return availableModels.value.filter((m) => isGrokVideoModel(m.id))
}
if (grokTestMode.value === 'text') {
return availableModels.value.filter((m) => isGrokTextModel(m.id))
}
return []
})
const supportsPromptInput = computed(() => {
if (!isGrokAccount.value) {
return supportsImageTest.value
}
return (
grokTestMode.value === 'image' ||
grokTestMode.value === 'video' ||
grokTestMode.value === 'search' ||
grokTestMode.value === 'tts'
)
})
const supportsImageUpload = computed(
() => isGrokAccount.value && (grokTestMode.value === 'image' || grokTestMode.value === 'video')
)
const supportsAudioUpload = computed(() => isGrokAccount.value && grokTestMode.value === 'stt')
const supportsVideoUploadURL = computed(() => isGrokAccount.value && grokTestMode.value === 'video')
const imageUploadLabel = computed(() =>
grokTestMode.value === 'video'
? t('admin.accounts.grok.videoFirstFrameLabel')
: t('admin.accounts.grok.imageUploadLabel')
)
const readFileAsDataURL = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
const onImageFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
return
}
if (file.size > 6 * 1024 * 1024) {
errorMessage.value = t('admin.accounts.grok.mediaTooLarge')
status.value = 'error'
input.value = ''
return
}
try {
const dataURL = await readFileAsDataURL(file)
uploadImageDataURL.value = dataURL
uploadImagePreview.value = dataURL
} catch {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
}
}
const onAudioFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
return
}
if (file.size > 6 * 1024 * 1024) {
errorMessage.value = t('admin.accounts.grok.mediaTooLarge')
status.value = 'error'
input.value = ''
return
}
try {
uploadAudioDataURL.value = await readFileAsDataURL(file)
uploadAudioName.value = file.name
} catch {
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
}
}
const clearMediaUploads = () => {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
videoUploadURL.value = ''
}
const promptInputLabel = computed(() => {
if (supportsGrokVideoTest.value || grokTestMode.value === 'video') {
return t('admin.accounts.videoPromptLabel')
}
if (supportsImageTest.value || grokTestMode.value === 'image') {
return t('admin.accounts.imagePromptLabel')
}
if (grokTestMode.value === 'search') {
return t('admin.accounts.grok.searchQueryLabel')
}
if (grokTestMode.value === 'tts') {
return t('admin.accounts.grok.ttsTextLabel')
}
return t('admin.accounts.imagePromptLabel')
})
const promptInputPlaceholder = computed(() => {
if (grokTestMode.value === 'video') {
return t('admin.accounts.videoPromptPlaceholder')
}
if (grokTestMode.value === 'image' || supportsImageTest.value) {
return t('admin.accounts.imagePromptPlaceholder')
}
if (grokTestMode.value === 'search') {
return t('admin.accounts.grok.searchQueryPlaceholder')
}
if (grokTestMode.value === 'tts') {
return t('admin.accounts.grok.ttsTextPlaceholder')
}
return ''
})
const promptInputHint = computed(() => {
if (grokTestMode.value === 'video') {
return t('admin.accounts.videoTestHint')
}
if (grokTestMode.value === 'image' || supportsImageTest.value) {
return t('admin.accounts.imageTestHint')
}
if (grokTestMode.value === 'search') {
return t('admin.accounts.grok.searchTestHint')
}
if (grokTestMode.value === 'tts') {
return t('admin.accounts.grok.ttsTestHint')
}
if (grokTestMode.value === 'stt') {
return t('admin.accounts.grok.sttTestHint')
}
if (grokTestMode.value === 'realtime') {
return t('admin.accounts.grok.realtimeTestHint')
}
return ''
})
const testModeSummary = computed(() => {
if (isGrokAccount.value) {
switch (grokTestMode.value) {
case 'video':
return t('admin.accounts.videoTestMode')
case 'image':
return t('admin.accounts.imageTestMode')
case 'search':
return t('admin.accounts.grok.searchTestMode')
case 'tts':
return t('admin.accounts.grok.ttsTestMode')
case 'stt':
return t('admin.accounts.grok.sttTestMode')
case 'realtime':
return t('admin.accounts.grok.realtimeTestMode')
default:
return t('admin.accounts.grok.textTestMode')
}
}
if (supportsImageTest.value) return t('admin.accounts.imageTestMode')
return t('admin.accounts.testPrompt')
})
const canStartTest = computed(() => {
if (status.value === 'connecting') return false
if (isGrokAccount.value) {
if (
grokTestMode.value === 'search' ||
grokTestMode.value === 'tts' ||
grokTestMode.value === 'stt' ||
grokTestMode.value === 'realtime'
) {
return true // standalone modes (prompt/model optional)
}
return Boolean(selectedModelId.value)
}
return Boolean(selectedModelId.value)
})
const sortTestModels = (models: ClaudeModel[]) => {
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
@@ -322,24 +656,63 @@ const sortTestModels = (models: ClaudeModel[]) => {
}
// Load available models when modal opens
const applyDefaultPromptForMode = () => {
if (!supportsPromptInput.value) return
if (testPrompt.value.trim()) return
if (grokTestMode.value === 'video') {
testPrompt.value = t('admin.accounts.videoPromptDefault')
} else if (grokTestMode.value === 'image' || supportsImageTest.value) {
testPrompt.value = t('admin.accounts.imagePromptDefault')
} else if (grokTestMode.value === 'search') {
testPrompt.value = t('admin.accounts.grok.searchQueryDefault')
} else if (grokTestMode.value === 'tts') {
testPrompt.value = t('admin.accounts.grok.ttsTextDefault')
}
}
const pickDefaultModelForMode = () => {
const opts = modelOptionsForMode.value
if (!opts.length) {
selectedModelId.value = ''
return
}
if (opts.some((m) => m.id === selectedModelId.value)) return
if (grokTestMode.value === 'text') {
const preferred =
opts.find((m) => m.id.includes('grok-4.5')) ||
opts.find((m) => m.id === 'grok') ||
opts[0]
selectedModelId.value = preferred.id
return
}
selectedModelId.value = opts[0].id
}
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
testPrompt.value = ''
testMode.value = 'default'
grokTestMode.value = 'text'
resetState()
await loadAvailableModels()
if (isGrokAccount.value) {
pickDefaultModelForMode()
applyDefaultPromptForMode()
}
} else {
abortStream()
}
}
)
watch(selectedModelId, () => {
if (supportsImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.imagePromptDefault')
}
watch(grokTestMode, () => {
if (!isGrokAccount.value) return
testPrompt.value = ''
clearMediaUploads()
pickDefaultModelForMode()
applyDefaultPromptForMode()
})
const loadAvailableModels = async () => {
@@ -378,6 +751,8 @@ const resetState = () => {
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
generatedAudios.value = []
generatedVideos.value = []
previewImageUrl.value = ''
}
@@ -406,12 +781,17 @@ const scrollToBottom = async () => {
}
const startTest = async () => {
if (!props.account || !selectedModelId.value) return
if (!props.account || !canStartTest.value) return
resetState()
status.value = 'connecting'
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
if (isGrokAccount.value) {
const modeLabel =
grokTestModeOptions.value.find((o) => o.value === grokTestMode.value)?.label || grokTestMode.value
addLine(t('admin.accounts.grok.selectedTestMode', { mode: modeLabel }), 'text-gray-400')
}
addLine('', 'text-gray-300')
abortStream()
@@ -422,14 +802,39 @@ const startTest = async () => {
const requestBody: {
model_id: string
prompt: string
mode?: 'default' | 'compact'
mode?: string
image_data_url?: string
audio_data_url?: string
video_upload_url?: string
} = {
model_id: selectedModelId.value,
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
model_id: showModelSelect.value ? selectedModelId.value : '',
prompt: supportsPromptInput.value ? testPrompt.value.trim() : ''
}
if (isOpenAIAccount.value) {
requestBody.mode = testMode.value
}
if (isGrokAccount.value) {
// Always send explicit Grok mode. search/tts/stt/realtime are standalone
// endpoints (no free-form model select). text/image/video use optional model.
requestBody.mode = grokTestMode.value
if (
grokTestMode.value === 'search' ||
grokTestMode.value === 'tts' ||
grokTestMode.value === 'stt' ||
grokTestMode.value === 'realtime'
) {
requestBody.model_id = ''
}
if (uploadImageDataURL.value && (grokTestMode.value === 'image' || grokTestMode.value === 'video')) {
requestBody.image_data_url = uploadImageDataURL.value
}
if (uploadAudioDataURL.value && grokTestMode.value === 'stt') {
requestBody.audio_data_url = uploadAudioDataURL.value
}
if (videoUploadURL.value.trim() && grokTestMode.value === 'video') {
requestBody.video_upload_url = videoUploadURL.value.trim()
}
}
// Use the configured API base; EventSource does not support POST.
const url = buildApiUrl(`/admin/accounts/${props.account.id}/test`)
@@ -499,6 +904,8 @@ const handleEvent = (event: {
success?: boolean
error?: string
image_url?: string
audio_url?: string
video_url?: string
mime_type?: string
}) => {
switch (event.type) {
@@ -508,7 +915,21 @@ const handleEvent = (event: {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
supportsImageTest.value
isGrokAccount.value
? grokTestMode.value === 'video'
? t('admin.accounts.sendingVideoRequest')
: grokTestMode.value === 'image'
? t('admin.accounts.sendingImageRequest')
: grokTestMode.value === 'search'
? t('admin.accounts.grok.sendingSearchRequest')
: grokTestMode.value === 'tts'
? t('admin.accounts.grok.sendingTTSRequest')
: grokTestMode.value === 'stt'
? t('admin.accounts.grok.sendingSTTRequest')
: grokTestMode.value === 'realtime'
? t('admin.accounts.grok.sendingRealtimeRequest')
: t('admin.accounts.sendingTestMessage')
: supportsImageTest.value
? t('admin.accounts.sendingImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
@@ -534,6 +955,26 @@ const handleEvent = (event: {
}
break
case 'audio':
if (event.audio_url) {
generatedAudios.value.push({
url: event.audio_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.audioReceived', { count: generatedAudios.value.length }), 'text-purple-300')
}
break
case 'video':
if (event.video_url) {
generatedVideos.value.push({
url: event.video_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.videoReceived', { count: generatedVideos.value.length }), 'text-purple-300')
}
break
case 'status':
if (event.text) {
addLine(event.text, 'text-cyan-300')
@@ -179,7 +179,8 @@ describe('AccountTestModal', () => {
const [, request] = (global.fetch as any).mock.calls[0]
expect(JSON.parse(request.body)).toEqual({
model_id: 'grok-4.3',
prompt: ''
prompt: '',
mode: 'text'
})
})
+65 -3
View File
@@ -144,7 +144,8 @@ export default {
creditsExhausted: 'Credits Exhausted',
creditsExhaustedUntil: 'AI Credits exhausted, expected recovery at {time}',
overloadedUntil: 'Overloaded until {time}',
viewTempUnschedDetails: 'View temp unschedulable details'
viewTempUnschedDetails: 'View temp unschedulable details',
tempUnschedulableUntil: 'Resumes {time}'
},
columns: {
name: 'Name',
@@ -308,6 +309,10 @@ export default {
notice: 'Rules are evaluated in order and require both error code and keyword match.',
addRule: 'Add Rule',
ruleOrder: 'Rule Order',
multipleErrorTrigger: '{count} matching errors in {minutes} minutes reached the trigger threshold ({threshold}).',
multipleErrorTriggerNoWindow: '{count} matching errors reached the trigger threshold ({threshold}).',
multipleErrorCountInWindow: '{count} matching errors occurred within {minutes} minutes.',
multipleErrorCount: '{count} matching errors contributed to this block.',
ruleIndex: 'Rule #{index}',
errorCode: 'Error Code',
errorCodePlaceholder: 'e.g. 429',
@@ -602,7 +607,52 @@ export default {
},
grok: {
baseUrlHint: 'Grok OAuth accounts forward to the official xAI API base URL.',
apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.'
apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.',
// Account connectivity test modal
testMode: 'Test mode',
testModeHint:
'Text / image / video use the selected model. Web search, TTS, STT and Realtime hit standalone endpoints (not free-form chat tools).',
testModeText: 'Text (Responses)',
testModeImage: 'Image (/images/generations)',
testModeVideo: 'Video (/videos/generations)',
testModeSearch: 'Web search (/web_search)',
testModeTTS: 'TTS (/tts)',
testModeSTT: 'STT (/stt)',
testModeRealtime: 'Realtime (WS /realtime)',
textTestMode: 'Mode: Text (Responses)',
searchTestMode: 'Mode: Web search (/web_search)',
ttsTestMode: 'Mode: TTS (/tts)',
sttTestMode: 'Mode: STT (/stt)',
realtimeTestMode: 'Mode: Realtime (WS /realtime)',
searchQueryLabel: 'Search query',
searchQueryPlaceholder: 'Example: xAI Grok',
searchQueryDefault: 'xAI Grok',
searchTestHint:
'Standalone web_search probe (same as gateway /v1/web_search). Not a free-form chat with tools.',
ttsTextLabel: 'TTS text',
ttsTextPlaceholder: 'Example: Hello from Sub2API connectivity test.',
ttsTextDefault: 'Hello from Sub2API account connectivity test.',
ttsTestHint: 'Standalone /v1/tts with language=en; success reports audio byte size.',
sttTestHint: 'Standalone /v1/stt with a synthetic silent WAV; success means the endpoint is reachable.',
realtimeTestHint:
'Standalone WebSocket dial to /v1/realtime (model=grok-voice-latest). Handshake success = connectivity OK; may also show the first server event.',
sendingSearchRequest: 'Sending standalone web_search request...',
sendingTTSRequest: 'Sending standalone /tts request...',
sendingSTTRequest: 'Sending standalone /stt request...',
sendingRealtimeRequest: 'Dialing standalone /realtime WebSocket...',
selectedTestMode: 'Test mode: {mode}',
imageUploadLabel: 'Source image (optional, for edits)',
videoFirstFrameLabel: 'First-frame / reference image (optional)',
imageUploadHint:
'PNG/JPEG recommended, both sides ≥ 8 px, under ~4 MB for edits. Uploading a source image switches to /images/edits (image-to-image). Leave empty for text-to-image /images/generations.',
audioUploadLabel: 'Audio file (optional for STT)',
audioUploadHint:
'Upload a real audio clip to transcribe. Without a file, a silent WAV is used for connectivity only.',
videoUploadUrlLabel: 'Video output.upload_url (ZDR, optional)',
videoUploadUrlPlaceholder: 'https://your-public-host/v1/media/uploads/<token>',
videoUploadUrlHint:
'Required for full video generation on Zero Data Retention teams: a public HTTPS URL that accepts PUT of the finished MP4. Without it, the test only verifies connectivity.',
mediaTooLarge: 'File is too large (max ~6 MB for admin test uploads).'
},
anthropic: {
apiKeyPassthrough: 'Auto passthrough (auth only)',
@@ -1310,10 +1360,22 @@ export default {
imagePromptLabel: 'Image prompt',
imagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
imageTestHint: 'When an image model is selected, this test sends a real image-generation request and previews the returned image below.',
imageTestHint:
'Calls standalone /v1/images/generations (Grok uses response_format=b64_json for ZDR-safe previews) and shows the returned image below.',
imageTestMode: 'Mode: Image generation test',
videoPromptLabel: 'Video prompt',
videoPromptPlaceholder: 'Example: A red ball bouncing once on a white floor, short simple motion.',
videoPromptDefault: 'A red ball bouncing once on a white floor, short simple motion.',
videoTestHint:
'Calls standalone /v1/videos/generations. Zero Data Retention (ZDR) teams require a public output.upload_url so xAI can PUT the finished MP4 — this admin probe does not host that receiver, so ZDR accounts get connectivity-only success without producing a video file.',
videoTestMode: 'Mode: Video generation test',
sendingVideoRequest: 'Sending video generation request...',
imagePreview: 'Generated images:',
imageReceived: 'Received test image #{count}',
audioPreview: 'Generated audio:',
audioReceived: 'Received test audio #{count}',
videoPreview: 'Generated video:',
videoReceived: 'Received test video #{count}',
// Stats Modal
viewStats: 'View Stats',
usageStatistics: 'Usage Statistics',
+64 -3
View File
@@ -347,7 +347,8 @@ export default {
creditsExhausted: '积分已用尽',
creditsExhaustedUntil: 'AI Credits 已用尽,预计 {time} 恢复',
overloadedUntil: '负载过重,重置时间:{time}',
viewTempUnschedDetails: '查看临时不可调度详情'
viewTempUnschedDetails: '查看临时不可调度详情',
tempUnschedulableUntil: '预计 {time} 恢复'
},
tempUnschedulable: {
title: '临时不可调度',
@@ -356,6 +357,10 @@ export default {
notice: '规则按顺序匹配,需同时满足错误码与关键词。',
addRule: '添加规则',
ruleOrder: '规则序号',
multipleErrorTrigger: '{minutes} 分钟内累计 {count} 次匹配错误,达到触发阈值({threshold})。',
multipleErrorTriggerNoWindow: '累计 {count} 次匹配错误,达到触发阈值({threshold})。',
multipleErrorCountInWindow: '{minutes} 分钟内累计发生 {count} 次匹配错误。',
multipleErrorCount: '本次不可调度由累计 {count} 次匹配错误触发。',
ruleIndex: '规则 #{index}',
errorCode: '错误码',
errorCodePlaceholder: '例如 429',
@@ -672,7 +677,51 @@ export default {
},
grok: {
baseUrlHint: 'Grok OAuth 账号会转发到官方 xAI API Base URL。',
apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。'
apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。',
// 账号连通性测试弹窗
testMode: '测试模式',
testModeHint:
'文本 / 图片 / 视频使用所选模型。网页搜索、TTS、STT、Realtime 走独立接口探测(不是对话里的 tools)。',
testModeText: '文本(Responses)',
testModeImage: '图片(/images/generations)',
testModeVideo: '视频(/videos/generations)',
testModeSearch: '网页搜索(/web_search)',
testModeTTS: '语音合成 TTS(/tts)',
testModeSTT: '语音识别 STT(/stt)',
testModeRealtime: '实时语音 Realtime(WS /realtime)',
textTestMode: '模式:文本(Responses)',
searchTestMode: '模式:网页搜索(/web_search)',
ttsTestMode: '模式:TTS(/tts)',
sttTestMode: '模式:STT(/stt)',
realtimeTestMode: '模式:Realtime(WS /realtime)',
searchQueryLabel: '搜索关键词',
searchQueryPlaceholder: '例如:xAI Grok',
searchQueryDefault: 'xAI Grok',
searchTestHint:
'独立网页搜索探测(与网关 /v1/web_search 语义一致),不是带 tools 的自由对话。',
ttsTextLabel: 'TTS 文本',
ttsTextPlaceholder: '例如:Hello from Sub2API connectivity test.',
ttsTextDefault: 'Hello from Sub2API account connectivity test.',
ttsTestHint: '独立调用 /v1/tts(language=en);成功时显示音频字节数。',
sttTestHint: '独立调用 /v1/stt,使用合成静音 WAV;成功表示接口可达。',
realtimeTestHint:
'独立 WebSocket 拨号 /v1/realtime(model=grok-voice-latest)。握手成功即连通;若有首包服务端事件会一并显示。',
sendingSearchRequest: '正在发送独立 web_search 请求...',
sendingTTSRequest: '正在发送独立 /tts 请求...',
sendingSTTRequest: '正在发送独立 /stt 请求...',
sendingRealtimeRequest: '正在拨号独立 /realtime WebSocket...',
selectedTestMode: '测试模式:{mode}',
imageUploadLabel: '源图片(可选,图生图/编辑)',
videoFirstFrameLabel: '首帧 / 参考图(可选)',
imageUploadHint:
'建议 PNG/JPEG,宽高均 ≥ 8 像素,编辑建议小于约 4MB。上传源图会走 /images/edits(图生图);不上传则走 /images/generations 文生图。',
audioUploadLabel: '音频文件(STT 可选)',
audioUploadHint: '上传真实音频做转写;不上传则用静音 WAV 仅测连通。',
videoUploadUrlLabel: '视频 output.upload_url(ZDR,可选)',
videoUploadUrlPlaceholder: 'https://your-public-host/v1/media/uploads/<token>',
videoUploadUrlHint:
'零数据保留(ZDR)账号完整出片需要:公网 HTTPS 地址,接受成品 MP4 的 PUT。不填则只验证连通性,不会真正出视频。',
mediaTooLarge: '文件过大(管理端测试上传上限约 6MB)。'
},
anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)',
@@ -1361,10 +1410,22 @@ export default {
imagePromptLabel: '生图提示词',
imagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
imageTestHint: '选择图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
imageTestHint:
'调用独立 /v1/images/generations 生图(Grok 使用 response_format=b64_json 以兼容 ZDR),并在下方预览返回图片。',
imageTestMode: '模式:生图测试',
videoPromptLabel: '视频提示词',
videoPromptPlaceholder: '例如:一只红球在白地板上弹跳一次,动作简短。',
videoPromptDefault: 'A red ball bouncing once on a white floor, short simple motion.',
videoTestHint:
'调用独立 /v1/videos/generations。零数据保留(ZDR)账号必须提供公网可 PUT 的 output.upload_url,xAI 才会把成品 MP4 写回去;管理端测试不托管该上传接收地址,因此 ZDR 账号只会得到「连通性成功」,不会实际生成视频文件。',
videoTestMode: '模式:视频生成测试',
sendingVideoRequest: '正在发送视频生成测试请求...',
imagePreview: '生成结果:',
imageReceived: '已收到第 {count} 张测试图片',
audioPreview: '生成音频:',
audioReceived: '已收到第 {count} 段测试音频',
videoPreview: '生成视频:',
videoReceived: '已收到第 {count} 段测试视频',
// Stats Modal
viewStats: '查看统计',
usageStatistics: '使用统计',