From a34d4967e6ef616adfc38848fb845f48a87118c4 Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 1 Jul 2026 15:12:48 +0800 Subject: [PATCH] feat: add LLM media tester --- backend/internal/server/router.go | 1 + backend/internal/server/routes/llm_tester.go | 310 +++++ frontend/src/api/__tests__/llmTester.spec.ts | 178 +++ frontend/src/api/llmTester.ts | 932 +++++++++++++ frontend/src/components/layout/AppSidebar.vue | 17 + frontend/src/composables/useModelWhitelist.ts | 16 +- frontend/src/i18n/locales/en.ts | 74 ++ frontend/src/i18n/locales/zh.ts | 74 ++ frontend/src/router/index.ts | 14 +- frontend/src/views/user/LLMTesterView.vue | 1157 +++++++++++++++++ frontend/vite.config.ts | 204 ++- 11 files changed, 2973 insertions(+), 4 deletions(-) create mode 100644 backend/internal/server/routes/llm_tester.go create mode 100644 frontend/src/api/__tests__/llmTester.spec.ts create mode 100644 frontend/src/api/llmTester.ts create mode 100644 frontend/src/views/user/LLMTesterView.vue diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 3d86373779..35f2c3949e 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -107,6 +107,7 @@ func registerRoutes( v1 := r.Group("/api/v1") // 注册各模块路由 + routes.RegisterLLMTesterRoutes(v1) routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient, settingService) routes.RegisterUserRoutes(v1, h, jwtAuth, settingService) routes.RegisterAdminRoutes(v1, h, adminAuth, settingService) diff --git a/backend/internal/server/routes/llm_tester.go b/backend/internal/server/routes/llm_tester.go new file mode 100644 index 0000000000..684b86d729 --- /dev/null +++ b/backend/internal/server/routes/llm_tester.go @@ -0,0 +1,310 @@ +package routes + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "github.com/gin-gonic/gin" +) + +const ( + llmTesterMaxRequestBytes = 12 << 20 + llmTesterMaxResponseBytes = 12 << 20 +) + +var ( + llmTesterVersionPathPattern = regexp.MustCompile(`/v\d+$`) + llmTesterHTTPClient = &http.Client{ + Timeout: 300 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: llmTesterSafeDialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 240 * time.Second, + IdleConnTimeout: 30 * time.Second, + }, + } + llmTesterDialer = &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + } + llmTesterBlockedCIDRs = mustParseLLMTesterCIDRs([]string{ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.168.0.0/16", + "::/128", + "::1/128", + "fc00::/7", + "fe80::/10", + }) +) + +type llmTesterProxyRequest struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +func RegisterLLMTesterRoutes(v1 *gin.RouterGroup) { + tester := v1.Group("/llm-tester") + { + tester.POST("/models", llmTesterProxyModels) + tester.POST("/chat/completions", llmTesterProxyChatCompletions) + tester.POST("/images/generations", llmTesterProxyImageGenerations) + tester.POST("/videos/generations", llmTesterProxyVideoGenerations) + tester.POST("/responses", llmTesterProxyResponses) + } +} + +func llmTesterProxyModels(c *gin.Context) { + var req llmTesterProxyRequest + if !bindLLMTesterProxyRequest(c, &req) { + return + } + forwardLLMTesterRequest(c, req, http.MethodGet, "models", nil) +} + +func llmTesterProxyChatCompletions(c *gin.Context) { + var req llmTesterProxyRequest + if !bindLLMTesterProxyRequest(c, &req) { + return + } + if len(bytes.TrimSpace(req.Payload)) == 0 { + response.BadRequest(c, "payload is required") + return + } + forwardLLMTesterRequest(c, req, http.MethodPost, "chat/completions", bytes.NewReader(req.Payload)) +} + +func llmTesterProxyImageGenerations(c *gin.Context) { + var req llmTesterProxyRequest + if !bindLLMTesterProxyRequest(c, &req) { + return + } + if len(bytes.TrimSpace(req.Payload)) == 0 { + response.BadRequest(c, "payload is required") + return + } + forwardLLMTesterRequest(c, req, http.MethodPost, "images/generations", bytes.NewReader(req.Payload)) +} + +func llmTesterProxyVideoGenerations(c *gin.Context) { + var req llmTesterProxyRequest + if !bindLLMTesterProxyRequest(c, &req) { + return + } + if len(bytes.TrimSpace(req.Payload)) == 0 { + response.BadRequest(c, "payload is required") + return + } + forwardLLMTesterRequest(c, req, http.MethodPost, "videos/generations", bytes.NewReader(req.Payload)) +} + +func llmTesterProxyResponses(c *gin.Context) { + var req llmTesterProxyRequest + if !bindLLMTesterProxyRequest(c, &req) { + return + } + if len(bytes.TrimSpace(req.Payload)) == 0 { + response.BadRequest(c, "payload is required") + return + } + forwardLLMTesterRequest(c, req, http.MethodPost, "responses", bytes.NewReader(req.Payload)) +} + +func bindLLMTesterProxyRequest(c *gin.Context, req *llmTesterProxyRequest) bool { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, llmTesterMaxRequestBytes) + if err := json.NewDecoder(c.Request.Body).Decode(req); err != nil { + response.BadRequest(c, "invalid request body") + return false + } + if strings.TrimSpace(req.BaseURL) == "" { + response.BadRequest(c, "base_url is required") + return false + } + if strings.TrimSpace(req.APIKey) == "" { + response.BadRequest(c, "api_key is required") + return false + } + if len(req.APIKey) > 8192 { + response.BadRequest(c, "api_key is too long") + return false + } + return true +} + +func forwardLLMTesterRequest(c *gin.Context, req llmTesterProxyRequest, method, resource string, body io.Reader) { + endpoint, err := buildLLMTesterEndpoint(req.BaseURL, resource) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + + upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), method, endpoint, body) + if err != nil { + response.BadRequest(c, "invalid upstream request") + return + } + upstreamReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(req.APIKey)) + upstreamReq.Header.Set("Accept", "application/json") + upstreamReq.Header.Set("User-Agent", "Sub2API-LLM-Tester/1.0") + upstreamReq.Header.Set("X-Title", "Sub2API LLM Tester") + if method == http.MethodPost { + upstreamReq.Header.Set("Content-Type", "application/json") + } + if origin := c.GetHeader("Origin"); origin != "" { + upstreamReq.Header.Set("HTTP-Referer", origin) + } + + upstreamResp, err := llmTesterHTTPClient.Do(upstreamReq) + if err != nil { + response.Error(c, http.StatusBadGateway, fmt.Sprintf("upstream request failed: %s", err.Error())) + return + } + defer upstreamResp.Body.Close() + + payload, err := readLLMTesterResponseBody(upstreamResp.Body) + if err != nil { + response.Error(c, http.StatusBadGateway, err.Error()) + return + } + + contentType := upstreamResp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + c.Data(upstreamResp.StatusCode, contentType, payload) +} + +func buildLLMTesterEndpoint(baseURL, resource string) (string, error) { + normalized, err := urlvalidator.ValidateHTTPSURL(baseURL, urlvalidator.ValidationOptions{}) + if err != nil { + return "", err + } + parsed, err := url.Parse(normalized) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", errors.New("invalid base_url") + } + if parsed.User != nil { + return "", errors.New("base_url must not include user info") + } + if err := urlvalidator.ValidateResolvedIP(parsed.Hostname()); err != nil { + return "", err + } + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.Path = strings.TrimRight(parsed.Path, "/") + if !llmTesterVersionPathPattern.MatchString(parsed.Path) { + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1" + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/" + strings.TrimLeft(resource, "/") + return parsed.String(), nil +} + +func readLLMTesterResponseBody(body io.Reader) ([]byte, error) { + limited := io.LimitReader(body, llmTesterMaxResponseBytes+1) + payload, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("failed to read upstream response: %w", err) + } + if len(payload) > llmTesterMaxResponseBytes { + return nil, errors.New("upstream response is too large") + } + return payload, nil +} + +func llmTesterSafeDialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + if llmTesterBlockedHost(host) { + return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address} + } + if ip := net.ParseIP(host); ip != nil { + if llmTesterBlockedIP(ip) { + return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address} + } + return llmTesterDialer.DialContext(ctx, network, address) + } + + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + if len(addrs) == 0 { + return nil, &net.AddrError{Err: "no addresses for host", Addr: host} + } + + var lastErr error + for _, addr := range addrs { + if llmTesterBlockedIP(addr.IP) { + lastErr = &net.AddrError{Err: "blocked by SSRF policy", Addr: addr.IP.String()} + continue + } + conn, err := llmTesterDialer.DialContext(ctx, network, net.JoinHostPort(addr.IP.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = &net.AddrError{Err: "no usable addresses", Addr: host} + } + return nil, lastErr +} + +func llmTesterBlockedHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + return host == "" || + host == "localhost" || + strings.HasSuffix(host, ".localhost") || + host == "metadata" || + host == "metadata.google.internal" || + host == "metadata.goog" || + host == "instance-data" || + host == "instance-data.ec2.internal" +} + +func llmTesterBlockedIP(ip net.IP) bool { + if ip == nil { + return true + } + if ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsPrivate() { + return true + } + for _, cidr := range llmTesterBlockedCIDRs { + if cidr.Contains(ip) { + return true + } + } + return false +} + +func mustParseLLMTesterCIDRs(raw []string) []*net.IPNet { + out := make([]*net.IPNet, 0, len(raw)) + for _, value := range raw { + _, cidr, err := net.ParseCIDR(value) + if err != nil { + panic("llm_tester: invalid blocked CIDR " + value + ": " + err.Error()) + } + out = append(out, cidr) + } + return out +} diff --git a/frontend/src/api/__tests__/llmTester.spec.ts b/frontend/src/api/__tests__/llmTester.spec.ts new file mode 100644 index 0000000000..e076f3ff0b --- /dev/null +++ b/frontend/src/api/__tests__/llmTester.spec.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { + extractImageGenerationResult, + extractVideoGenerationResult, + getLLMTesterModelCapabilities, + isLikelyChatCompletionModelId, + parseModelList, +} from '@/api/llmTester' + +describe('LLM tester model filtering', () => { + it('keeps text chat and vision chat models from provider metadata', () => { + const models = parseModelList({ + data: [ + { + id: 'openai/gpt-4o', + name: 'GPT-4o', + architecture: { + modality: 'text+image->text', + input_modalities: ['text', 'image'], + output_modalities: ['text'], + }, + }, + { + id: 'anthropic/claude-sonnet', + architecture: { + modality: 'text->text', + output_modalities: ['text'], + }, + }, + ], + }) + + expect(models.map((model) => model.id)).toEqual([ + 'anthropic/claude-sonnet', + 'openai/gpt-4o', + ]) + }) + + it('keeps image-generation models while removing unsupported utility models', () => { + const models = parseModelList({ + data: [ + { + id: 'gpt-image-2', + architecture: { + modality: 'text+image->image', + output_modalities: ['image'], + }, + }, + { + id: 'text-embedding-3-small', + architecture: { + modality: 'text->embedding', + }, + }, + { + id: 'grok', + architecture: { + modality: 'text->text', + output_modalities: ['text'], + }, + }, + ], + }) + + expect(models.map((model) => model.id)).toEqual(['gpt-image-2', 'grok']) + expect(getLLMTesterModelCapabilities(models[0])).toContain('image_generation') + }) + + it('keeps Grok media models and classifies them by route capability', () => { + const models = parseModelList({ + data: [ + { id: 'grok-imagine', owned_by: 'xai' }, + { id: 'grok-imagine-image', owned_by: 'xai' }, + { id: 'grok-imagine-image-quality', owned_by: 'xai' }, + { id: 'grok-imagine-edit', owned_by: 'xai' }, + { id: 'grok-imagine-video', owned_by: 'xai' }, + { id: 'grok-imagine-video-1.5', owned_by: 'xai' }, + ], + }) + + expect(models.map((model) => model.id)).toEqual([ + 'grok-imagine', + 'grok-imagine-edit', + 'grok-imagine-image', + 'grok-imagine-image-quality', + 'grok-imagine-video', + 'grok-imagine-video-1.5', + ]) + expect(getLLMTesterModelCapabilities(models[0])).toEqual(['image_generation']) + expect(getLLMTesterModelCapabilities(models[4])).toEqual(['video_generation']) + }) + + it('uses id heuristics when simple OpenAI-compatible model rows omit metadata', () => { + expect(isLikelyChatCompletionModelId('gpt-5.4')).toBe(true) + expect(isLikelyChatCompletionModelId('gpt-image-2')).toBe(false) + expect(isLikelyChatCompletionModelId('grok-imagine-video-1.5')).toBe(false) + expect(isLikelyChatCompletionModelId('text-embedding-3-small')).toBe(false) + }) + + it('converts image generation responses into assistant attachments', () => { + const result = extractImageGenerationResult({ + data: [ + { + b64_json: 'abc123', + revised_prompt: 'A bright test image', + }, + ], + }) + + expect(result.text).toContain('Generated 1 image') + expect(result.text).toContain('A bright test image') + expect(result.attachments).toHaveLength(1) + expect(result.attachments[0].dataUrl).toBe('data:image/png;base64,abc123') + }) + + it('converts Responses image_generation_call results into assistant attachments', () => { + const result = extractImageGenerationResult({ + output: [ + { + type: 'image_generation_call', + result: 'a'.repeat(120), + }, + ], + }) + + expect(result.text).toContain('Generated 1 image') + expect(result.attachments).toHaveLength(1) + expect(result.attachments[0].dataUrl).toBe(`data:image/png;base64,${'a'.repeat(120)}`) + }) + + it('keeps generated image URLs from provider responses', () => { + const result = extractImageGenerationResult({ + output: [ + { + type: 'image_generation_call', + image_url: 'https://example.com/generated.png', + }, + ], + }) + + expect(result.attachments).toHaveLength(1) + expect(result.attachments[0].dataUrl).toBe('https://example.com/generated.png') + }) + + it('converts Responses SSE image output events into assistant attachments', () => { + const result = extractImageGenerationResult([ + 'data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","result":"aGVsbG8=","revised_prompt":"draw a cat","output_format":"png"}}', + '', + 'data: {"type":"response.completed","response":{"output":[]}}', + '', + 'data: [DONE]', + '', + ].join('\n')) + + expect(result.text).toContain('Generated 1 image') + expect(result.text).toContain('draw a cat') + expect(result.attachments).toHaveLength(1) + expect(result.attachments[0].dataUrl).toBe('data:image/png;base64,aGVsbG8=') + }) + + it('converts video generation responses into media attachments', () => { + const result = extractVideoGenerationResult({ + id: 'video_req_123', + status: 'completed', + data: [ + { + url: 'https://example.com/generated.mp4', + }, + ], + }) + + expect(result.text).toContain('Generated 1 video') + expect(result.text).toContain('Request ID: video_req_123') + expect(result.attachments).toHaveLength(1) + expect(result.attachments[0].kind).toBe('media') + expect(result.attachments[0].dataUrl).toBe('https://example.com/generated.mp4') + }) +}) diff --git a/frontend/src/api/llmTester.ts b/frontend/src/api/llmTester.ts new file mode 100644 index 0000000000..960d2624ad --- /dev/null +++ b/frontend/src/api/llmTester.ts @@ -0,0 +1,932 @@ +import { buildApiUrl } from '@/api/client' + +export interface LLMTesterProfile { + id: string + name: string + provider: 'openrouter' | 'sub2api' | 'custom' + baseUrl: string + apiKey: string + selectedModel: string + lastFetchedAt?: string +} + +export interface LLMTesterModel { + id: string + name: string + ownedBy?: string + contextLength?: number + raw?: Record +} + +export type LLMTesterModelCapability = 'chat' | 'vision' | 'image_generation' | 'video_generation' + +export interface LLMTesterAttachment { + id: string + name: string + type: string + size: number + kind: 'image' | 'text' | 'media' | 'file' + dataUrl?: string + text?: string +} + +export interface LLMTesterMessage { + id: string + role: 'user' | 'assistant' + content: string + attachments?: LLMTesterAttachment[] +} + +export interface ChatCompletionOptions { + baseUrl: string + apiKey: string + model: string + messages: LLMTesterMessage[] + systemInstruction?: string + temperature?: number + maxTokens?: number + signal?: AbortSignal +} + +export interface ImageGenerationOptions { + baseUrl: string + apiKey: string + model: string + messages: LLMTesterMessage[] + systemInstruction?: string + signal?: AbortSignal +} + +export interface ImageGenerationResult { + text: string + attachments: LLMTesterAttachment[] + raw: unknown +} + +export type MediaGenerationResult = ImageGenerationResult + +interface OpenAIContentTextPart { + type: 'text' + text: string +} + +interface OpenAIContentImagePart { + type: 'image_url' + image_url: { + url: string + } +} + +type OpenAIMessageContent = string | Array + +interface OpenAIChatMessage { + role: 'system' | 'user' | 'assistant' + content: OpenAIMessageContent +} + +export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1' + +export function defaultSub2APIBaseUrl(): string { + return '/v1' +} + +export function normalizeBaseUrl(input: string): string { + const trimmed = input.trim().replace(/\/+$/, '') + if (!trimmed) return '' + if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) return trimmed + return `https://${trimmed}` +} + +export type LLMTesterProxyPath = 'models' | 'chat/completions' | 'images/generations' | 'videos/generations' | 'responses' + +export function buildOpenAIEndpoint(baseUrl: string, path: LLMTesterProxyPath): string { + const normalized = normalizeBaseUrl(baseUrl) + if (!normalized) return '' + const resource = path.replace(/^v\d+\//, '') + if (/\/v\d+$/i.test(normalized)) return `${normalized}/${resource}` + return `${normalized}/v1/${resource}` +} + +function getHeaderSafeSiteTitle(): string { + if (typeof document === 'undefined') return 'Sub2API LLM Tester' + return document.title || 'Sub2API LLM Tester' +} + +function buildHeaders(apiKey: string): HeadersInit { + return { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'X-Title': getHeaderSafeSiteTitle(), + } +} + +function buildJsonHeaders(): HeadersInit { + return { + 'Content-Type': 'application/json', + } +} + +function getObject(value: unknown): Record | undefined { + return value && typeof value === 'object' ? value as Record : undefined +} + +function getString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined +} + +function getNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function getStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value + .map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '') + .filter(Boolean) +} + +export function isLikelyChatCompletionModelId(modelId: string): boolean { + const id = modelId.trim().toLowerCase() + if (!id) return false + if (/(^|[/:-])(?:text-)?embedding/.test(id) || id.includes('embedding')) return false + if (/(^|[/:-])(?:gpt-)?image(?:-|$)/.test(id) || id.includes('/image-')) return false + if (isLikelyImageGenerationModelId(id) || isLikelyVideoGenerationModelId(id)) return false + if (id.includes('dall-e') || id.includes('whisper') || id.includes('tts')) return false + if (id.includes('moderation') || id.includes('omni-moderation')) return false + if (id.includes('transcribe') || id.includes('realtime')) return false + return true +} + +const GROK_IMAGE_MODEL_IDS = new Set([ + 'grok-imagine', + 'grok-imagine-image', + 'grok-imagine-image-quality', + 'grok-imagine-edit', +]) + +const GROK_VIDEO_MODEL_IDS = new Set([ + 'grok-imagine-video', + 'grok-imagine-video-1.5', +]) + +export function isLikelyImageGenerationModelId(modelId: string): boolean { + const id = modelId.trim().toLowerCase() + if (!id) return false + return ( + GROK_IMAGE_MODEL_IDS.has(id) || + /(^|[/:-])(?:gpt-)?image(?:-|$)/.test(id) || + id.includes('/image-') || + id.includes('dall-e') || + id.includes('imagen') + ) +} + +export function isLikelyVideoGenerationModelId(modelId: string): boolean { + const id = modelId.trim().toLowerCase() + if (!id) return false + return GROK_VIDEO_MODEL_IDS.has(id) || id.includes('video-generation') || /(^|[/:-])video(?:-|$)/.test(id) +} + +function splitModalities(value: string): string[] { + return value + .split(/[+,]/) + .map((part) => part.trim().toLowerCase()) + .filter(Boolean) +} + +function getModelModalities(model: LLMTesterModel): { input: string[]; output: string[] } { + const architecture = getObject(model.raw?.architecture) + const input = new Set(getStringArray(architecture?.input_modalities)) + const output = new Set(getStringArray(architecture?.output_modalities)) + + const modality = getString(architecture?.modality)?.toLowerCase() + if (modality?.includes('->')) { + const [inputSide, outputSide] = modality.split('->') + splitModalities(inputSide || '').forEach((item) => input.add(item)) + splitModalities(outputSide || '').forEach((item) => output.add(item)) + } + + return { + input: Array.from(input), + output: Array.from(output), + } +} + +function isKnownUnsupportedModelId(modelId: string): boolean { + const id = modelId.trim().toLowerCase() + return ( + /(^|[/:-])(?:text-)?embedding/.test(id) || + id.includes('embedding') || + id.includes('moderation') || + id.includes('omni-moderation') || + id.includes('whisper') || + id.includes('tts') || + id.includes('transcribe') || + id.includes('realtime') + ) +} + +export function getLLMTesterModelCapabilities(model: LLMTesterModel): LLMTesterModelCapability[] { + const capabilities = new Set() + const modalities = getModelModalities(model) + const hasOutputMetadata = modalities.output.length > 0 + const outputsText = modalities.output.includes('text') + const outputsImage = modalities.output.includes('image') || isLikelyImageGenerationModelId(model.id) + const outputsVideo = modalities.output.includes('video') || isLikelyVideoGenerationModelId(model.id) + const unsupportedByTester = isKnownUnsupportedModelId(model.id) + + if (outputsImage) { + capabilities.add('image_generation') + } + + if (outputsVideo) { + capabilities.add('video_generation') + } + + if (!unsupportedByTester && !outputsImage && !outputsVideo && (!hasOutputMetadata || outputsText)) { + capabilities.add('chat') + } + + if (capabilities.has('chat') && modalities.input.includes('image')) { + capabilities.add('vision') + } + + return Array.from(capabilities) +} + +export function isChatCompletionModel(model: LLMTesterModel): boolean { + return getLLMTesterModelCapabilities(model).includes('chat') +} + +export function isImageGenerationModel(model: LLMTesterModel): boolean { + return getLLMTesterModelCapabilities(model).includes('image_generation') +} + +export function isVideoGenerationModel(model: LLMTesterModel): boolean { + return getLLMTesterModelCapabilities(model).includes('video_generation') +} + +export function isLLMTesterSupportedModel(model: LLMTesterModel): boolean { + const capabilities = getLLMTesterModelCapabilities(model) + return capabilities.includes('chat') || capabilities.includes('image_generation') || capabilities.includes('video_generation') +} + +function extractErrorMessage(payload: unknown, fallback: string): string { + const obj = getObject(payload) + const errorObj = getObject(obj?.error) + return ( + getString(errorObj?.message) || + getString(obj?.message) || + getString(obj?.detail) || + fallback + ) +} + +async function parseResponsePayload(response: Response): Promise { + const contentType = response.headers.get('content-type') || '' + if (contentType.includes('application/json')) return response.json() + const text = await response.text() + try { + return JSON.parse(text) + } catch { + return text + } +} + +function unwrapApiEnvelope(payload: unknown): unknown { + const obj = getObject(payload) + if (!obj || !('code' in obj) || !('data' in obj)) return payload + return obj.data +} + +function shouldUseTesterProxy(baseUrl: string): boolean { + const normalized = normalizeBaseUrl(baseUrl) + if (!normalized || normalized.startsWith('/')) return false + if (typeof window === 'undefined') return true + try { + return new URL(normalized).origin !== window.location.origin + } catch { + return true + } +} + +async function postTesterProxy(path: LLMTesterProxyPath, body: Record, signal?: AbortSignal): Promise { + const response = await fetch(buildApiUrl(`/llm-tester/${path}`), { + method: 'POST', + headers: buildJsonHeaders(), + body: JSON.stringify(body), + signal, + }) + const payload = await parseResponsePayload(response) + if (!response.ok) { + const fallback = path === 'models' + ? `Failed to fetch models (${response.status})` + : path === 'videos/generations' + ? `Video generation failed (${response.status})` + : path === 'images/generations' || path === 'responses' + ? `Image generation failed (${response.status})` + : `Chat request failed (${response.status})` + throw new Error(extractErrorMessage(payload, fallback)) + } + return unwrapApiEnvelope(payload) +} + +export function parseModelList(payload: unknown): LLMTesterModel[] { + const obj = getObject(payload) + const data = Array.isArray(obj?.data) ? obj.data : Array.isArray(payload) ? payload : [] + + return data + .map((item): LLMTesterModel | null => { + const raw = getObject(item) + if (!raw) return null + + const id = getString(raw.id) || getString(raw.name) + if (!id) return null + + const topProvider = getObject(raw.top_provider) + return { + id, + name: getString(raw.name) || id, + ownedBy: getString(raw.owned_by) || getString(raw.ownedBy), + contextLength: getNumber(raw.context_length) || getNumber(raw.contextLength) || getNumber(topProvider?.context_length), + raw, + } + }) + .filter((model): model is LLMTesterModel => model !== null) + .filter(isLLMTesterSupportedModel) + .sort((a, b) => a.id.localeCompare(b.id)) +} + +export async function fetchLLMModels(baseUrl: string, apiKey: string, signal?: AbortSignal): Promise { + const endpoint = buildOpenAIEndpoint(baseUrl, 'models') + if (!endpoint) throw new Error('Base URL is required') + + if (shouldUseTesterProxy(baseUrl)) { + const payload = await postTesterProxy('models', { + base_url: normalizeBaseUrl(baseUrl), + api_key: apiKey, + }, signal) + return parseModelList(payload) + } + + const response = await fetch(endpoint, { + method: 'GET', + headers: buildHeaders(apiKey), + signal, + }) + const payload = await parseResponsePayload(response) + if (!response.ok) { + throw new Error(extractErrorMessage(payload, `Failed to fetch models (${response.status})`)) + } + + return parseModelList(payload) +} + +function inferLanguage(filename: string, type: string): string { + const lower = filename.toLowerCase() + const ext = lower.includes('.') ? lower.split('.').pop() || '' : '' + const byExt: Record = { + js: 'javascript', + jsx: 'jsx', + ts: 'typescript', + tsx: 'tsx', + vue: 'vue', + py: 'python', + go: 'go', + rs: 'rust', + java: 'java', + c: 'c', + cpp: 'cpp', + cs: 'csharp', + html: 'html', + css: 'css', + json: 'json', + md: 'markdown', + sh: 'bash', + sql: 'sql', + yml: 'yaml', + yaml: 'yaml', + xml: 'xml', + toml: 'toml', + csv: 'csv', + } + if (byExt[ext]) return byExt[ext] + if (type.includes('json')) return 'json' + if (type.includes('markdown')) return 'markdown' + if (type.includes('html')) return 'html' + return '' +} + +function formatTextAttachment(attachment: LLMTesterAttachment): string { + const language = inferLanguage(attachment.name, attachment.type) + return [ + `Attached file: ${attachment.name}`, + `\`\`\`${language}`, + attachment.text || '', + '```', + ].join('\n') +} + +function buildImageGenerationPrompt(messages: LLMTesterMessage[], systemInstruction = ''): string { + const latestUserMessage = [...messages].reverse().find((message) => message.role === 'user') + const attachments = latestUserMessage?.attachments || [] + const textAttachments = attachments.filter((attachment) => attachment.kind === 'text' && attachment.text) + const mediaAttachments = attachments.filter((attachment) => attachment.kind !== 'text') + + const sections = [ + systemInstruction.trim(), + latestUserMessage?.content.trim() || '', + ...textAttachments.map(formatTextAttachment), + ...mediaAttachments.map((attachment) => `Attached reference file: ${attachment.name} (${attachment.type || 'unknown type'}, ${attachment.size} bytes).`), + ].filter(Boolean) + + return sections.join('\n\n') +} + +function buildMediaGenerationPrompt(messages: LLMTesterMessage[], systemInstruction = ''): string { + return buildImageGenerationPrompt(messages, systemInstruction) +} + +function buildUserContent(message: LLMTesterMessage): OpenAIMessageContent { + const attachments = message.attachments || [] + const imageAttachments = attachments.filter((attachment) => attachment.kind === 'image' && attachment.dataUrl) + const textAttachments = attachments.filter((attachment) => attachment.kind === 'text' && attachment.text) + const otherAttachments = attachments.filter((attachment) => attachment.kind !== 'image' && attachment.kind !== 'text') + + const textParts = [ + message.content.trim(), + ...textAttachments.map(formatTextAttachment), + ...otherAttachments.map((attachment) => `Attached media: ${attachment.name} (${attachment.type || 'unknown type'}, ${attachment.size} bytes).`), + ].filter(Boolean) + + if (imageAttachments.length === 0) return textParts.join('\n\n') + + const content: Array = [] + content.push({ + type: 'text', + text: textParts.join('\n\n') || 'Please analyze the attached image.', + }) + + for (const attachment of imageAttachments) { + if (!attachment.dataUrl) continue + content.push({ + type: 'image_url', + image_url: { url: attachment.dataUrl }, + }) + } + + return content +} + +export function buildChatCompletionMessages(messages: LLMTesterMessage[], systemInstruction = ''): OpenAIChatMessage[] { + const out: OpenAIChatMessage[] = [] + const system = systemInstruction.trim() + if (system) { + out.push({ role: 'system', content: system }) + } + + for (const message of messages) { + out.push({ + role: message.role, + content: message.role === 'user' ? buildUserContent(message) : message.content, + }) + } + + return out +} + +export function extractChatCompletionText(payload: unknown): string { + const obj = getObject(payload) + const choices = Array.isArray(obj?.choices) ? obj.choices : [] + const firstChoice = getObject(choices[0]) + const message = getObject(firstChoice?.message) + const content = message?.content + + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((part) => { + const partObj = getObject(part) + return getString(partObj?.text) || getString(partObj?.content) || '' + }) + .filter(Boolean) + .join('\n') + } + + const text = getString(firstChoice?.text) + if (text) return text + + return JSON.stringify(payload, null, 2) +} + +export function extractImageGenerationResult(payload: unknown): ImageGenerationResult { + const attachments: LLMTesterAttachment[] = [] + const lines: string[] = [] + + const pushImageAttachment = (rawValue: unknown, index: number) => { + const value = normalizeGeneratedImageValue(rawValue) + if (!value) return + attachments.push({ + id: `generated-image-${Date.now()}-${index}`, + name: `generated-image-${index + 1}.png`, + type: 'image/png', + size: 0, + kind: 'image', + dataUrl: value, + }) + } + + const explicitImageResult = (value: unknown): unknown => { + const text = getString(value) + if (!text) return value + if (/^(?:data:image\/|https?:\/\/)/i.test(text)) return text + return `data:image/png;base64,${text}` + } + + const processOutputItem = (item: unknown) => { + const outputItem = getObject(item) + if (!outputItem) return + const type = getString(outputItem.type) + + if (type === 'image_generation_call') { + const b64 = getString(outputItem.b64_json) + pushImageAttachment(b64 ? `data:image/png;base64,${b64}` : explicitImageResult(outputItem.result) || outputItem.image_url || outputItem.url, attachments.length) + const revisedPrompt = getString(outputItem.revised_prompt) + if (revisedPrompt) { + lines.push(`Revised prompt: ${revisedPrompt}`) + } + } + + const content = Array.isArray(outputItem.content) ? outputItem.content : [] + content.forEach((part) => { + const partObj = getObject(part) + if (!partObj) return + const partType = getString(partObj.type) + const text = getString(partObj.text) + if (text && (partType === 'output_text' || partType === 'text')) { + lines.push(text) + } + const b64 = getString(partObj.b64_json) + pushImageAttachment(b64 ? `data:image/png;base64,${b64}` : explicitImageResult(partObj.result) || partObj.image_url || partObj.url, attachments.length) + }) + + const outputText = getString(outputItem.text) + if (outputText && type !== 'image_generation_call') { + lines.push(outputText) + } + } + + const processPayload = (rawPayload: unknown) => { + const obj = getObject(rawPayload) + if (!obj) return + + if (obj.item) { + processOutputItem(obj.item) + } + if (obj.response) { + processPayload(obj.response) + } + + const data = Array.isArray(obj.data) ? obj.data : [] + data.forEach((item, index) => { + const image = getObject(item) + if (!image) return + + const revisedPrompt = getString(image.revised_prompt) + if (revisedPrompt) { + lines.push(`Revised prompt: ${revisedPrompt}`) + } + + const b64 = getString(image.b64_json) + const url = getString(image.url) + pushImageAttachment(b64 ? `data:image/png;base64,${b64}` : url, index) + }) + + const output = Array.isArray(obj.output) ? obj.output : [] + output.forEach(processOutputItem) + } + + const payloads = typeof payload === 'string' ? parseEventStreamPayload(payload) : [payload] + payloads.forEach(processPayload) + + if (attachments.length > 0) { + lines.unshift(`Generated ${attachments.length} image${attachments.length === 1 ? '' : 's'}.`) + } + + return { + text: lines.join('\n\n') || JSON.stringify(payload, null, 2), + attachments, + raw: payload, + } +} + +function parseEventStreamPayload(payload: string): unknown[] { + const events: unknown[] = [] + const dataLines: string[] = [] + + const flush = () => { + const data = dataLines.join('\n').trim() + dataLines.length = 0 + if (!data || data === '[DONE]') return + try { + events.push(JSON.parse(data)) + } catch { + events.push(data) + } + } + + for (const line of payload.split(/\r?\n/)) { + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trimStart()) + continue + } + if (!line.trim()) { + flush() + } + } + flush() + + if (events.length > 0) return events + try { + return [JSON.parse(payload)] + } catch { + return [] + } +} + +function normalizeGeneratedImageValue(value: unknown): string { + if (typeof value === 'object' && value !== null) { + const obj = getObject(value) + return normalizeGeneratedImageValue(obj?.url || obj?.b64_json || obj?.result) + } + const text = getString(value) + if (!text) return '' + if (/^data:image\//i.test(text)) return text + if (/^https?:\/\//i.test(text)) return text + const compact = text.replace(/\s+/g, '') + if (compact.length > 100 && /^[A-Za-z0-9+/=]+$/.test(compact)) { + return `data:image/png;base64,${compact}` + } + return '' +} + +function normalizeGeneratedMediaValue(value: unknown): string { + if (typeof value === 'object' && value !== null) { + const obj = getObject(value) + return normalizeGeneratedMediaValue( + obj?.url || + obj?.video_url || + obj?.download_url || + obj?.b64_json || + obj?.base64 || + obj?.result + ) + } + const text = getString(value) + if (!text) return '' + if (/^data:video\//i.test(text)) return text + if (/^https?:\/\//i.test(text)) return text + const compact = text.replace(/\s+/g, '') + if (compact.length > 100 && /^[A-Za-z0-9+/=]+$/.test(compact)) { + return `data:video/mp4;base64,${compact}` + } + return '' +} + +export function extractVideoGenerationResult(payload: unknown): MediaGenerationResult { + const attachments: LLMTesterAttachment[] = [] + const lines: string[] = [] + + const pushVideoAttachment = (rawValue: unknown, index: number) => { + const value = normalizeGeneratedMediaValue(rawValue) + if (!value) return + attachments.push({ + id: `generated-video-${Date.now()}-${index}`, + name: `generated-video-${index + 1}.mp4`, + type: 'video/mp4', + size: 0, + kind: 'media', + dataUrl: value, + }) + } + + const processObject = (value: unknown) => { + const obj = getObject(value) + if (!obj) return + + const status = getString(obj.status) + if (status) lines.push(`Status: ${status}`) + const id = getString(obj.id) || getString(obj.request_id) + if (id) lines.push(`Request ID: ${id}`) + const revisedPrompt = getString(obj.revised_prompt) + if (revisedPrompt) lines.push(`Revised prompt: ${revisedPrompt}`) + + pushVideoAttachment(obj, attachments.length) + + const data = Array.isArray(obj.data) ? obj.data : [] + data.forEach((item) => { + processObject(item) + }) + + const output = Array.isArray(obj.output) ? obj.output : [] + output.forEach((item) => { + processObject(item) + }) + + const content = Array.isArray(obj.content) ? obj.content : [] + content.forEach((item) => { + const itemObj = getObject(item) + const text = getString(itemObj?.text) + if (text) lines.push(text) + processObject(item) + }) + } + + const payloads = typeof payload === 'string' ? parseEventStreamPayload(payload) : [payload] + payloads.forEach(processObject) + + const uniqueLines = Array.from(new Set(lines)) + if (attachments.length > 0) { + uniqueLines.unshift(`Generated ${attachments.length} video${attachments.length === 1 ? '' : 's'}.`) + } + + return { + text: uniqueLines.join('\n\n') || JSON.stringify(payload, null, 2), + attachments, + raw: payload, + } +} + +function imageToolModelId(model: string): string { + const trimmed = model.trim() + if (!trimmed) return 'gpt-image-2' + const parts = trimmed.split('/').filter(Boolean) + return parts[parts.length - 1] || trimmed +} + +function imageResponsesDriverModel(model: string): string { + return isLikelyImageGenerationModelId(model) ? 'gpt-5.4' : model +} + +function buildResponsesImageGenerationBody(model: string, prompt: string): Record { + return { + model: imageResponsesDriverModel(model), + stream: true, + tools: [ + { + type: 'image_generation', + model: imageToolModelId(model), + }, + ], + input: [ + { + role: 'user', + content: [ + { + type: 'input_text', + text: prompt, + }, + ], + }, + ], + } +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} + +async function postOpenAIResource( + baseUrl: string, + apiKey: string, + path: LLMTesterProxyPath, + body: Record, + signal?: AbortSignal +): Promise { + const endpoint = buildOpenAIEndpoint(baseUrl, path) + if (!endpoint) throw new Error('Base URL is required') + + if (shouldUseTesterProxy(baseUrl)) { + return postTesterProxy(path, { + base_url: normalizeBaseUrl(baseUrl), + api_key: apiKey, + payload: body, + }, signal) + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: buildHeaders(apiKey), + body: JSON.stringify(body), + signal, + }) + const payload = await parseResponsePayload(response) + if (!response.ok) { + const fallback = path === 'chat/completions' + ? `Chat request failed (${response.status})` + : path === 'videos/generations' + ? `Video generation failed (${response.status})` + : `Image generation failed (${response.status})` + throw new Error(extractErrorMessage(payload, fallback)) + } + + return payload +} + +export async function sendLLMChatCompletion(options: ChatCompletionOptions): Promise<{ text: string; raw: unknown }> { + const endpoint = buildOpenAIEndpoint(options.baseUrl, 'chat/completions') + if (!endpoint) throw new Error('Base URL is required') + + const body: Record = { + model: options.model, + messages: buildChatCompletionMessages(options.messages, options.systemInstruction), + stream: false, + } + + if (typeof options.temperature === 'number' && Number.isFinite(options.temperature)) { + body.temperature = options.temperature + } + if (typeof options.maxTokens === 'number' && Number.isFinite(options.maxTokens) && options.maxTokens > 0) { + body.max_tokens = Math.floor(options.maxTokens) + } + + if (shouldUseTesterProxy(options.baseUrl)) { + const payload = await postTesterProxy('chat/completions', { + base_url: normalizeBaseUrl(options.baseUrl), + api_key: options.apiKey, + payload: body, + }, options.signal) + return { + text: extractChatCompletionText(payload), + raw: payload, + } + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: buildHeaders(options.apiKey), + body: JSON.stringify(body), + signal: options.signal, + }) + const payload = await parseResponsePayload(response) + if (!response.ok) { + throw new Error(extractErrorMessage(payload, `Chat request failed (${response.status})`)) + } + + return { + text: extractChatCompletionText(payload), + raw: payload, + } +} + +export async function sendLLMImageGeneration(options: ImageGenerationOptions): Promise { + const prompt = buildImageGenerationPrompt(options.messages, options.systemInstruction) + if (!prompt) throw new Error('Prompt is required for image generation') + + const body: Record = { + model: options.model, + prompt, + n: 1, + } + if (/^gpt-image-/i.test(imageToolModelId(options.model))) { + body.stream = true + } + + try { + const payload = await postOpenAIResource(options.baseUrl, options.apiKey, 'images/generations', body, options.signal) + return extractImageGenerationResult(payload) + } catch (primaryError) { + if (isAbortError(primaryError)) throw primaryError + + try { + const fallbackPayload = await postOpenAIResource( + options.baseUrl, + options.apiKey, + 'responses', + buildResponsesImageGenerationBody(options.model, prompt), + options.signal + ) + const fallbackResult = extractImageGenerationResult(fallbackPayload) + if (fallbackResult.attachments.length > 0) return fallbackResult + throw new Error('Responses image tool returned no image output') + } catch (fallbackError) { + if (isAbortError(fallbackError)) throw fallbackError + const primaryMessage = primaryError instanceof Error ? primaryError.message : 'Image endpoint failed' + const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : 'Responses fallback failed' + throw new Error(`${primaryMessage}; responses fallback failed: ${fallbackMessage}`) + } + } +} + +export async function sendLLMVideoGeneration(options: ImageGenerationOptions): Promise { + const prompt = buildMediaGenerationPrompt(options.messages, options.systemInstruction) + if (!prompt) throw new Error('Prompt is required for video generation') + + const body: Record = { + model: options.model, + prompt, + } + + const payload = await postOpenAIResource(options.baseUrl, options.apiKey, 'videos/generations', body, options.signal) + return extractVideoGenerationResult(payload) +} diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 3d7f1604c7..8d48591fa0 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -278,6 +278,21 @@ const KeyIcon = { ) } +const TesterIcon = { + render: () => + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z' + }) + ] + ) +} + const ChartIcon = { render: () => h( @@ -666,6 +681,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] { } items.push( { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, + { path: '/llm-tester', label: t('nav.llmTester'), icon: TesterIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/available-channels', label: t('nav.availableChannels'), icon: ChannelIcon, hideInSimpleMode: true, featureFlag: flagAvailableChannels }, { path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon, featureFlag: flagChannelMonitor }, @@ -773,6 +789,7 @@ const adminNavItems = computed((): NavItem[] => { if (authStore.isSimpleMode) { const filtered = visible.filter(item => !item.hideInSimpleMode) filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }) + filtered.push({ path: '/llm-tester', label: t('nav.llmTester'), icon: TesterIcon }) filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) for (const cm of customMenuItemsForAdmin.value) { filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index 244c6c8db2..b430d99f76 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -141,7 +141,13 @@ const xaiModels = [ 'grok-latest', 'grok-build', 'grok-4.20-reasoning', - 'grok-4.20-non-reasoning' + 'grok-4.20-non-reasoning', + 'grok-imagine', + 'grok-imagine-image', + 'grok-imagine-image-quality', + 'grok-imagine-edit', + 'grok-imagine-video', + 'grok-imagine-video-1.5' ] // Cohere @@ -286,7 +292,13 @@ const grokPresetMappings = [ { label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, { label: 'Build 0.1', from: 'grok-build', to: 'grok-build-0.1', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' }, { label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, - { label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' } + { label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' }, + { label: 'Imagine', from: 'grok-imagine', to: 'grok-imagine', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-300' }, + { label: 'Image', from: 'grok-imagine-image', to: 'grok-imagine-image', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-300' }, + { label: 'Image Quality', from: 'grok-imagine-image-quality', to: 'grok-imagine-image-quality', color: 'bg-fuchsia-100 text-fuchsia-700 hover:bg-fuchsia-200 dark:bg-fuchsia-900/30 dark:text-fuchsia-300' }, + { label: 'Edit', from: 'grok-imagine-edit', to: 'grok-imagine-edit', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-300' }, + { label: 'Video', from: 'grok-imagine-video', to: 'grok-imagine-video', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300' }, + { label: 'Video 1.5', from: 'grok-imagine-video-1.5', to: 'grok-imagine-video-1.5', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-300' } ] // Antigravity 预设映射(支持通配符) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 941c2d71ae..4b31f6fa5e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -394,6 +394,7 @@ export default { dashboard: 'Dashboard', announcements: 'Announcements', apiKeys: 'API Keys', + llmTester: 'LLM Tester', usage: 'Usage', redeem: 'Redeem', affiliate: 'Affiliate Rebates', @@ -1129,6 +1130,79 @@ export default { } }, + llmTester: { + title: 'LLM Tester', + description: 'Save OpenAI-compatible endpoints, fetch models, and run multimodal chat tests', + profile: 'Profile', + newProfile: 'New profile', + provider: 'Provider', + customProvider: 'Custom', + profileNamePlaceholder: 'OpenRouter staging', + baseUrl: 'Base URL', + apiKey: 'API Key', + showKey: 'Show key', + hideKey: 'Hide key', + model: 'Model', + selectModel: 'Select a model', + searchModels: 'Search fetched models...', + fetchModels: 'Fetch Models', + modelCount: '{count} models fetched', + lastFetched: 'Fetched {time}', + localStorageNotice: 'Keys stay in this browser', + savedProfiles: 'Saved Profiles', + noProfiles: 'No saved profiles', + requestOptions: 'Request Options', + temperature: 'Temperature', + maxTokens: 'Max Tokens', + systemInstruction: 'System Instruction', + systemInstructionPlaceholder: 'Optional', + chat: 'Chat', + noModelSelected: 'No model selected', + clearChat: 'Clear', + cancel: 'Cancel', + emptyChatTitle: 'Ready for a test message', + emptyChatDescription: 'Select a model, attach images or code, and send a prompt.', + you: 'You', + assistant: 'Assistant', + thinking: 'Thinking...', + attachFiles: 'Attach files', + openAttachment: 'Open', + downloadAttachment: 'Download', + removeAttachment: 'Remove', + promptPlaceholder: 'Ask anything, paste code, or attach an image...', + imagePromptPlaceholder: 'Describe the image you want to generate...', + videoPromptPlaceholder: 'Describe the video you want to generate...', + send: 'Send', + profileSaved: 'Profile saved', + profileDeleted: 'Profile deleted', + modelsFetched: 'Fetched {count} models', + capabilities: { + chat: 'Chat', + vision: 'Vision chat', + imageGeneration: 'Image generation', + videoGeneration: 'Video generation' + }, + errors: { + loadFailed: 'Failed to load saved profiles', + saveFailed: 'Failed to save profiles', + nameRequired: 'Profile name is required', + baseUrlRequired: 'Base URL is required', + apiKeyRequired: 'API Key is required', + modelsFailed: 'Failed to fetch models', + chatFailed: 'Chat request failed', + unsupportedModel: 'This model is not supported by the tester yet.', + unsupportedChatModel: 'This tester only supports text chat models. Pick a chat-capable model.', + imagePromptRequired: 'Add a text prompt before generating an image.', + videoPromptRequired: 'Add a text prompt before generating a video.', + openUnavailable: 'Unable to open this attachment', + downloadUnavailable: 'Unable to download this attachment', + cancelled: 'Request cancelled', + imageTooLarge: '{name} is larger than the 5 MB image limit', + textTooLarge: '{name} is larger than the 240 KB text limit', + fileReadFailed: 'Failed to read {name}' + } + }, + affiliate: { title: 'Affiliate Rebates', description: 'Invite new users and convert your rebate quota into account balance', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c9d2c6fbd4..5f38f67a00 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -394,6 +394,7 @@ export default { dashboard: '仪表盘', announcements: '公告', apiKeys: 'API 密钥', + llmTester: 'LLM 测试器', usage: '使用记录', redeem: '兑换', affiliate: '邀请返利', @@ -1133,6 +1134,79 @@ export default { } }, + llmTester: { + title: 'LLM 测试器', + description: '保存 OpenAI 兼容端点,拉取模型列表,并进行多模态聊天测试', + profile: '配置', + newProfile: '新配置', + provider: '服务商', + customProvider: '自定义', + profileNamePlaceholder: 'OpenRouter 测试', + baseUrl: 'Base URL', + apiKey: 'API Key', + showKey: '显示密钥', + hideKey: '隐藏密钥', + model: '模型', + selectModel: '选择模型', + searchModels: '搜索已拉取模型...', + fetchModels: '拉取模型', + modelCount: '已拉取 {count} 个模型', + lastFetched: '拉取时间 {time}', + localStorageNotice: '密钥仅保存在此浏览器', + savedProfiles: '已保存配置', + noProfiles: '暂无保存配置', + requestOptions: '请求选项', + temperature: 'Temperature', + maxTokens: 'Max Tokens', + systemInstruction: 'System Instruction', + systemInstructionPlaceholder: '可选', + chat: '聊天', + noModelSelected: '未选择模型', + clearChat: '清空', + cancel: '取消', + emptyChatTitle: '可以开始测试', + emptyChatDescription: '选择模型,附加图片或代码,然后发送提示词。', + you: '你', + assistant: '助手', + thinking: '思考中...', + attachFiles: '附加文件', + openAttachment: '打开', + downloadAttachment: '下载', + removeAttachment: '移除', + promptPlaceholder: '输入问题、粘贴代码,或附加图片...', + imagePromptPlaceholder: '描述你想生成的图片...', + videoPromptPlaceholder: '描述你想生成的视频...', + send: '发送', + profileSaved: '配置已保存', + profileDeleted: '配置已删除', + modelsFetched: '已拉取 {count} 个模型', + capabilities: { + chat: '聊天', + vision: '视觉聊天', + imageGeneration: '图片生成', + videoGeneration: '视频生成' + }, + errors: { + loadFailed: '加载保存配置失败', + saveFailed: '保存配置失败', + nameRequired: '请输入配置名称', + baseUrlRequired: '请输入 Base URL', + apiKeyRequired: '请输入 API Key', + modelsFailed: '拉取模型失败', + chatFailed: '聊天请求失败', + unsupportedModel: '此测试器暂不支持该模型。', + unsupportedChatModel: '此测试器仅支持文本聊天模型,请选择可聊天的模型。', + imagePromptRequired: '生成图片前请先输入文本提示词。', + videoPromptRequired: '生成视频前请先输入文本提示词。', + openUnavailable: '无法打开此附件', + downloadUnavailable: '无法下载此附件', + cancelled: '请求已取消', + imageTooLarge: '{name} 超过 5 MB 图片限制', + textTooLarge: '{name} 超过 240 KB 文本限制', + fileReadFailed: '读取 {name} 失败' + } + }, + affiliate: { title: '邀请返利', description: '邀请新用户注册,并将返利额度转入账户余额', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8721efd70a..069371d841 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -205,6 +205,18 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'keys.description' } }, + { + path: '/llm-tester', + name: 'LLMTester', + component: () => import('@/views/user/LLMTesterView.vue'), + meta: { + requiresAuth: false, + requiresAdmin: false, + title: 'LLM Tester', + titleKey: 'llmTester.title', + descriptionKey: 'llmTester.description' + } + }, { path: '/usage', name: 'Usage', @@ -690,7 +702,7 @@ let authInitialized = false const navigationLoading = useNavigationLoadingState() // 延迟初始化预加载,传入 router 实例 let routePrefetch: ReturnType | null = null -const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/payment/airwallex', '/legal'] +const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/llm-tester', '/setup', '/payment/result', '/payment/airwallex', '/legal'] const BACKEND_MODE_CALLBACK_PATHS = [ '/auth/callback', '/auth/linuxdo/callback', diff --git a/frontend/src/views/user/LLMTesterView.vue b/frontend/src/views/user/LLMTesterView.vue new file mode 100644 index 0000000000..56eab507b2 --- /dev/null +++ b/frontend/src/views/user/LLMTesterView.vue @@ -0,0 +1,1157 @@ + + + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3877070453..430ed952b6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,6 +2,10 @@ import { defineConfig, loadEnv, Plugin } from 'vite' import vue from '@vitejs/plugin-vue' import checker from 'vite-plugin-checker' import { resolve } from 'path' +import { Buffer } from 'node:buffer' +import { lookup } from 'node:dns/promises' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { isIP } from 'node:net' /** * Vite 插件:开发模式下注入公开配置到 index.html @@ -34,6 +38,203 @@ function injectPublicSettings(backendUrl: string): Plugin { } } +const LLM_TESTER_MAX_BODY_BYTES = 12 * 1024 * 1024 +const LLM_TESTER_TIMEOUT_MS = 300000 + +function llmTesterDevProxy(): Plugin { + return { + name: 'llm-tester-dev-proxy', + apply: 'serve', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const pathname = new URL(req.url || '/', 'http://localhost').pathname + if (req.method !== 'POST' || !pathname.startsWith('/api/v1/llm-tester/')) { + next() + return + } + + try { + const body = await readDevProxyJson(req) + const route = pathname.slice('/api/v1/llm-tester/'.length) + if (route === 'models') { + await forwardDevLLMTesterRequest(res, body, 'GET', 'models') + return + } + if (route === 'chat/completions') { + await forwardDevLLMTesterRequest(res, body, 'POST', 'chat/completions') + return + } + if (route === 'images/generations') { + await forwardDevLLMTesterRequest(res, body, 'POST', 'images/generations') + return + } + if (route === 'responses') { + await forwardDevLLMTesterRequest(res, body, 'POST', 'responses') + return + } + next() + } catch (error) { + writeDevProxyError(res, 502, devProxyErrorMessage(error)) + } + }) + } + } +} + +async function readDevProxyJson(req: IncomingMessage): Promise> { + const chunks: Buffer[] = [] + let total = 0 + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + total += buffer.length + if (total > LLM_TESTER_MAX_BODY_BYTES) { + throw new Error('request body is too large') + } + chunks.push(buffer) + } + + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + throw new Error('invalid request body') + } +} + +async function forwardDevLLMTesterRequest( + res: ServerResponse, + body: Record, + method: 'GET' | 'POST', + resource: 'models' | 'chat/completions' | 'images/generations' | 'responses' +) { + const baseUrl = String(body.base_url || '').trim() + const apiKey = String(body.api_key || '').trim() + if (!baseUrl) { + writeDevProxyError(res, 400, 'base_url is required') + return + } + if (!apiKey) { + writeDevProxyError(res, 400, 'api_key is required') + return + } + if (apiKey.length > 8192) { + writeDevProxyError(res, 400, 'api_key is too long') + return + } + if (method === 'POST' && !body.payload) { + writeDevProxyError(res, 400, 'payload is required') + return + } + + const endpoint = await buildDevLLMTesterEndpoint(baseUrl, resource) + const upstream = await fetch(endpoint, { + method, + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'Sub2API-LLM-Tester/1.0', + 'X-Title': 'Sub2API LLM Tester' + }, + body: method === 'POST' ? JSON.stringify(body.payload || {}) : undefined, + signal: AbortSignal.timeout(LLM_TESTER_TIMEOUT_MS) + }) + const payload = Buffer.from(await upstream.arrayBuffer()) + if (payload.length > LLM_TESTER_MAX_BODY_BYTES) { + writeDevProxyError(res, 502, 'upstream response is too large') + return + } + + res.statusCode = upstream.status + res.setHeader('Content-Type', upstream.headers.get('content-type') || 'application/json') + res.end(payload) +} + +async function buildDevLLMTesterEndpoint(baseUrl: string, resource: 'models' | 'chat/completions' | 'images/generations' | 'responses'): Promise { + const url = new URL(baseUrl.replace(/\/+$/, '')) + if (url.protocol !== 'https:') { + throw new Error('base_url must use https') + } + if (url.username || url.password) { + throw new Error('base_url must not include user info') + } + await assertDevProxyPublicHost(url.hostname) + url.search = '' + url.hash = '' + if (!/\/v\d+$/i.test(url.pathname)) { + url.pathname = `${url.pathname.replace(/\/+$/, '')}/v1` + } + url.pathname = `${url.pathname.replace(/\/+$/, '')}/${resource}` + return url.toString() +} + +async function assertDevProxyPublicHost(hostname: string) { + const host = hostname.trim().toLowerCase() + if (isBlockedDevProxyHost(host)) { + throw new Error(`host is not allowed: ${hostname}`) + } + if (isIP(host)) { + if (isBlockedDevProxyIP(host)) throw new Error(`host is not allowed: ${hostname}`) + return + } + const addrs = await lookup(host, { all: true, verbatim: false }) + if (!addrs.length) { + throw new Error(`host did not resolve: ${hostname}`) + } + for (const addr of addrs) { + if (isBlockedDevProxyIP(addr.address)) { + throw new Error(`resolved ip is not allowed: ${addr.address}`) + } + } +} + +function isBlockedDevProxyHost(host: string): boolean { + return ( + !host || + host === 'localhost' || + host.endsWith('.localhost') || + host === 'metadata' || + host === 'metadata.google.internal' || + host === 'metadata.goog' || + host === 'instance-data' || + host === 'instance-data.ec2.internal' + ) +} + +function isBlockedDevProxyIP(address: string): boolean { + if (address.includes(':')) { + const lower = address.toLowerCase() + return lower === '::' || lower === '::1' || lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80') + } + const parts = address.split('.').map((part) => Number(part)) + if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return true + const [a, b] = parts + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ) +} + +function writeDevProxyError(res: ServerResponse, status: number, message: string) { + res.statusCode = status + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ code: status, message })) +} + +function devProxyErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : 'LLM tester proxy failed' + const cause = error instanceof Error ? (error as Error & { cause?: unknown }).cause : undefined + if (cause instanceof Error && cause.message && cause.message !== message) { + return `${message}: ${cause.message}` + } + return message || 'LLM tester proxy failed' +} + export default defineConfig(({ mode }) => { // 加载环境变量 const env = loadEnv(mode, process.cwd(), '') @@ -46,7 +247,8 @@ export default defineConfig(({ mode }) => { checker({ vueTsc: true }), - injectPublicSettings(backendUrl) + injectPublicSettings(backendUrl), + llmTesterDevProxy() ], resolve: { alias: {