Revert "feat: add LLM media tester"

This reverts commit a34d4967e6.
This commit is contained in:
Heatherm Huang
2026-07-01 16:08:21 +08:00
parent c9fb221a31
commit f77cf6b477
11 changed files with 4 additions and 2973 deletions
-1
View File
@@ -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)
@@ -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
}
@@ -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')
})
})
-932
View File
@@ -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<string, unknown>
}
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<OpenAIContentTextPart | OpenAIContentImagePart>
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<string, unknown> | undefined {
return value && typeof value === 'object' ? value as Record<string, unknown> : 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<LLMTesterModelCapability>()
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<unknown> {
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<string, unknown>, signal?: AbortSignal): Promise<unknown> {
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<LLMTesterModel[]> {
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<string, string> = {
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<OpenAIContentTextPart | OpenAIContentImagePart> = []
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<string, unknown> {
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<string, unknown>,
signal?: AbortSignal
): Promise<unknown> {
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<string, unknown> = {
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<ImageGenerationResult> {
const prompt = buildImageGenerationPrompt(options.messages, options.systemInstruction)
if (!prompt) throw new Error('Prompt is required for image generation')
const body: Record<string, unknown> = {
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<MediaGenerationResult> {
const prompt = buildMediaGenerationPrompt(options.messages, options.systemInstruction)
if (!prompt) throw new Error('Prompt is required for video generation')
const body: Record<string, unknown> = {
model: options.model,
prompt,
}
const payload = await postOpenAIResource(options.baseUrl, options.apiKey, 'videos/generations', body, options.signal)
return extractVideoGenerationResult(payload)
}
@@ -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 })
+2 -14
View File
@@ -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 预设映射(支持通配符)
-74
View File
@@ -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',
-74
View File
@@ -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: '邀请新用户注册,并将返利额度转入账户余额',
+1 -13
View File
@@ -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<typeof useRoutePrefetch> | 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',
File diff suppressed because it is too large Load Diff
+1 -203
View File
@@ -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<Record<string, any>> {
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<string, any>,
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<string> {
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: {