diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 35f2c3949e..3d86373779 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -107,7 +107,6 @@ 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 deleted file mode 100644 index dd347177ee..0000000000 --- a/backend/internal/server/routes/llm_tester.go +++ /dev/null @@ -1,310 +0,0 @@ -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 func() { _ = 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 deleted file mode 100644 index e076f3ff0b..0000000000 --- a/frontend/src/api/__tests__/llmTester.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index 960d2624ad..0000000000 --- a/frontend/src/api/llmTester.ts +++ /dev/null @@ -1,932 +0,0 @@ -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 8d48591fa0..3d7f1604c7 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -278,21 +278,6 @@ 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( @@ -681,7 +666,6 @@ 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 }, @@ -789,7 +773,6 @@ 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 b430d99f76..244c6c8db2 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -141,13 +141,7 @@ const xaiModels = [ 'grok-latest', 'grok-build', 'grok-4.20-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' + 'grok-4.20-non-reasoning' ] // Cohere @@ -292,13 +286,7 @@ 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: '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' } + { 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' } ] // Antigravity 预设映射(支持通配符) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4b31f6fa5e..941c2d71ae 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -394,7 +394,6 @@ export default { dashboard: 'Dashboard', announcements: 'Announcements', apiKeys: 'API Keys', - llmTester: 'LLM Tester', usage: 'Usage', redeem: 'Redeem', affiliate: 'Affiliate Rebates', @@ -1130,79 +1129,6 @@ 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 5f38f67a00..c9d2c6fbd4 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -394,7 +394,6 @@ export default { dashboard: '仪表盘', announcements: '公告', apiKeys: 'API 密钥', - llmTester: 'LLM 测试器', usage: '使用记录', redeem: '兑换', affiliate: '邀请返利', @@ -1134,79 +1133,6 @@ 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 069371d841..8721efd70a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -205,18 +205,6 @@ 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', @@ -702,7 +690,7 @@ let authInitialized = false const navigationLoading = useNavigationLoadingState() // 延迟初始化预加载,传入 router 实例 let routePrefetch: ReturnType | null = null -const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/llm-tester', '/setup', '/payment/result', '/payment/airwallex', '/legal'] +const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/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 deleted file mode 100644 index 56eab507b2..0000000000 --- a/frontend/src/views/user/LLMTesterView.vue +++ /dev/null @@ -1,1157 +0,0 @@ - - - - - diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 430ed952b6..3877070453 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,10 +2,6 @@ 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 @@ -38,203 +34,6 @@ 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(), '') @@ -247,8 +46,7 @@ export default defineConfig(({ mode }) => { checker({ vueTsc: true }), - injectPublicSettings(backendUrl), - llmTesterDevProxy() + injectPublicSettings(backendUrl) ], resolve: { alias: {