mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '使用统计',
|
||||
|
||||
Reference in New Issue
Block a user