mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat: add LLM media tester
This commit is contained in:
@@ -107,6 +107,7 @@ func registerRoutes(
|
||||
v1 := r.Group("/api/v1")
|
||||
|
||||
// 注册各模块路由
|
||||
routes.RegisterLLMTesterRoutes(v1)
|
||||
routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient, settingService)
|
||||
routes.RegisterUserRoutes(v1, h, jwtAuth, settingService)
|
||||
routes.RegisterAdminRoutes(v1, h, adminAuth, settingService)
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
llmTesterMaxRequestBytes = 12 << 20
|
||||
llmTesterMaxResponseBytes = 12 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
llmTesterVersionPathPattern = regexp.MustCompile(`/v\d+$`)
|
||||
llmTesterHTTPClient = &http.Client{
|
||||
Timeout: 300 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: llmTesterSafeDialContext,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 240 * time.Second,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
llmTesterDialer = &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
llmTesterBlockedCIDRs = mustParseLLMTesterCIDRs([]string{
|
||||
"0.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"::/128",
|
||||
"::1/128",
|
||||
"fc00::/7",
|
||||
"fe80::/10",
|
||||
})
|
||||
)
|
||||
|
||||
type llmTesterProxyRequest struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
func RegisterLLMTesterRoutes(v1 *gin.RouterGroup) {
|
||||
tester := v1.Group("/llm-tester")
|
||||
{
|
||||
tester.POST("/models", llmTesterProxyModels)
|
||||
tester.POST("/chat/completions", llmTesterProxyChatCompletions)
|
||||
tester.POST("/images/generations", llmTesterProxyImageGenerations)
|
||||
tester.POST("/videos/generations", llmTesterProxyVideoGenerations)
|
||||
tester.POST("/responses", llmTesterProxyResponses)
|
||||
}
|
||||
}
|
||||
|
||||
func llmTesterProxyModels(c *gin.Context) {
|
||||
var req llmTesterProxyRequest
|
||||
if !bindLLMTesterProxyRequest(c, &req) {
|
||||
return
|
||||
}
|
||||
forwardLLMTesterRequest(c, req, http.MethodGet, "models", nil)
|
||||
}
|
||||
|
||||
func llmTesterProxyChatCompletions(c *gin.Context) {
|
||||
var req llmTesterProxyRequest
|
||||
if !bindLLMTesterProxyRequest(c, &req) {
|
||||
return
|
||||
}
|
||||
if len(bytes.TrimSpace(req.Payload)) == 0 {
|
||||
response.BadRequest(c, "payload is required")
|
||||
return
|
||||
}
|
||||
forwardLLMTesterRequest(c, req, http.MethodPost, "chat/completions", bytes.NewReader(req.Payload))
|
||||
}
|
||||
|
||||
func llmTesterProxyImageGenerations(c *gin.Context) {
|
||||
var req llmTesterProxyRequest
|
||||
if !bindLLMTesterProxyRequest(c, &req) {
|
||||
return
|
||||
}
|
||||
if len(bytes.TrimSpace(req.Payload)) == 0 {
|
||||
response.BadRequest(c, "payload is required")
|
||||
return
|
||||
}
|
||||
forwardLLMTesterRequest(c, req, http.MethodPost, "images/generations", bytes.NewReader(req.Payload))
|
||||
}
|
||||
|
||||
func llmTesterProxyVideoGenerations(c *gin.Context) {
|
||||
var req llmTesterProxyRequest
|
||||
if !bindLLMTesterProxyRequest(c, &req) {
|
||||
return
|
||||
}
|
||||
if len(bytes.TrimSpace(req.Payload)) == 0 {
|
||||
response.BadRequest(c, "payload is required")
|
||||
return
|
||||
}
|
||||
forwardLLMTesterRequest(c, req, http.MethodPost, "videos/generations", bytes.NewReader(req.Payload))
|
||||
}
|
||||
|
||||
func llmTesterProxyResponses(c *gin.Context) {
|
||||
var req llmTesterProxyRequest
|
||||
if !bindLLMTesterProxyRequest(c, &req) {
|
||||
return
|
||||
}
|
||||
if len(bytes.TrimSpace(req.Payload)) == 0 {
|
||||
response.BadRequest(c, "payload is required")
|
||||
return
|
||||
}
|
||||
forwardLLMTesterRequest(c, req, http.MethodPost, "responses", bytes.NewReader(req.Payload))
|
||||
}
|
||||
|
||||
func bindLLMTesterProxyRequest(c *gin.Context, req *llmTesterProxyRequest) bool {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, llmTesterMaxRequestBytes)
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(req); err != nil {
|
||||
response.BadRequest(c, "invalid request body")
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(req.BaseURL) == "" {
|
||||
response.BadRequest(c, "base_url is required")
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(req.APIKey) == "" {
|
||||
response.BadRequest(c, "api_key is required")
|
||||
return false
|
||||
}
|
||||
if len(req.APIKey) > 8192 {
|
||||
response.BadRequest(c, "api_key is too long")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func forwardLLMTesterRequest(c *gin.Context, req llmTesterProxyRequest, method, resource string, body io.Reader) {
|
||||
endpoint, err := buildLLMTesterEndpoint(req.BaseURL, resource)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), method, endpoint, body)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "invalid upstream request")
|
||||
return
|
||||
}
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(req.APIKey))
|
||||
upstreamReq.Header.Set("Accept", "application/json")
|
||||
upstreamReq.Header.Set("User-Agent", "Sub2API-LLM-Tester/1.0")
|
||||
upstreamReq.Header.Set("X-Title", "Sub2API LLM Tester")
|
||||
if method == http.MethodPost {
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if origin := c.GetHeader("Origin"); origin != "" {
|
||||
upstreamReq.Header.Set("HTTP-Referer", origin)
|
||||
}
|
||||
|
||||
upstreamResp, err := llmTesterHTTPClient.Do(upstreamReq)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadGateway, fmt.Sprintf("upstream request failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
defer upstreamResp.Body.Close()
|
||||
|
||||
payload, err := readLLMTesterResponseBody(upstreamResp.Body)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
contentType := upstreamResp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
c.Data(upstreamResp.StatusCode, contentType, payload)
|
||||
}
|
||||
|
||||
func buildLLMTesterEndpoint(baseURL, resource string) (string, error) {
|
||||
normalized, err := urlvalidator.ValidateHTTPSURL(baseURL, urlvalidator.ValidationOptions{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parsed, err := url.Parse(normalized)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", errors.New("invalid base_url")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return "", errors.New("base_url must not include user info")
|
||||
}
|
||||
if err := urlvalidator.ValidateResolvedIP(parsed.Hostname()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
if !llmTesterVersionPathPattern.MatchString(parsed.Path) {
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1"
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/" + strings.TrimLeft(resource, "/")
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func readLLMTesterResponseBody(body io.Reader) ([]byte, error) {
|
||||
limited := io.LimitReader(body, llmTesterMaxResponseBytes+1)
|
||||
payload, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
||||
}
|
||||
if len(payload) > llmTesterMaxResponseBytes {
|
||||
return nil, errors.New("upstream response is too large")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func llmTesterSafeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if llmTesterBlockedHost(host) {
|
||||
return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address}
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if llmTesterBlockedIP(ip) {
|
||||
return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address}
|
||||
}
|
||||
return llmTesterDialer.DialContext(ctx, network, address)
|
||||
}
|
||||
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, &net.AddrError{Err: "no addresses for host", Addr: host}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, addr := range addrs {
|
||||
if llmTesterBlockedIP(addr.IP) {
|
||||
lastErr = &net.AddrError{Err: "blocked by SSRF policy", Addr: addr.IP.String()}
|
||||
continue
|
||||
}
|
||||
conn, err := llmTesterDialer.DialContext(ctx, network, net.JoinHostPort(addr.IP.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = &net.AddrError{Err: "no usable addresses", Addr: host}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func llmTesterBlockedHost(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
return host == "" ||
|
||||
host == "localhost" ||
|
||||
strings.HasSuffix(host, ".localhost") ||
|
||||
host == "metadata" ||
|
||||
host == "metadata.google.internal" ||
|
||||
host == "metadata.goog" ||
|
||||
host == "instance-data" ||
|
||||
host == "instance-data.ec2.internal"
|
||||
}
|
||||
|
||||
func llmTesterBlockedIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
if ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
for _, cidr := range llmTesterBlockedCIDRs {
|
||||
if cidr.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mustParseLLMTesterCIDRs(raw []string) []*net.IPNet {
|
||||
out := make([]*net.IPNet, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
_, cidr, err := net.ParseCIDR(value)
|
||||
if err != nil {
|
||||
panic("llm_tester: invalid blocked CIDR " + value + ": " + err.Error())
|
||||
}
|
||||
out = append(out, cidr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
extractImageGenerationResult,
|
||||
extractVideoGenerationResult,
|
||||
getLLMTesterModelCapabilities,
|
||||
isLikelyChatCompletionModelId,
|
||||
parseModelList,
|
||||
} from '@/api/llmTester'
|
||||
|
||||
describe('LLM tester model filtering', () => {
|
||||
it('keeps text chat and vision chat models from provider metadata', () => {
|
||||
const models = parseModelList({
|
||||
data: [
|
||||
{
|
||||
id: 'openai/gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
architecture: {
|
||||
modality: 'text+image->text',
|
||||
input_modalities: ['text', 'image'],
|
||||
output_modalities: ['text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'anthropic/claude-sonnet',
|
||||
architecture: {
|
||||
modality: 'text->text',
|
||||
output_modalities: ['text'],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual([
|
||||
'anthropic/claude-sonnet',
|
||||
'openai/gpt-4o',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps image-generation models while removing unsupported utility models', () => {
|
||||
const models = parseModelList({
|
||||
data: [
|
||||
{
|
||||
id: 'gpt-image-2',
|
||||
architecture: {
|
||||
modality: 'text+image->image',
|
||||
output_modalities: ['image'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'text-embedding-3-small',
|
||||
architecture: {
|
||||
modality: 'text->embedding',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'grok',
|
||||
architecture: {
|
||||
modality: 'text->text',
|
||||
output_modalities: ['text'],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual(['gpt-image-2', 'grok'])
|
||||
expect(getLLMTesterModelCapabilities(models[0])).toContain('image_generation')
|
||||
})
|
||||
|
||||
it('keeps Grok media models and classifies them by route capability', () => {
|
||||
const models = parseModelList({
|
||||
data: [
|
||||
{ id: 'grok-imagine', owned_by: 'xai' },
|
||||
{ id: 'grok-imagine-image', owned_by: 'xai' },
|
||||
{ id: 'grok-imagine-image-quality', owned_by: 'xai' },
|
||||
{ id: 'grok-imagine-edit', owned_by: 'xai' },
|
||||
{ id: 'grok-imagine-video', owned_by: 'xai' },
|
||||
{ id: 'grok-imagine-video-1.5', owned_by: 'xai' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual([
|
||||
'grok-imagine',
|
||||
'grok-imagine-edit',
|
||||
'grok-imagine-image',
|
||||
'grok-imagine-image-quality',
|
||||
'grok-imagine-video',
|
||||
'grok-imagine-video-1.5',
|
||||
])
|
||||
expect(getLLMTesterModelCapabilities(models[0])).toEqual(['image_generation'])
|
||||
expect(getLLMTesterModelCapabilities(models[4])).toEqual(['video_generation'])
|
||||
})
|
||||
|
||||
it('uses id heuristics when simple OpenAI-compatible model rows omit metadata', () => {
|
||||
expect(isLikelyChatCompletionModelId('gpt-5.4')).toBe(true)
|
||||
expect(isLikelyChatCompletionModelId('gpt-image-2')).toBe(false)
|
||||
expect(isLikelyChatCompletionModelId('grok-imagine-video-1.5')).toBe(false)
|
||||
expect(isLikelyChatCompletionModelId('text-embedding-3-small')).toBe(false)
|
||||
})
|
||||
|
||||
it('converts image generation responses into assistant attachments', () => {
|
||||
const result = extractImageGenerationResult({
|
||||
data: [
|
||||
{
|
||||
b64_json: 'abc123',
|
||||
revised_prompt: 'A bright test image',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.text).toContain('Generated 1 image')
|
||||
expect(result.text).toContain('A bright test image')
|
||||
expect(result.attachments).toHaveLength(1)
|
||||
expect(result.attachments[0].dataUrl).toBe('data:image/png;base64,abc123')
|
||||
})
|
||||
|
||||
it('converts Responses image_generation_call results into assistant attachments', () => {
|
||||
const result = extractImageGenerationResult({
|
||||
output: [
|
||||
{
|
||||
type: 'image_generation_call',
|
||||
result: 'a'.repeat(120),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.text).toContain('Generated 1 image')
|
||||
expect(result.attachments).toHaveLength(1)
|
||||
expect(result.attachments[0].dataUrl).toBe(`data:image/png;base64,${'a'.repeat(120)}`)
|
||||
})
|
||||
|
||||
it('keeps generated image URLs from provider responses', () => {
|
||||
const result = extractImageGenerationResult({
|
||||
output: [
|
||||
{
|
||||
type: 'image_generation_call',
|
||||
image_url: 'https://example.com/generated.png',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.attachments).toHaveLength(1)
|
||||
expect(result.attachments[0].dataUrl).toBe('https://example.com/generated.png')
|
||||
})
|
||||
|
||||
it('converts Responses SSE image output events into assistant attachments', () => {
|
||||
const result = extractImageGenerationResult([
|
||||
'data: {"type":"response.output_item.done","item":{"id":"ig_123","type":"image_generation_call","result":"aGVsbG8=","revised_prompt":"draw a cat","output_format":"png"}}',
|
||||
'',
|
||||
'data: {"type":"response.completed","response":{"output":[]}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
expect(result.text).toContain('Generated 1 image')
|
||||
expect(result.text).toContain('draw a cat')
|
||||
expect(result.attachments).toHaveLength(1)
|
||||
expect(result.attachments[0].dataUrl).toBe('data:image/png;base64,aGVsbG8=')
|
||||
})
|
||||
|
||||
it('converts video generation responses into media attachments', () => {
|
||||
const result = extractVideoGenerationResult({
|
||||
id: 'video_req_123',
|
||||
status: 'completed',
|
||||
data: [
|
||||
{
|
||||
url: 'https://example.com/generated.mp4',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.text).toContain('Generated 1 video')
|
||||
expect(result.text).toContain('Request ID: video_req_123')
|
||||
expect(result.attachments).toHaveLength(1)
|
||||
expect(result.attachments[0].kind).toBe('media')
|
||||
expect(result.attachments[0].dataUrl).toBe('https://example.com/generated.mp4')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,932 @@
|
||||
import { buildApiUrl } from '@/api/client'
|
||||
|
||||
export interface LLMTesterProfile {
|
||||
id: string
|
||||
name: string
|
||||
provider: 'openrouter' | 'sub2api' | 'custom'
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
selectedModel: string
|
||||
lastFetchedAt?: string
|
||||
}
|
||||
|
||||
export interface LLMTesterModel {
|
||||
id: string
|
||||
name: string
|
||||
ownedBy?: string
|
||||
contextLength?: number
|
||||
raw?: Record<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,6 +278,21 @@ const KeyIcon = {
|
||||
)
|
||||
}
|
||||
|
||||
const TesterIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const ChartIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
@@ -666,6 +681,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
|
||||
}
|
||||
items.push(
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/llm-tester', label: t('nav.llmTester'), icon: TesterIcon, hideInSimpleMode: true },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/available-channels', label: t('nav.availableChannels'), icon: ChannelIcon, hideInSimpleMode: true, featureFlag: flagAvailableChannels },
|
||||
{ path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon, featureFlag: flagChannelMonitor },
|
||||
@@ -773,6 +789,7 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
if (authStore.isSimpleMode) {
|
||||
const filtered = visible.filter(item => !item.hideInSimpleMode)
|
||||
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
|
||||
filtered.push({ path: '/llm-tester', label: t('nav.llmTester'), icon: TesterIcon })
|
||||
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||
for (const cm of customMenuItemsForAdmin.value) {
|
||||
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||
|
||||
@@ -141,7 +141,13 @@ const xaiModels = [
|
||||
'grok-latest',
|
||||
'grok-build',
|
||||
'grok-4.20-reasoning',
|
||||
'grok-4.20-non-reasoning'
|
||||
'grok-4.20-non-reasoning',
|
||||
'grok-imagine',
|
||||
'grok-imagine-image',
|
||||
'grok-imagine-image-quality',
|
||||
'grok-imagine-edit',
|
||||
'grok-imagine-video',
|
||||
'grok-imagine-video-1.5'
|
||||
]
|
||||
|
||||
// Cohere
|
||||
@@ -286,7 +292,13 @@ const grokPresetMappings = [
|
||||
{ label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
{ label: 'Build 0.1', from: 'grok-build', to: 'grok-build-0.1', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
||||
{ label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' }
|
||||
{ label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' },
|
||||
{ label: 'Imagine', from: 'grok-imagine', to: 'grok-imagine', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-300' },
|
||||
{ label: 'Image', from: 'grok-imagine-image', to: 'grok-imagine-image', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-300' },
|
||||
{ label: 'Image Quality', from: 'grok-imagine-image-quality', to: 'grok-imagine-image-quality', color: 'bg-fuchsia-100 text-fuchsia-700 hover:bg-fuchsia-200 dark:bg-fuchsia-900/30 dark:text-fuchsia-300' },
|
||||
{ label: 'Edit', from: 'grok-imagine-edit', to: 'grok-imagine-edit', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-300' },
|
||||
{ label: 'Video', from: 'grok-imagine-video', to: 'grok-imagine-video', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300' },
|
||||
{ label: 'Video 1.5', from: 'grok-imagine-video-1.5', to: 'grok-imagine-video-1.5', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-300' }
|
||||
]
|
||||
|
||||
// Antigravity 预设映射(支持通配符)
|
||||
|
||||
@@ -394,6 +394,7 @@ export default {
|
||||
dashboard: 'Dashboard',
|
||||
announcements: 'Announcements',
|
||||
apiKeys: 'API Keys',
|
||||
llmTester: 'LLM Tester',
|
||||
usage: 'Usage',
|
||||
redeem: 'Redeem',
|
||||
affiliate: 'Affiliate Rebates',
|
||||
@@ -1129,6 +1130,79 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
llmTester: {
|
||||
title: 'LLM Tester',
|
||||
description: 'Save OpenAI-compatible endpoints, fetch models, and run multimodal chat tests',
|
||||
profile: 'Profile',
|
||||
newProfile: 'New profile',
|
||||
provider: 'Provider',
|
||||
customProvider: 'Custom',
|
||||
profileNamePlaceholder: 'OpenRouter staging',
|
||||
baseUrl: 'Base URL',
|
||||
apiKey: 'API Key',
|
||||
showKey: 'Show key',
|
||||
hideKey: 'Hide key',
|
||||
model: 'Model',
|
||||
selectModel: 'Select a model',
|
||||
searchModels: 'Search fetched models...',
|
||||
fetchModels: 'Fetch Models',
|
||||
modelCount: '{count} models fetched',
|
||||
lastFetched: 'Fetched {time}',
|
||||
localStorageNotice: 'Keys stay in this browser',
|
||||
savedProfiles: 'Saved Profiles',
|
||||
noProfiles: 'No saved profiles',
|
||||
requestOptions: 'Request Options',
|
||||
temperature: 'Temperature',
|
||||
maxTokens: 'Max Tokens',
|
||||
systemInstruction: 'System Instruction',
|
||||
systemInstructionPlaceholder: 'Optional',
|
||||
chat: 'Chat',
|
||||
noModelSelected: 'No model selected',
|
||||
clearChat: 'Clear',
|
||||
cancel: 'Cancel',
|
||||
emptyChatTitle: 'Ready for a test message',
|
||||
emptyChatDescription: 'Select a model, attach images or code, and send a prompt.',
|
||||
you: 'You',
|
||||
assistant: 'Assistant',
|
||||
thinking: 'Thinking...',
|
||||
attachFiles: 'Attach files',
|
||||
openAttachment: 'Open',
|
||||
downloadAttachment: 'Download',
|
||||
removeAttachment: 'Remove',
|
||||
promptPlaceholder: 'Ask anything, paste code, or attach an image...',
|
||||
imagePromptPlaceholder: 'Describe the image you want to generate...',
|
||||
videoPromptPlaceholder: 'Describe the video you want to generate...',
|
||||
send: 'Send',
|
||||
profileSaved: 'Profile saved',
|
||||
profileDeleted: 'Profile deleted',
|
||||
modelsFetched: 'Fetched {count} models',
|
||||
capabilities: {
|
||||
chat: 'Chat',
|
||||
vision: 'Vision chat',
|
||||
imageGeneration: 'Image generation',
|
||||
videoGeneration: 'Video generation'
|
||||
},
|
||||
errors: {
|
||||
loadFailed: 'Failed to load saved profiles',
|
||||
saveFailed: 'Failed to save profiles',
|
||||
nameRequired: 'Profile name is required',
|
||||
baseUrlRequired: 'Base URL is required',
|
||||
apiKeyRequired: 'API Key is required',
|
||||
modelsFailed: 'Failed to fetch models',
|
||||
chatFailed: 'Chat request failed',
|
||||
unsupportedModel: 'This model is not supported by the tester yet.',
|
||||
unsupportedChatModel: 'This tester only supports text chat models. Pick a chat-capable model.',
|
||||
imagePromptRequired: 'Add a text prompt before generating an image.',
|
||||
videoPromptRequired: 'Add a text prompt before generating a video.',
|
||||
openUnavailable: 'Unable to open this attachment',
|
||||
downloadUnavailable: 'Unable to download this attachment',
|
||||
cancelled: 'Request cancelled',
|
||||
imageTooLarge: '{name} is larger than the 5 MB image limit',
|
||||
textTooLarge: '{name} is larger than the 240 KB text limit',
|
||||
fileReadFailed: 'Failed to read {name}'
|
||||
}
|
||||
},
|
||||
|
||||
affiliate: {
|
||||
title: 'Affiliate Rebates',
|
||||
description: 'Invite new users and convert your rebate quota into account balance',
|
||||
|
||||
@@ -394,6 +394,7 @@ export default {
|
||||
dashboard: '仪表盘',
|
||||
announcements: '公告',
|
||||
apiKeys: 'API 密钥',
|
||||
llmTester: 'LLM 测试器',
|
||||
usage: '使用记录',
|
||||
redeem: '兑换',
|
||||
affiliate: '邀请返利',
|
||||
@@ -1133,6 +1134,79 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
llmTester: {
|
||||
title: 'LLM 测试器',
|
||||
description: '保存 OpenAI 兼容端点,拉取模型列表,并进行多模态聊天测试',
|
||||
profile: '配置',
|
||||
newProfile: '新配置',
|
||||
provider: '服务商',
|
||||
customProvider: '自定义',
|
||||
profileNamePlaceholder: 'OpenRouter 测试',
|
||||
baseUrl: 'Base URL',
|
||||
apiKey: 'API Key',
|
||||
showKey: '显示密钥',
|
||||
hideKey: '隐藏密钥',
|
||||
model: '模型',
|
||||
selectModel: '选择模型',
|
||||
searchModels: '搜索已拉取模型...',
|
||||
fetchModels: '拉取模型',
|
||||
modelCount: '已拉取 {count} 个模型',
|
||||
lastFetched: '拉取时间 {time}',
|
||||
localStorageNotice: '密钥仅保存在此浏览器',
|
||||
savedProfiles: '已保存配置',
|
||||
noProfiles: '暂无保存配置',
|
||||
requestOptions: '请求选项',
|
||||
temperature: 'Temperature',
|
||||
maxTokens: 'Max Tokens',
|
||||
systemInstruction: 'System Instruction',
|
||||
systemInstructionPlaceholder: '可选',
|
||||
chat: '聊天',
|
||||
noModelSelected: '未选择模型',
|
||||
clearChat: '清空',
|
||||
cancel: '取消',
|
||||
emptyChatTitle: '可以开始测试',
|
||||
emptyChatDescription: '选择模型,附加图片或代码,然后发送提示词。',
|
||||
you: '你',
|
||||
assistant: '助手',
|
||||
thinking: '思考中...',
|
||||
attachFiles: '附加文件',
|
||||
openAttachment: '打开',
|
||||
downloadAttachment: '下载',
|
||||
removeAttachment: '移除',
|
||||
promptPlaceholder: '输入问题、粘贴代码,或附加图片...',
|
||||
imagePromptPlaceholder: '描述你想生成的图片...',
|
||||
videoPromptPlaceholder: '描述你想生成的视频...',
|
||||
send: '发送',
|
||||
profileSaved: '配置已保存',
|
||||
profileDeleted: '配置已删除',
|
||||
modelsFetched: '已拉取 {count} 个模型',
|
||||
capabilities: {
|
||||
chat: '聊天',
|
||||
vision: '视觉聊天',
|
||||
imageGeneration: '图片生成',
|
||||
videoGeneration: '视频生成'
|
||||
},
|
||||
errors: {
|
||||
loadFailed: '加载保存配置失败',
|
||||
saveFailed: '保存配置失败',
|
||||
nameRequired: '请输入配置名称',
|
||||
baseUrlRequired: '请输入 Base URL',
|
||||
apiKeyRequired: '请输入 API Key',
|
||||
modelsFailed: '拉取模型失败',
|
||||
chatFailed: '聊天请求失败',
|
||||
unsupportedModel: '此测试器暂不支持该模型。',
|
||||
unsupportedChatModel: '此测试器仅支持文本聊天模型,请选择可聊天的模型。',
|
||||
imagePromptRequired: '生成图片前请先输入文本提示词。',
|
||||
videoPromptRequired: '生成视频前请先输入文本提示词。',
|
||||
openUnavailable: '无法打开此附件',
|
||||
downloadUnavailable: '无法下载此附件',
|
||||
cancelled: '请求已取消',
|
||||
imageTooLarge: '{name} 超过 5 MB 图片限制',
|
||||
textTooLarge: '{name} 超过 240 KB 文本限制',
|
||||
fileReadFailed: '读取 {name} 失败'
|
||||
}
|
||||
},
|
||||
|
||||
affiliate: {
|
||||
title: '邀请返利',
|
||||
description: '邀请新用户注册,并将返利额度转入账户余额',
|
||||
|
||||
@@ -205,6 +205,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'keys.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/llm-tester',
|
||||
name: 'LLMTester',
|
||||
component: () => import('@/views/user/LLMTesterView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
requiresAdmin: false,
|
||||
title: 'LLM Tester',
|
||||
titleKey: 'llmTester.title',
|
||||
descriptionKey: 'llmTester.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/usage',
|
||||
name: 'Usage',
|
||||
@@ -690,7 +702,7 @@ let authInitialized = false
|
||||
const navigationLoading = useNavigationLoadingState()
|
||||
// 延迟初始化预加载,传入 router 实例
|
||||
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
|
||||
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result', '/payment/airwallex', '/legal']
|
||||
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/llm-tester', '/setup', '/payment/result', '/payment/airwallex', '/legal']
|
||||
const BACKEND_MODE_CALLBACK_PATHS = [
|
||||
'/auth/callback',
|
||||
'/auth/linuxdo/callback',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+203
-1
@@ -2,6 +2,10 @@ import { defineConfig, loadEnv, Plugin } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import checker from 'vite-plugin-checker'
|
||||
import { resolve } from 'path'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { lookup } from 'node:dns/promises'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { isIP } from 'node:net'
|
||||
|
||||
/**
|
||||
* Vite 插件:开发模式下注入公开配置到 index.html
|
||||
@@ -34,6 +38,203 @@ function injectPublicSettings(backendUrl: string): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
const LLM_TESTER_MAX_BODY_BYTES = 12 * 1024 * 1024
|
||||
const LLM_TESTER_TIMEOUT_MS = 300000
|
||||
|
||||
function llmTesterDevProxy(): Plugin {
|
||||
return {
|
||||
name: 'llm-tester-dev-proxy',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname
|
||||
if (req.method !== 'POST' || !pathname.startsWith('/api/v1/llm-tester/')) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readDevProxyJson(req)
|
||||
const route = pathname.slice('/api/v1/llm-tester/'.length)
|
||||
if (route === 'models') {
|
||||
await forwardDevLLMTesterRequest(res, body, 'GET', 'models')
|
||||
return
|
||||
}
|
||||
if (route === 'chat/completions') {
|
||||
await forwardDevLLMTesterRequest(res, body, 'POST', 'chat/completions')
|
||||
return
|
||||
}
|
||||
if (route === 'images/generations') {
|
||||
await forwardDevLLMTesterRequest(res, body, 'POST', 'images/generations')
|
||||
return
|
||||
}
|
||||
if (route === 'responses') {
|
||||
await forwardDevLLMTesterRequest(res, body, 'POST', 'responses')
|
||||
return
|
||||
}
|
||||
next()
|
||||
} catch (error) {
|
||||
writeDevProxyError(res, 502, devProxyErrorMessage(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readDevProxyJson(req: IncomingMessage): Promise<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(), '')
|
||||
@@ -46,7 +247,8 @@ export default defineConfig(({ mode }) => {
|
||||
checker({
|
||||
vueTsc: true
|
||||
}),
|
||||
injectPublicSettings(backendUrl)
|
||||
injectPublicSettings(backendUrl),
|
||||
llmTesterDevProxy()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user