mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 05:02:17 +00:00
Merge branch 'QuantumNous:main' into main
This commit is contained in:
8
.github/workflows/linux-release.yml
vendored
8
.github/workflows/linux-release.yml
vendored
@@ -38,21 +38,21 @@ jobs:
|
|||||||
- name: Build Backend (amd64)
|
- name: Build Backend (amd64)
|
||||||
run: |
|
run: |
|
||||||
go mod download
|
go mod download
|
||||||
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
|
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
|
||||||
|
|
||||||
- name: Build Backend (arm64)
|
- name: Build Backend (arm64)
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
|
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
|
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api-arm64
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
one-api
|
new-api
|
||||||
one-api-arm64
|
new-api-arm64
|
||||||
draft: true
|
draft: true
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
env:
|
env:
|
||||||
|
|||||||
4
.github/workflows/macos-release.yml
vendored
4
.github/workflows/macos-release.yml
vendored
@@ -39,12 +39,12 @@ jobs:
|
|||||||
- name: Build Backend
|
- name: Build Backend
|
||||||
run: |
|
run: |
|
||||||
go mod download
|
go mod download
|
||||||
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
|
go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o new-api-macos
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
with:
|
with:
|
||||||
files: one-api-macos
|
files: new-api-macos
|
||||||
draft: true
|
draft: true
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
env:
|
env:
|
||||||
|
|||||||
4
.github/workflows/windows-release.yml
vendored
4
.github/workflows/windows-release.yml
vendored
@@ -41,12 +41,12 @@ jobs:
|
|||||||
- name: Build Backend
|
- name: Build Backend
|
||||||
run: |
|
run: |
|
||||||
go mod download
|
go mod download
|
||||||
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
|
go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o new-api.exe
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
with:
|
with:
|
||||||
files: one-api.exe
|
files: new-api.exe
|
||||||
draft: true
|
draft: true
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
env:
|
env:
|
||||||
|
|||||||
22
common/ip.go
Normal file
22
common/ip.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "net"
|
||||||
|
|
||||||
|
func IsPrivateIP(ip net.IP) bool {
|
||||||
|
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private := []net.IPNet{
|
||||||
|
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
|
||||||
|
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
|
||||||
|
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, privateNet := range private {
|
||||||
|
if privateNet.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
327
common/ssrf_protection.go
Normal file
327
common/ssrf_protection.go
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSRFProtection SSRF防护配置
|
||||||
|
type SSRFProtection struct {
|
||||||
|
AllowPrivateIp bool
|
||||||
|
DomainFilterMode bool // true: 白名单, false: 黑名单
|
||||||
|
DomainList []string // domain format, e.g. example.com, *.example.com
|
||||||
|
IpFilterMode bool // true: 白名单, false: 黑名单
|
||||||
|
IpList []string // CIDR or single IP
|
||||||
|
AllowedPorts []int // 允许的端口范围
|
||||||
|
ApplyIPFilterForDomain bool // 对域名启用IP过滤
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSSRFProtection 默认SSRF防护配置
|
||||||
|
var DefaultSSRFProtection = &SSRFProtection{
|
||||||
|
AllowPrivateIp: false,
|
||||||
|
DomainFilterMode: true,
|
||||||
|
DomainList: []string{},
|
||||||
|
IpFilterMode: true,
|
||||||
|
IpList: []string{},
|
||||||
|
AllowedPorts: []int{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPrivateIP 检查IP是否为私有地址
|
||||||
|
func isPrivateIP(ip net.IP) bool {
|
||||||
|
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查私有网段
|
||||||
|
private := []net.IPNet{
|
||||||
|
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
|
||||||
|
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
|
||||||
|
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
|
||||||
|
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
|
||||||
|
{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
|
||||||
|
{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
|
||||||
|
{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, privateNet := range private {
|
||||||
|
if privateNet.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查IPv6私有地址
|
||||||
|
if ip.To4() == nil {
|
||||||
|
// IPv6 loopback
|
||||||
|
if ip.Equal(net.IPv6loopback) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// IPv6 link-local
|
||||||
|
if strings.HasPrefix(ip.String(), "fe80:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// IPv6 unique local
|
||||||
|
if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePortRanges 解析端口范围配置
|
||||||
|
// 支持格式: "80", "443", "8000-9000"
|
||||||
|
func parsePortRanges(portConfigs []string) ([]int, error) {
|
||||||
|
var ports []int
|
||||||
|
|
||||||
|
for _, config := range portConfigs {
|
||||||
|
config = strings.TrimSpace(config)
|
||||||
|
if config == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(config, "-") {
|
||||||
|
// 处理端口范围 "8000-9000"
|
||||||
|
parts := strings.Split(config, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid port range format: %s", config)
|
||||||
|
}
|
||||||
|
|
||||||
|
startPort, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid start port in range %s: %v", config, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endPort, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid end port in range %s: %v", config, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if startPort > endPort {
|
||||||
|
return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 {
|
||||||
|
return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加范围内的所有端口
|
||||||
|
for port := startPort; port <= endPort; port++ {
|
||||||
|
ports = append(ports, port)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 处理单个端口 "80"
|
||||||
|
port, err := strconv.Atoi(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid port number: %s", config)
|
||||||
|
}
|
||||||
|
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
ports = append(ports, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedPort 检查端口是否被允许
|
||||||
|
func (p *SSRFProtection) isAllowedPort(port int) bool {
|
||||||
|
if len(p.AllowedPorts) == 0 {
|
||||||
|
return true // 如果没有配置端口限制,则允许所有端口
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowedPort := range p.AllowedPorts {
|
||||||
|
if port == allowedPort {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDomainWhitelisted 检查域名是否在白名单中
|
||||||
|
func isDomainListed(domain string, list []string) bool {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
for _, item := range list {
|
||||||
|
item = strings.ToLower(strings.TrimSpace(item))
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 精确匹配
|
||||||
|
if domain == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 通配符匹配 (*.example.com)
|
||||||
|
if strings.HasPrefix(item, "*.") {
|
||||||
|
suffix := strings.TrimPrefix(item, "*.")
|
||||||
|
if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SSRFProtection) isDomainAllowed(domain string) bool {
|
||||||
|
listed := isDomainListed(domain, p.DomainList)
|
||||||
|
if p.DomainFilterMode { // 白名单
|
||||||
|
return listed
|
||||||
|
}
|
||||||
|
// 黑名单
|
||||||
|
return !listed
|
||||||
|
}
|
||||||
|
|
||||||
|
// isIPWhitelisted 检查IP是否在白名单中
|
||||||
|
|
||||||
|
func isIPListed(ip net.IP, list []string) bool {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, whitelistCIDR := range list {
|
||||||
|
_, network, err := net.ParseCIDR(whitelistCIDR)
|
||||||
|
if err != nil {
|
||||||
|
// 尝试作为单个IP处理
|
||||||
|
if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil {
|
||||||
|
if ip.Equal(whitelistIP) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if network.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsIPAccessAllowed 检查IP是否允许访问
|
||||||
|
func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
|
||||||
|
// 私有IP限制
|
||||||
|
if isPrivateIP(ip) && !p.AllowPrivateIp {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
listed := isIPListed(ip, p.IpList)
|
||||||
|
if p.IpFilterMode { // 白名单
|
||||||
|
return listed
|
||||||
|
}
|
||||||
|
// 黑名单
|
||||||
|
return !listed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateURL 验证URL是否安全
|
||||||
|
func (p *SSRFProtection) ValidateURL(urlStr string) error {
|
||||||
|
// 解析URL
|
||||||
|
u, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL format: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只允许HTTP/HTTPS协议
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析主机和端口
|
||||||
|
host, portStr, err := net.SplitHostPort(u.Host)
|
||||||
|
if err != nil {
|
||||||
|
// 没有端口,使用默认端口
|
||||||
|
host = u.Hostname()
|
||||||
|
if u.Scheme == "https" {
|
||||||
|
portStr = "443"
|
||||||
|
} else {
|
||||||
|
portStr = "80"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证端口
|
||||||
|
port, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid port: %s", portStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.isAllowedPort(port) {
|
||||||
|
return fmt.Errorf("port %d is not allowed", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 host 是 IP,则跳过域名检查
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
if !p.IsIPAccessAllowed(ip) {
|
||||||
|
if isPrivateIP(ip) {
|
||||||
|
return fmt.Errorf("private IP address not allowed: %s", ip.String())
|
||||||
|
}
|
||||||
|
if p.IpFilterMode {
|
||||||
|
return fmt.Errorf("ip not in whitelist: %s", ip.String())
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ip in blacklist: %s", ip.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先进行域名过滤
|
||||||
|
if !p.isDomainAllowed(host) {
|
||||||
|
if p.DomainFilterMode {
|
||||||
|
return fmt.Errorf("domain not in whitelist: %s", host)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("domain in blacklist: %s", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若未启用对域名应用IP过滤,则到此通过
|
||||||
|
if !p.ApplyIPFilterForDomain {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析域名对应IP并检查
|
||||||
|
ips, err := net.LookupIP(host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
if !p.IsIPAccessAllowed(ip) {
|
||||||
|
if isPrivateIP(ip) && !p.AllowPrivateIp {
|
||||||
|
return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
|
||||||
|
}
|
||||||
|
if p.IpFilterMode {
|
||||||
|
return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String())
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
|
||||||
|
func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error {
|
||||||
|
// 如果SSRF防护被禁用,直接返回成功
|
||||||
|
if !enableSSRFProtection {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析端口范围配置
|
||||||
|
allowedPortInts, err := parsePortRanges(allowedPorts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("request reject - invalid port configuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protection := &SSRFProtection{
|
||||||
|
AllowPrivateIp: allowPrivateIp,
|
||||||
|
DomainFilterMode: domainFilterMode,
|
||||||
|
DomainList: domainList,
|
||||||
|
IpFilterMode: ipFilterMode,
|
||||||
|
IpList: ipList,
|
||||||
|
AllowedPorts: allowedPortInts,
|
||||||
|
ApplyIPFilterForDomain: applyIPFilterForDomain,
|
||||||
|
}
|
||||||
|
return protection.ValidateURL(urlStr)
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SysLog(s string) {
|
func SysLog(s string) {
|
||||||
@@ -22,3 +23,33 @@ func FatalLog(v ...any) {
|
|||||||
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
|
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LogStartupSuccess(startTime time.Time, port string) {
|
||||||
|
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
durationMs := duration.Milliseconds()
|
||||||
|
|
||||||
|
// Get network IPs
|
||||||
|
networkIps := GetNetworkIps()
|
||||||
|
|
||||||
|
// Print blank line for spacing
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||||
|
|
||||||
|
// Print the main success message
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs)
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||||
|
|
||||||
|
// Skip fancy startup message in container environments
|
||||||
|
if !IsRunningInContainer() {
|
||||||
|
// Print local URL
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print network URLs
|
||||||
|
for _, ip := range networkIps {
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print blank line for spacing
|
||||||
|
fmt.Fprintf(gin.DefaultWriter, "\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,78 @@ func GetIp() (ip string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetNetworkIps() []string {
|
||||||
|
var networkIps []string
|
||||||
|
ips, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return networkIps
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range ips {
|
||||||
|
if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
||||||
|
if ipNet.IP.To4() != nil {
|
||||||
|
ip := ipNet.IP.String()
|
||||||
|
// Include common private network ranges
|
||||||
|
if strings.HasPrefix(ip, "10.") ||
|
||||||
|
strings.HasPrefix(ip, "172.") ||
|
||||||
|
strings.HasPrefix(ip, "192.168.") {
|
||||||
|
networkIps = append(networkIps, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return networkIps
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunningInContainer detects if the application is running inside a container
|
||||||
|
func IsRunningInContainer() bool {
|
||||||
|
// Method 1: Check for .dockerenv file (Docker containers)
|
||||||
|
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Check cgroup for container indicators
|
||||||
|
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
|
||||||
|
content := string(data)
|
||||||
|
if strings.Contains(content, "docker") ||
|
||||||
|
strings.Contains(content, "containerd") ||
|
||||||
|
strings.Contains(content, "kubepods") ||
|
||||||
|
strings.Contains(content, "/lxc/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Check environment variables commonly set by container runtimes
|
||||||
|
containerEnvVars := []string{
|
||||||
|
"KUBERNETES_SERVICE_HOST",
|
||||||
|
"DOCKER_CONTAINER",
|
||||||
|
"container",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, envVar := range containerEnvVars {
|
||||||
|
if os.Getenv(envVar) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 4: Check if init process is not the traditional init
|
||||||
|
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
|
||||||
|
comm := strings.TrimSpace(string(data))
|
||||||
|
// In containers, process 1 is often not "init" or "systemd"
|
||||||
|
if comm != "init" && comm != "systemd" {
|
||||||
|
// Additional check: if it's a common container entrypoint
|
||||||
|
if strings.Contains(comm, "docker") ||
|
||||||
|
strings.Contains(comm, "containerd") ||
|
||||||
|
strings.Contains(comm, "runc") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var sizeKB = 1024
|
var sizeKB = 1024
|
||||||
var sizeMB = sizeKB * 1024
|
var sizeMB = sizeKB * 1024
|
||||||
var sizeGB = sizeMB * 1024
|
var sizeGB = sizeMB * 1024
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ const (
|
|||||||
SunoActionMusic = "MUSIC"
|
SunoActionMusic = "MUSIC"
|
||||||
SunoActionLyrics = "LYRICS"
|
SunoActionLyrics = "LYRICS"
|
||||||
|
|
||||||
TaskActionGenerate = "generate"
|
TaskActionGenerate = "generate"
|
||||||
TaskActionTextGenerate = "textGenerate"
|
TaskActionTextGenerate = "textGenerate"
|
||||||
|
TaskActionFirstTailGenerate = "firstTailGenerate"
|
||||||
|
TaskActionReferenceGenerate = "referenceGenerate"
|
||||||
)
|
)
|
||||||
|
|
||||||
var SunoModel2Action = map[string]string{
|
var SunoModel2Action = map[string]string{
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ func testChannel(channel *model.Channel, testModel string) testResult {
|
|||||||
requestPath = "/v1/embeddings" // 修改请求路径
|
requestPath = "/v1/embeddings" // 修改请求路径
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VolcEngine 图像生成模型
|
||||||
|
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
|
||||||
|
requestPath = "/v1/images/generations"
|
||||||
|
}
|
||||||
|
|
||||||
c.Request = &http.Request{
|
c.Request = &http.Request{
|
||||||
Method: "POST",
|
Method: "POST",
|
||||||
URL: &url.URL{Path: requestPath}, // 使用动态路径
|
URL: &url.URL{Path: requestPath}, // 使用动态路径
|
||||||
@@ -109,6 +114,21 @@ func testChannel(channel *model.Channel, testModel string) testResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重新检查模型类型并更新请求路径
|
||||||
|
if strings.Contains(strings.ToLower(testModel), "embedding") ||
|
||||||
|
strings.HasPrefix(testModel, "m3e") ||
|
||||||
|
strings.Contains(testModel, "bge-") ||
|
||||||
|
strings.Contains(testModel, "embed") ||
|
||||||
|
channel.Type == constant.ChannelTypeMokaAI {
|
||||||
|
requestPath = "/v1/embeddings"
|
||||||
|
c.Request.URL.Path = requestPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
|
||||||
|
requestPath = "/v1/images/generations"
|
||||||
|
c.Request.URL.Path = requestPath
|
||||||
|
}
|
||||||
|
|
||||||
cache, err := model.GetUserCache(1)
|
cache, err := model.GetUserCache(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return testResult{
|
return testResult{
|
||||||
@@ -140,6 +160,9 @@ func testChannel(channel *model.Channel, testModel string) testResult {
|
|||||||
if c.Request.URL.Path == "/v1/embeddings" {
|
if c.Request.URL.Path == "/v1/embeddings" {
|
||||||
relayFormat = types.RelayFormatEmbedding
|
relayFormat = types.RelayFormatEmbedding
|
||||||
}
|
}
|
||||||
|
if c.Request.URL.Path == "/v1/images/generations" {
|
||||||
|
relayFormat = types.RelayFormatOpenAIImage
|
||||||
|
}
|
||||||
|
|
||||||
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
|
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
|
||||||
|
|
||||||
@@ -201,6 +224,22 @@ func testChannel(channel *model.Channel, testModel string) testResult {
|
|||||||
}
|
}
|
||||||
// 调用专门用于 Embedding 的转换函数
|
// 调用专门用于 Embedding 的转换函数
|
||||||
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
|
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
|
||||||
|
} else if info.RelayMode == relayconstant.RelayModeImagesGenerations {
|
||||||
|
// 创建一个 ImageRequest
|
||||||
|
prompt := "cat"
|
||||||
|
if request.Prompt != nil {
|
||||||
|
if promptStr, ok := request.Prompt.(string); ok && promptStr != "" {
|
||||||
|
prompt = promptStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imageRequest := dto.ImageRequest{
|
||||||
|
Prompt: prompt,
|
||||||
|
Model: request.Model,
|
||||||
|
N: uint(request.N),
|
||||||
|
Size: request.Size,
|
||||||
|
}
|
||||||
|
// 调用专门用于图像生成的转换函数
|
||||||
|
convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest)
|
||||||
} else {
|
} else {
|
||||||
// 对其他所有请求类型(如 Chat),保持原有逻辑
|
// 对其他所有请求类型(如 Chat),保持原有逻辑
|
||||||
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
|
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"one-api/constant"
|
"one-api/constant"
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
"one-api/model"
|
"one-api/model"
|
||||||
|
"one-api/service"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -188,6 +189,8 @@ func FetchUpstreamModels(c *gin.Context) {
|
|||||||
url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader
|
url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader
|
||||||
case constant.ChannelTypeAli:
|
case constant.ChannelTypeAli:
|
||||||
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
|
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
|
||||||
|
case constant.ChannelTypeZhipu_v4:
|
||||||
|
url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
|
||||||
default:
|
default:
|
||||||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||||||
}
|
}
|
||||||
@@ -501,9 +504,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AddChannelRequest struct {
|
type AddChannelRequest struct {
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
|
MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
|
||||||
Channel *model.Channel `json:"channel"`
|
BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"`
|
||||||
|
Channel *model.Channel `json:"channel"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getVertexArrayKeys(keys string) ([]string, error) {
|
func getVertexArrayKeys(keys string) ([]string, error) {
|
||||||
@@ -616,6 +620,13 @@ func AddChannel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
localChannel := addChannelRequest.Channel
|
localChannel := addChannelRequest.Channel
|
||||||
localChannel.Key = key
|
localChannel.Key = key
|
||||||
|
if addChannelRequest.BatchAddSetKeyPrefix2Name && len(keys) > 1 {
|
||||||
|
keyPrefix := localChannel.Key
|
||||||
|
if len(localChannel.Key) > 8 {
|
||||||
|
keyPrefix = localChannel.Key[:8]
|
||||||
|
}
|
||||||
|
localChannel.Name = fmt.Sprintf("%s %s", localChannel.Name, keyPrefix)
|
||||||
|
}
|
||||||
channels = append(channels, *localChannel)
|
channels = append(channels, *localChannel)
|
||||||
}
|
}
|
||||||
err = model.BatchInsertChannels(channels)
|
err = model.BatchInsertChannels(channels)
|
||||||
@@ -623,6 +634,7 @@ func AddChannel(c *gin.Context) {
|
|||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
service.ResetProxyClientCache()
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -884,6 +896,7 @@ func UpdateChannel(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
model.InitChannelCache()
|
model.InitChannelCache()
|
||||||
|
service.ResetProxyClientCache()
|
||||||
channel.Key = ""
|
channel.Key = ""
|
||||||
clearChannelInfo(&channel.Channel)
|
clearChannelInfo(&channel.Channel)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -1093,8 +1106,8 @@ func CopyChannel(c *gin.Context) {
|
|||||||
// MultiKeyManageRequest represents the request for multi-key management operations
|
// MultiKeyManageRequest represents the request for multi-key management operations
|
||||||
type MultiKeyManageRequest struct {
|
type MultiKeyManageRequest struct {
|
||||||
ChannelId int `json:"channel_id"`
|
ChannelId int `json:"channel_id"`
|
||||||
Action string `json:"action"` // "disable_key", "enable_key", "delete_disabled_keys", "get_key_status"
|
Action string `json:"action"` // "disable_key", "enable_key", "delete_key", "delete_disabled_keys", "get_key_status"
|
||||||
KeyIndex *int `json:"key_index,omitempty"` // for disable_key and enable_key actions
|
KeyIndex *int `json:"key_index,omitempty"` // for disable_key, enable_key, and delete_key actions
|
||||||
Page int `json:"page,omitempty"` // for get_key_status pagination
|
Page int `json:"page,omitempty"` // for get_key_status pagination
|
||||||
PageSize int `json:"page_size,omitempty"` // for get_key_status pagination
|
PageSize int `json:"page_size,omitempty"` // for get_key_status pagination
|
||||||
Status *int `json:"status,omitempty"` // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all
|
Status *int `json:"status,omitempty"` // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all
|
||||||
@@ -1422,6 +1435,86 @@ func ManageMultiKeys(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
case "delete_key":
|
||||||
|
if request.KeyIndex == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "未指定要删除的密钥索引",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keyIndex := *request.KeyIndex
|
||||||
|
if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "密钥索引超出范围",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := channel.GetKeys()
|
||||||
|
var remainingKeys []string
|
||||||
|
var newStatusList = make(map[int]int)
|
||||||
|
var newDisabledTime = make(map[int]int64)
|
||||||
|
var newDisabledReason = make(map[int]string)
|
||||||
|
|
||||||
|
newIndex := 0
|
||||||
|
for i, key := range keys {
|
||||||
|
// 跳过要删除的密钥
|
||||||
|
if i == keyIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingKeys = append(remainingKeys, key)
|
||||||
|
|
||||||
|
// 保留其他密钥的状态信息,重新索引
|
||||||
|
if channel.ChannelInfo.MultiKeyStatusList != nil {
|
||||||
|
if status, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists && status != 1 {
|
||||||
|
newStatusList[newIndex] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if channel.ChannelInfo.MultiKeyDisabledTime != nil {
|
||||||
|
if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists {
|
||||||
|
newDisabledTime[newIndex] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if channel.ChannelInfo.MultiKeyDisabledReason != nil {
|
||||||
|
if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists {
|
||||||
|
newDisabledReason[newIndex] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(remainingKeys) == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "不能删除最后一个密钥",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update channel with remaining keys
|
||||||
|
channel.Key = strings.Join(remainingKeys, "\n")
|
||||||
|
channel.ChannelInfo.MultiKeySize = len(remainingKeys)
|
||||||
|
channel.ChannelInfo.MultiKeyStatusList = newStatusList
|
||||||
|
channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime
|
||||||
|
channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason
|
||||||
|
|
||||||
|
err = channel.Update()
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
model.InitChannelCache()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "密钥已删除",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
case "delete_disabled_keys":
|
case "delete_disabled_keys":
|
||||||
keys := channel.GetKeys()
|
keys := channel.GetKeys()
|
||||||
var remainingKeys []string
|
var remainingKeys []string
|
||||||
|
|||||||
@@ -128,6 +128,33 @@ func UpdateOption(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
case "ImageRatio":
|
||||||
|
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "图片倍率设置失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "AudioRatio":
|
||||||
|
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "音频倍率设置失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "AudioCompletionRatio":
|
||||||
|
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "音频补全倍率设置失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
case "ModelRequestRateLimitGroup":
|
case "ModelRequestRateLimitGroup":
|
||||||
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
|
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func GetSetup(c *gin.Context) {
|
|||||||
func PostSetup(c *gin.Context) {
|
func PostSetup(c *gin.Context) {
|
||||||
// Check if setup is already completed
|
// Check if setup is already completed
|
||||||
if constant.Setup {
|
if constant.Setup {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "系统已经初始化完成",
|
"message": "系统已经初始化完成",
|
||||||
})
|
})
|
||||||
@@ -66,7 +66,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
var req SetupRequest
|
var req SetupRequest
|
||||||
err := c.ShouldBindJSON(&req)
|
err := c.ShouldBindJSON(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "请求参数有误",
|
"message": "请求参数有误",
|
||||||
})
|
})
|
||||||
@@ -77,7 +77,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
if !rootExists {
|
if !rootExists {
|
||||||
// Validate username length: max 12 characters to align with model.User validation
|
// Validate username length: max 12 characters to align with model.User validation
|
||||||
if len(req.Username) > 12 {
|
if len(req.Username) > 12 {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "用户名长度不能超过12个字符",
|
"message": "用户名长度不能超过12个字符",
|
||||||
})
|
})
|
||||||
@@ -85,7 +85,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// Validate password
|
// Validate password
|
||||||
if req.Password != req.ConfirmPassword {
|
if req.Password != req.ConfirmPassword {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "两次输入的密码不一致",
|
"message": "两次输入的密码不一致",
|
||||||
})
|
})
|
||||||
@@ -93,7 +93,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(req.Password) < 8 {
|
if len(req.Password) < 8 {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "密码长度至少为8个字符",
|
"message": "密码长度至少为8个字符",
|
||||||
})
|
})
|
||||||
@@ -103,7 +103,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
// Create root user
|
// Create root user
|
||||||
hashedPassword, err := common.Password2Hash(req.Password)
|
hashedPassword, err := common.Password2Hash(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "系统错误: " + err.Error(),
|
"message": "系统错误: " + err.Error(),
|
||||||
})
|
})
|
||||||
@@ -120,7 +120,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
err = model.DB.Create(&rootUser).Error
|
err = model.DB.Create(&rootUser).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "创建管理员账号失败: " + err.Error(),
|
"message": "创建管理员账号失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
@@ -135,7 +135,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
// Save operation modes to database for persistence
|
// Save operation modes to database for persistence
|
||||||
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
|
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "保存自用模式设置失败: " + err.Error(),
|
"message": "保存自用模式设置失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
@@ -144,7 +144,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
|
|
||||||
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
|
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "保存演示站点模式设置失败: " + err.Error(),
|
"message": "保存演示站点模式设置失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
@@ -160,7 +160,7 @@ func PostSetup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
err = model.DB.Create(&setup).Error
|
err = model.DB.Create(&setup).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "系统初始化失败: " + err.Error(),
|
"message": "系统初始化失败: " + err.Error(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
|
|||||||
|
|
||||||
params := &stripe.CheckoutSessionParams{
|
params := &stripe.CheckoutSessionParams{
|
||||||
ClientReferenceID: stripe.String(referenceId),
|
ClientReferenceID: stripe.String(referenceId),
|
||||||
SuccessURL: stripe.String(system_setting.ServerAddress + "/log"),
|
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
|
||||||
CancelURL: stripe.String(system_setting.ServerAddress + "/topup"),
|
CancelURL: stripe.String(system_setting.ServerAddress + "/topup"),
|
||||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||||
{
|
{
|
||||||
@@ -225,7 +225,8 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
|
|||||||
Quantity: stripe.Int64(amount),
|
Quantity: stripe.Int64(amount),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
|
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
|
||||||
|
AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled),
|
||||||
}
|
}
|
||||||
|
|
||||||
if "" == customerId {
|
if "" == customerId {
|
||||||
|
|||||||
@@ -19,4 +19,12 @@ const (
|
|||||||
type ChannelOtherSettings struct {
|
type ChannelOtherSettings struct {
|
||||||
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
|
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
|
||||||
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
|
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
|
||||||
|
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
|
||||||
|
if s == nil || s.OpenRouterEnterprise == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return *s.OpenRouterEnterprise
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,30 @@ type GeminiChatRequest struct {
|
|||||||
SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
|
SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
|
||||||
GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"`
|
GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"`
|
||||||
Tools json.RawMessage `json:"tools,omitempty"`
|
Tools json.RawMessage `json:"tools,omitempty"`
|
||||||
|
ToolConfig *ToolConfig `json:"toolConfig,omitempty"`
|
||||||
SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"`
|
SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"`
|
||||||
|
CachedContent string `json:"cachedContent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolConfig struct {
|
||||||
|
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
|
||||||
|
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunctionCallingConfig struct {
|
||||||
|
Mode FunctionCallingConfigMode `json:"mode,omitempty"`
|
||||||
|
AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"`
|
||||||
|
}
|
||||||
|
type FunctionCallingConfigMode string
|
||||||
|
|
||||||
|
type RetrievalConfig struct {
|
||||||
|
LatLng *LatLng `json:"latLng,omitempty"`
|
||||||
|
LanguageCode string `json:"languageCode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LatLng struct {
|
||||||
|
Latitude *float64 `json:"latitude,omitempty"`
|
||||||
|
Longitude *float64 `json:"longitude,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||||
@@ -228,6 +251,7 @@ type GeminiChatTool struct {
|
|||||||
GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
|
GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
|
||||||
CodeExecution any `json:"codeExecution,omitempty"`
|
CodeExecution any `json:"codeExecution,omitempty"`
|
||||||
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
|
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
|
||||||
|
URLContext any `json:"urlContext,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeminiChatGenerationConfig struct {
|
type GeminiChatGenerationConfig struct {
|
||||||
@@ -239,12 +263,20 @@ type GeminiChatGenerationConfig struct {
|
|||||||
StopSequences []string `json:"stopSequences,omitempty"`
|
StopSequences []string `json:"stopSequences,omitempty"`
|
||||||
ResponseMimeType string `json:"responseMimeType,omitempty"`
|
ResponseMimeType string `json:"responseMimeType,omitempty"`
|
||||||
ResponseSchema any `json:"responseSchema,omitempty"`
|
ResponseSchema any `json:"responseSchema,omitempty"`
|
||||||
|
ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
|
||||||
|
PresencePenalty *float32 `json:"presencePenalty,omitempty"`
|
||||||
|
FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"`
|
||||||
|
ResponseLogprobs bool `json:"responseLogprobs,omitempty"`
|
||||||
|
Logprobs *int32 `json:"logprobs,omitempty"`
|
||||||
|
MediaResolution MediaResolution `json:"mediaResolution,omitempty"`
|
||||||
Seed int64 `json:"seed,omitempty"`
|
Seed int64 `json:"seed,omitempty"`
|
||||||
ResponseModalities []string `json:"responseModalities,omitempty"`
|
ResponseModalities []string `json:"responseModalities,omitempty"`
|
||||||
ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
|
ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
|
||||||
SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
|
SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaResolution string
|
||||||
|
|
||||||
type GeminiChatCandidate struct {
|
type GeminiChatCandidate struct {
|
||||||
Content GeminiChatContent `json:"content"`
|
Content GeminiChatContent `json:"content"`
|
||||||
FinishReason *string `json:"finishReason"`
|
FinishReason *string `json:"finishReason"`
|
||||||
|
|||||||
@@ -772,11 +772,12 @@ type OpenAIResponsesRequest struct {
|
|||||||
Instructions json.RawMessage `json:"instructions,omitempty"`
|
Instructions json.RawMessage `json:"instructions,omitempty"`
|
||||||
MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
|
MaxOutputTokens uint `json:"max_output_tokens,omitempty"`
|
||||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||||
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
|
ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"`
|
||||||
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
PreviousResponseID string `json:"previous_response_id,omitempty"`
|
||||||
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
Reasoning *Reasoning `json:"reasoning,omitempty"`
|
||||||
ServiceTier string `json:"service_tier,omitempty"`
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
Store bool `json:"store,omitempty"`
|
Store json.RawMessage `json:"store,omitempty"`
|
||||||
|
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
|
||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
Text json.RawMessage `json:"text,omitempty"`
|
Text json.RawMessage `json:"text,omitempty"`
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import (
|
|||||||
"one-api/types"
|
"one-api/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResponsesOutputTypeImageGenerationCall = "image_generation_call"
|
||||||
|
)
|
||||||
|
|
||||||
type SimpleResponse struct {
|
type SimpleResponse struct {
|
||||||
Usage `json:"usage"`
|
Usage `json:"usage"`
|
||||||
Error any `json:"error"`
|
Error any `json:"error"`
|
||||||
@@ -273,6 +277,42 @@ func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError {
|
|||||||
return GetOpenAIError(o.Error)
|
return GetOpenAIError(o.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool {
|
||||||
|
if len(o.Output) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, output := range o.Output {
|
||||||
|
if output.Type == ResponsesOutputTypeImageGenerationCall {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenAIResponsesResponse) GetQuality() string {
|
||||||
|
if len(o.Output) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, output := range o.Output {
|
||||||
|
if output.Type == ResponsesOutputTypeImageGenerationCall {
|
||||||
|
return output.Quality
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OpenAIResponsesResponse) GetSize() string {
|
||||||
|
if len(o.Output) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, output := range o.Output {
|
||||||
|
if output.Type == ResponsesOutputTypeImageGenerationCall {
|
||||||
|
return output.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type IncompleteDetails struct {
|
type IncompleteDetails struct {
|
||||||
Reasoning string `json:"reasoning"`
|
Reasoning string `json:"reasoning"`
|
||||||
}
|
}
|
||||||
@@ -283,6 +323,8 @@ type ResponsesOutput struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content []ResponsesOutputContent `json:"content"`
|
Content []ResponsesOutputContent `json:"content"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Size string `json:"size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResponsesOutputContent struct {
|
type ResponsesOutputContent struct {
|
||||||
|
|||||||
26
main.go
26
main.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -16,6 +17,8 @@ import (
|
|||||||
"one-api/setting/ratio_setting"
|
"one-api/setting/ratio_setting"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/bytedance/gopkg/util/gopool"
|
"github.com/bytedance/gopkg/util/gopool"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
@@ -33,6 +36,7 @@ var buildFS embed.FS
|
|||||||
var indexPage []byte
|
var indexPage []byte
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
err := InitResources()
|
err := InitResources()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -145,11 +149,31 @@ func main() {
|
|||||||
})
|
})
|
||||||
server.Use(sessions.Sessions("session", store))
|
server.Use(sessions.Sessions("session", store))
|
||||||
|
|
||||||
|
analyticsInjectBuilder := &strings.Builder{}
|
||||||
|
if os.Getenv("UMAMI_WEBSITE_ID") != "" {
|
||||||
|
umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID")
|
||||||
|
umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL")
|
||||||
|
if umamiScriptURL == "" {
|
||||||
|
umamiScriptURL = "https://analytics.umami.is/script.js"
|
||||||
|
}
|
||||||
|
analyticsInjectBuilder.WriteString("<script defer src=\"")
|
||||||
|
analyticsInjectBuilder.WriteString(umamiScriptURL)
|
||||||
|
analyticsInjectBuilder.WriteString("\" data-website-id=\"")
|
||||||
|
analyticsInjectBuilder.WriteString(umamiSiteID)
|
||||||
|
analyticsInjectBuilder.WriteString("\"></script>")
|
||||||
|
}
|
||||||
|
analyticsInject := analyticsInjectBuilder.String()
|
||||||
|
indexPage = bytes.ReplaceAll(indexPage, []byte("<analytics></analytics>\n"), []byte(analyticsInject))
|
||||||
|
|
||||||
router.SetRouter(server, buildFS, indexPage)
|
router.SetRouter(server, buildFS, indexPage)
|
||||||
var port = os.Getenv("PORT")
|
var port = os.Getenv("PORT")
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = strconv.Itoa(*common.Port)
|
port = strconv.Itoa(*common.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log startup success message
|
||||||
|
common.LogStartupSuccess(startTime, port)
|
||||||
|
|
||||||
err = server.Run(":" + port)
|
err = server.Run(":" + port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.FatalLog("failed to start HTTP server: " + err.Error())
|
common.FatalLog("failed to start HTTP server: " + err.Error())
|
||||||
@@ -204,4 +228,4 @@ func InitResources() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func InitOptionMap() {
|
|||||||
common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret
|
common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret
|
||||||
common.OptionMap["StripePriceId"] = setting.StripePriceId
|
common.OptionMap["StripePriceId"] = setting.StripePriceId
|
||||||
common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
|
common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
|
||||||
|
common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled)
|
||||||
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
|
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
|
||||||
common.OptionMap["Chats"] = setting.Chats2JsonString()
|
common.OptionMap["Chats"] = setting.Chats2JsonString()
|
||||||
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
|
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
|
||||||
@@ -112,6 +113,9 @@ func InitOptionMap() {
|
|||||||
common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString()
|
common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString()
|
||||||
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
|
common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
|
||||||
common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
|
common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
|
||||||
|
common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
|
||||||
|
common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
|
||||||
|
common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
|
||||||
common.OptionMap["TopUpLink"] = common.TopUpLink
|
common.OptionMap["TopUpLink"] = common.TopUpLink
|
||||||
//common.OptionMap["ChatLink"] = common.ChatLink
|
//common.OptionMap["ChatLink"] = common.ChatLink
|
||||||
//common.OptionMap["ChatLink2"] = common.ChatLink2
|
//common.OptionMap["ChatLink2"] = common.ChatLink2
|
||||||
@@ -327,6 +331,8 @@ func updateOptionMap(key string, value string) (err error) {
|
|||||||
setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64)
|
setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64)
|
||||||
case "StripeMinTopUp":
|
case "StripeMinTopUp":
|
||||||
setting.StripeMinTopUp, _ = strconv.Atoi(value)
|
setting.StripeMinTopUp, _ = strconv.Atoi(value)
|
||||||
|
case "StripePromotionCodesEnabled":
|
||||||
|
setting.StripePromotionCodesEnabled = value == "true"
|
||||||
case "TopupGroupRatio":
|
case "TopupGroupRatio":
|
||||||
err = common.UpdateTopupGroupRatioByJSONString(value)
|
err = common.UpdateTopupGroupRatioByJSONString(value)
|
||||||
case "GitHubClientId":
|
case "GitHubClientId":
|
||||||
@@ -397,6 +403,12 @@ func updateOptionMap(key string, value string) (err error) {
|
|||||||
err = ratio_setting.UpdateModelPriceByJSONString(value)
|
err = ratio_setting.UpdateModelPriceByJSONString(value)
|
||||||
case "CacheRatio":
|
case "CacheRatio":
|
||||||
err = ratio_setting.UpdateCacheRatioByJSONString(value)
|
err = ratio_setting.UpdateCacheRatioByJSONString(value)
|
||||||
|
case "ImageRatio":
|
||||||
|
err = ratio_setting.UpdateImageRatioByJSONString(value)
|
||||||
|
case "AudioRatio":
|
||||||
|
err = ratio_setting.UpdateAudioRatioByJSONString(value)
|
||||||
|
case "AudioCompletionRatio":
|
||||||
|
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value)
|
||||||
case "TopUpLink":
|
case "TopUpLink":
|
||||||
common.TopUpLink = value
|
common.TopUpLink = value
|
||||||
//case "ChatLink":
|
//case "ChatLink":
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.LogError(c, "do request failed: "+err.Error())
|
||||||
return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed"))
|
return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed"))
|
||||||
}
|
}
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ var awsModelIDMap = map[string]string{
|
|||||||
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
|
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
|
||||||
"nova-pro-v1:0": "amazon.nova-pro-v1:0",
|
"nova-pro-v1:0": "amazon.nova-pro-v1:0",
|
||||||
"nova-premier-v1:0": "amazon.nova-premier-v1:0",
|
"nova-premier-v1:0": "amazon.nova-premier-v1:0",
|
||||||
|
"nova-canvas-v1:0": "amazon.nova-canvas-v1:0",
|
||||||
|
"nova-reel-v1:0": "amazon.nova-reel-v1:0",
|
||||||
|
"nova-reel-v1:1": "amazon.nova-reel-v1:1",
|
||||||
|
"nova-sonic-v1:0": "amazon.nova-sonic-v1:0",
|
||||||
}
|
}
|
||||||
|
|
||||||
var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
||||||
@@ -82,10 +86,27 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
|||||||
"apac": true,
|
"apac": true,
|
||||||
},
|
},
|
||||||
"amazon.nova-premier-v1:0": {
|
"amazon.nova-premier-v1:0": {
|
||||||
|
"us": true,
|
||||||
|
},
|
||||||
|
"amazon.nova-canvas-v1:0": {
|
||||||
"us": true,
|
"us": true,
|
||||||
"eu": true,
|
"eu": true,
|
||||||
"apac": true,
|
"apac": true,
|
||||||
}}
|
},
|
||||||
|
"amazon.nova-reel-v1:0": {
|
||||||
|
"us": true,
|
||||||
|
"eu": true,
|
||||||
|
"apac": true,
|
||||||
|
},
|
||||||
|
"amazon.nova-reel-v1:1": {
|
||||||
|
"us": true,
|
||||||
|
},
|
||||||
|
"amazon.nova-sonic-v1:0": {
|
||||||
|
"us": true,
|
||||||
|
"eu": true,
|
||||||
|
"apac": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
var awsRegionCrossModelPrefixMap = map[string]string{
|
var awsRegionCrossModelPrefixMap = map[string]string{
|
||||||
"us": "us",
|
"us": "us",
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ package deepseek
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
"one-api/relay/channel"
|
"one-api/relay/channel"
|
||||||
|
"one-api/relay/channel/claude"
|
||||||
"one-api/relay/channel/openai"
|
"one-api/relay/channel/openai"
|
||||||
relaycommon "one-api/relay/common"
|
relaycommon "one-api/relay/common"
|
||||||
"one-api/relay/constant"
|
"one-api/relay/constant"
|
||||||
"one-api/types"
|
"one-api/types"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Adaptor struct {
|
type Adaptor struct {
|
||||||
@@ -25,7 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||||
adaptor := openai.Adaptor{}
|
adaptor := claude.Adaptor{}
|
||||||
return adaptor.ConvertClaudeRequest(c, info, req)
|
return adaptor.ConvertClaudeRequest(c, info, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,14 +44,19 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
|||||||
|
|
||||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||||
fimBaseUrl := info.ChannelBaseUrl
|
fimBaseUrl := info.ChannelBaseUrl
|
||||||
if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") {
|
switch info.RelayFormat {
|
||||||
fimBaseUrl += "/beta"
|
case types.RelayFormatClaude:
|
||||||
}
|
return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil
|
||||||
switch info.RelayMode {
|
|
||||||
case constant.RelayModeCompletions:
|
|
||||||
return fmt.Sprintf("%s/completions", fimBaseUrl), nil
|
|
||||||
default:
|
default:
|
||||||
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
|
if !strings.HasSuffix(info.ChannelBaseUrl, "/beta") {
|
||||||
|
fimBaseUrl += "/beta"
|
||||||
|
}
|
||||||
|
switch info.RelayMode {
|
||||||
|
case constant.RelayModeCompletions:
|
||||||
|
return fmt.Sprintf("%s/completions", fimBaseUrl), nil
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +92,17 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||||
if info.IsStream {
|
switch info.RelayFormat {
|
||||||
usage, err = openai.OaiStreamHandler(c, info, resp)
|
case types.RelayFormatClaude:
|
||||||
} else {
|
if info.IsStream {
|
||||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage)
|
||||||
|
} else {
|
||||||
|
return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
adaptor := openai.Adaptor{}
|
||||||
|
return adaptor.DoResponse(c, resp, info)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) GetModelList() []string {
|
func (a *Adaptor) GetModelList() []string {
|
||||||
|
|||||||
@@ -215,8 +215,8 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
|||||||
|
|
||||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||||
if info.RelayMode == constant.RelayModeGemini {
|
if info.RelayMode == constant.RelayModeGemini {
|
||||||
if strings.HasSuffix(info.RequestURLPath, ":embedContent") ||
|
if strings.Contains(info.RequestURLPath, ":embedContent") ||
|
||||||
strings.HasSuffix(info.RequestURLPath, ":batchEmbedContents") {
|
strings.Contains(info.RequestURLPath, ":batchEmbedContents") {
|
||||||
return NativeGeminiEmbeddingHandler(c, resp, info)
|
return NativeGeminiEmbeddingHandler(c, resp, info)
|
||||||
}
|
}
|
||||||
if info.IsStream {
|
if info.IsStream {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference?hl=zh-cn#blob
|
||||||
var geminiSupportedMimeTypes = map[string]bool{
|
var geminiSupportedMimeTypes = map[string]bool{
|
||||||
"application/pdf": true,
|
"application/pdf": true,
|
||||||
"audio/mpeg": true,
|
"audio/mpeg": true,
|
||||||
@@ -30,6 +31,7 @@ var geminiSupportedMimeTypes = map[string]bool{
|
|||||||
"audio/wav": true,
|
"audio/wav": true,
|
||||||
"image/png": true,
|
"image/png": true,
|
||||||
"image/jpeg": true,
|
"image/jpeg": true,
|
||||||
|
"image/webp": true,
|
||||||
"text/plain": true,
|
"text/plain": true,
|
||||||
"video/mov": true,
|
"video/mov": true,
|
||||||
"video/mpeg": true,
|
"video/mpeg": true,
|
||||||
@@ -243,6 +245,7 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
|||||||
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
|
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
|
||||||
googleSearch := false
|
googleSearch := false
|
||||||
codeExecution := false
|
codeExecution := false
|
||||||
|
urlContext := false
|
||||||
for _, tool := range textRequest.Tools {
|
for _, tool := range textRequest.Tools {
|
||||||
if tool.Function.Name == "googleSearch" {
|
if tool.Function.Name == "googleSearch" {
|
||||||
googleSearch = true
|
googleSearch = true
|
||||||
@@ -252,6 +255,10 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
|||||||
codeExecution = true
|
codeExecution = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if tool.Function.Name == "urlContext" {
|
||||||
|
urlContext = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
if tool.Function.Parameters != nil {
|
if tool.Function.Parameters != nil {
|
||||||
|
|
||||||
params, ok := tool.Function.Parameters.(map[string]interface{})
|
params, ok := tool.Function.Parameters.(map[string]interface{})
|
||||||
@@ -279,6 +286,11 @@ func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
|||||||
GoogleSearch: make(map[string]string),
|
GoogleSearch: make(map[string]string),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if urlContext {
|
||||||
|
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||||
|
URLContext: make(map[string]string),
|
||||||
|
})
|
||||||
|
}
|
||||||
if len(functions) > 0 {
|
if len(functions) > 0 {
|
||||||
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||||
FunctionDeclarations: functions,
|
FunctionDeclarations: functions,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||||
adaptor := openai.Adaptor{}
|
adaptor := claude.Adaptor{}
|
||||||
return adaptor.ConvertClaudeRequest(c, info, req)
|
return adaptor.ConvertClaudeRequest(c, info, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
relaycommon "one-api/relay/common"
|
relaycommon "one-api/relay/common"
|
||||||
relayconstant "one-api/relay/constant"
|
relayconstant "one-api/relay/constant"
|
||||||
"one-api/types"
|
"one-api/types"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -17,10 +18,7 @@ import (
|
|||||||
type Adaptor struct {
|
type Adaptor struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||||
//TODO implement me
|
|
||||||
return nil, errors.New("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
||||||
openaiAdaptor := openai.Adaptor{}
|
openaiAdaptor := openai.Adaptor{}
|
||||||
@@ -31,32 +29,21 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
openaiRequest.(*dto.GeneralOpenAIRequest).StreamOptions = &dto.StreamOptions{
|
openaiRequest.(*dto.GeneralOpenAIRequest).StreamOptions = &dto.StreamOptions{
|
||||||
IncludeUsage: true,
|
IncludeUsage: true,
|
||||||
}
|
}
|
||||||
return requestOpenAI2Ollama(c, openaiRequest.(*dto.GeneralOpenAIRequest))
|
// map to ollama chat request (Claude -> OpenAI -> Ollama chat)
|
||||||
|
return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") }
|
||||||
//TODO implement me
|
|
||||||
return nil, errors.New("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||||
//TODO implement me
|
|
||||||
return nil, errors.New("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||||
if info.RelayFormat == types.RelayFormatClaude {
|
if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil }
|
||||||
return info.ChannelBaseUrl + "/v1/chat/completions", nil
|
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil }
|
||||||
}
|
return info.ChannelBaseUrl + "/api/chat", nil
|
||||||
switch info.RelayMode {
|
|
||||||
case relayconstant.RelayModeEmbeddings:
|
|
||||||
return info.ChannelBaseUrl + "/api/embed", nil
|
|
||||||
default:
|
|
||||||
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||||
@@ -66,10 +53,12 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||||
if request == nil {
|
if request == nil { return nil, errors.New("request is nil") }
|
||||||
return nil, errors.New("request is nil")
|
// decide generate or chat
|
||||||
|
if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions {
|
||||||
|
return openAIToGenerate(c, request)
|
||||||
}
|
}
|
||||||
return requestOpenAI2Ollama(c, request)
|
return openAIChatToOllamaChat(c, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||||
@@ -80,10 +69,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
|||||||
return requestOpenAI2Embeddings(request), nil
|
return requestOpenAI2Embeddings(request), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { return nil, errors.New("not implemented") }
|
||||||
// TODO implement me
|
|
||||||
return nil, errors.New("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||||
return channel.DoApiRequest(a, c, info, requestBody)
|
return channel.DoApiRequest(a, c, info, requestBody)
|
||||||
@@ -92,15 +78,13 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
|||||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||||
switch info.RelayMode {
|
switch info.RelayMode {
|
||||||
case relayconstant.RelayModeEmbeddings:
|
case relayconstant.RelayModeEmbeddings:
|
||||||
usage, err = ollamaEmbeddingHandler(c, info, resp)
|
return ollamaEmbeddingHandler(c, info, resp)
|
||||||
default:
|
default:
|
||||||
if info.IsStream {
|
if info.IsStream {
|
||||||
usage, err = openai.OaiStreamHandler(c, info, resp)
|
return ollamaStreamHandler(c, info, resp)
|
||||||
} else {
|
|
||||||
usage, err = openai.OpenaiHandler(c, info, resp)
|
|
||||||
}
|
}
|
||||||
|
return ollamaChatHandler(c, info, resp)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) GetModelList() []string {
|
func (a *Adaptor) GetModelList() []string {
|
||||||
|
|||||||
@@ -2,48 +2,69 @@ package ollama
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"one-api/dto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type OllamaRequest struct {
|
type OllamaChatMessage struct {
|
||||||
Model string `json:"model,omitempty"`
|
Role string `json:"role"`
|
||||||
Messages []dto.Message `json:"messages,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Stream bool `json:"stream,omitempty"`
|
Images []string `json:"images,omitempty"`
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
|
||||||
Seed float64 `json:"seed,omitempty"`
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
Topp float64 `json:"top_p,omitempty"`
|
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||||
TopK int `json:"top_k,omitempty"`
|
|
||||||
Stop any `json:"stop,omitempty"`
|
|
||||||
MaxTokens uint `json:"max_tokens,omitempty"`
|
|
||||||
Tools []dto.ToolCallRequest `json:"tools,omitempty"`
|
|
||||||
ResponseFormat any `json:"response_format,omitempty"`
|
|
||||||
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
|
|
||||||
PresencePenalty float64 `json:"presence_penalty,omitempty"`
|
|
||||||
Suffix any `json:"suffix,omitempty"`
|
|
||||||
StreamOptions *dto.StreamOptions `json:"stream_options,omitempty"`
|
|
||||||
Prompt any `json:"prompt,omitempty"`
|
|
||||||
Think json.RawMessage `json:"think,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type OllamaToolFunction struct {
|
||||||
Seed int `json:"seed,omitempty"`
|
Name string `json:"name"`
|
||||||
Temperature *float64 `json:"temperature,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
TopK int `json:"top_k,omitempty"`
|
Parameters interface{} `json:"parameters,omitempty"`
|
||||||
TopP float64 `json:"top_p,omitempty"`
|
}
|
||||||
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
|
|
||||||
PresencePenalty float64 `json:"presence_penalty,omitempty"`
|
type OllamaTool struct {
|
||||||
NumPredict int `json:"num_predict,omitempty"`
|
Type string `json:"type"`
|
||||||
NumCtx int `json:"num_ctx,omitempty"`
|
Function OllamaToolFunction `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OllamaToolCall struct {
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments interface{} `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OllamaChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []OllamaChatMessage `json:"messages"`
|
||||||
|
Tools interface{} `json:"tools,omitempty"`
|
||||||
|
Format interface{} `json:"format,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Options map[string]any `json:"options,omitempty"`
|
||||||
|
KeepAlive interface{} `json:"keep_alive,omitempty"`
|
||||||
|
Think json.RawMessage `json:"think,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OllamaGenerateRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Prompt string `json:"prompt,omitempty"`
|
||||||
|
Suffix string `json:"suffix,omitempty"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
Format interface{} `json:"format,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Options map[string]any `json:"options,omitempty"`
|
||||||
|
KeepAlive interface{} `json:"keep_alive,omitempty"`
|
||||||
|
Think json.RawMessage `json:"think,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OllamaEmbeddingRequest struct {
|
type OllamaEmbeddingRequest struct {
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model"`
|
||||||
Input []string `json:"input"`
|
Input interface{} `json:"input"`
|
||||||
Options *Options `json:"options,omitempty"`
|
Options map[string]any `json:"options,omitempty"`
|
||||||
|
Dimensions int `json:"dimensions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OllamaEmbeddingResponse struct {
|
type OllamaEmbeddingResponse struct {
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Embedding [][]float64 `json:"embeddings,omitempty"`
|
Embeddings [][]float64 `json:"embeddings"`
|
||||||
|
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ollama
|
package ollama
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,121 +15,176 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func requestOpenAI2Ollama(c *gin.Context, request *dto.GeneralOpenAIRequest) (*OllamaRequest, error) {
|
func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaChatRequest, error) {
|
||||||
messages := make([]dto.Message, 0, len(request.Messages))
|
chatReq := &OllamaChatRequest{
|
||||||
for _, message := range request.Messages {
|
Model: r.Model,
|
||||||
if !message.IsStringContent() {
|
Stream: r.Stream,
|
||||||
mediaMessages := message.ParseContent()
|
Options: map[string]any{},
|
||||||
for j, mediaMessage := range mediaMessages {
|
Think: r.Think,
|
||||||
if mediaMessage.Type == dto.ContentTypeImageURL {
|
}
|
||||||
imageUrl := mediaMessage.GetImageMedia()
|
if r.ResponseFormat != nil {
|
||||||
// check if not base64
|
if r.ResponseFormat.Type == "json" {
|
||||||
if strings.HasPrefix(imageUrl.Url, "http") {
|
chatReq.Format = "json"
|
||||||
fileData, err := service.GetFileBase64FromUrl(c, imageUrl.Url, "formatting image for Ollama")
|
} else if r.ResponseFormat.Type == "json_schema" {
|
||||||
if err != nil {
|
if len(r.ResponseFormat.JsonSchema) > 0 {
|
||||||
return nil, err
|
var schema any
|
||||||
|
_ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema)
|
||||||
|
chatReq.Format = schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// options mapping
|
||||||
|
if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature }
|
||||||
|
if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP }
|
||||||
|
if r.TopK != 0 { chatReq.Options["top_k"] = r.TopK }
|
||||||
|
if r.FrequencyPenalty != 0 { chatReq.Options["frequency_penalty"] = r.FrequencyPenalty }
|
||||||
|
if r.PresencePenalty != 0 { chatReq.Options["presence_penalty"] = r.PresencePenalty }
|
||||||
|
if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) }
|
||||||
|
if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) }
|
||||||
|
|
||||||
|
if r.Stop != nil {
|
||||||
|
switch v := r.Stop.(type) {
|
||||||
|
case string:
|
||||||
|
chatReq.Options["stop"] = []string{v}
|
||||||
|
case []string:
|
||||||
|
chatReq.Options["stop"] = v
|
||||||
|
case []any:
|
||||||
|
arr := make([]string,0,len(v))
|
||||||
|
for _, i := range v { if s,ok:=i.(string); ok { arr = append(arr,s) } }
|
||||||
|
if len(arr)>0 { chatReq.Options["stop"] = arr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.Tools) > 0 {
|
||||||
|
tools := make([]OllamaTool,0,len(r.Tools))
|
||||||
|
for _, t := range r.Tools {
|
||||||
|
tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}})
|
||||||
|
}
|
||||||
|
chatReq.Tools = tools
|
||||||
|
}
|
||||||
|
|
||||||
|
chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages))
|
||||||
|
for _, m := range r.Messages {
|
||||||
|
var textBuilder strings.Builder
|
||||||
|
var images []string
|
||||||
|
if m.IsStringContent() {
|
||||||
|
textBuilder.WriteString(m.StringContent())
|
||||||
|
} else {
|
||||||
|
parts := m.ParseContent()
|
||||||
|
for _, part := range parts {
|
||||||
|
if part.Type == dto.ContentTypeImageURL {
|
||||||
|
img := part.GetImageMedia()
|
||||||
|
if img != nil && img.Url != "" {
|
||||||
|
var base64Data string
|
||||||
|
if strings.HasPrefix(img.Url, "http") {
|
||||||
|
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
|
||||||
|
if err != nil { return nil, err }
|
||||||
|
base64Data = fileData.Base64Data
|
||||||
|
} else if strings.HasPrefix(img.Url, "data:") {
|
||||||
|
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { base64Data = img.Url[idx+1:] }
|
||||||
|
} else {
|
||||||
|
base64Data = img.Url
|
||||||
}
|
}
|
||||||
imageUrl.Url = fmt.Sprintf("data:%s;base64,%s", fileData.MimeType, fileData.Base64Data)
|
if base64Data != "" { images = append(images, base64Data) }
|
||||||
}
|
}
|
||||||
mediaMessage.ImageUrl = imageUrl
|
} else if part.Type == dto.ContentTypeText {
|
||||||
mediaMessages[j] = mediaMessage
|
textBuilder.WriteString(part.Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message.SetMediaContent(mediaMessages)
|
|
||||||
}
|
}
|
||||||
messages = append(messages, dto.Message{
|
cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
|
||||||
Role: message.Role,
|
if len(images)>0 { cm.Images = images }
|
||||||
Content: message.Content,
|
if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
|
||||||
ToolCalls: message.ToolCalls,
|
if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
|
||||||
ToolCallId: message.ToolCallId,
|
parsed := m.ParseToolCalls()
|
||||||
})
|
if len(parsed) > 0 {
|
||||||
|
calls := make([]OllamaToolCall,0,len(parsed))
|
||||||
|
for _, tc := range parsed {
|
||||||
|
var args interface{}
|
||||||
|
if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) }
|
||||||
|
if args==nil { args = map[string]any{} }
|
||||||
|
oc := OllamaToolCall{}
|
||||||
|
oc.Function.Name = tc.Function.Name
|
||||||
|
oc.Function.Arguments = args
|
||||||
|
calls = append(calls, oc)
|
||||||
|
}
|
||||||
|
cm.ToolCalls = calls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chatReq.Messages = append(chatReq.Messages, cm)
|
||||||
}
|
}
|
||||||
str, ok := request.Stop.(string)
|
return chatReq, nil
|
||||||
var Stop []string
|
|
||||||
if ok {
|
|
||||||
Stop = []string{str}
|
|
||||||
} else {
|
|
||||||
Stop, _ = request.Stop.([]string)
|
|
||||||
}
|
|
||||||
ollamaRequest := &OllamaRequest{
|
|
||||||
Model: request.Model,
|
|
||||||
Messages: messages,
|
|
||||||
Stream: request.Stream,
|
|
||||||
Temperature: request.Temperature,
|
|
||||||
Seed: request.Seed,
|
|
||||||
Topp: request.TopP,
|
|
||||||
TopK: request.TopK,
|
|
||||||
Stop: Stop,
|
|
||||||
Tools: request.Tools,
|
|
||||||
MaxTokens: request.GetMaxTokens(),
|
|
||||||
ResponseFormat: request.ResponseFormat,
|
|
||||||
FrequencyPenalty: request.FrequencyPenalty,
|
|
||||||
PresencePenalty: request.PresencePenalty,
|
|
||||||
Prompt: request.Prompt,
|
|
||||||
StreamOptions: request.StreamOptions,
|
|
||||||
Suffix: request.Suffix,
|
|
||||||
}
|
|
||||||
ollamaRequest.Think = request.Think
|
|
||||||
return ollamaRequest, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestOpenAI2Embeddings(request dto.EmbeddingRequest) *OllamaEmbeddingRequest {
|
// openAIToGenerate converts OpenAI completions request to Ollama generate
|
||||||
return &OllamaEmbeddingRequest{
|
func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGenerateRequest, error) {
|
||||||
Model: request.Model,
|
gen := &OllamaGenerateRequest{
|
||||||
Input: request.ParseInput(),
|
Model: r.Model,
|
||||||
Options: &Options{
|
Stream: r.Stream,
|
||||||
Seed: int(request.Seed),
|
Options: map[string]any{},
|
||||||
Temperature: request.Temperature,
|
Think: r.Think,
|
||||||
TopP: request.TopP,
|
|
||||||
FrequencyPenalty: request.FrequencyPenalty,
|
|
||||||
PresencePenalty: request.PresencePenalty,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
// Prompt may be in r.Prompt (string or []any)
|
||||||
|
if r.Prompt != nil {
|
||||||
|
switch v := r.Prompt.(type) {
|
||||||
|
case string:
|
||||||
|
gen.Prompt = v
|
||||||
|
case []any:
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, it := range v { if s,ok:=it.(string); ok { sb.WriteString(s) } }
|
||||||
|
gen.Prompt = sb.String()
|
||||||
|
default:
|
||||||
|
gen.Prompt = fmt.Sprintf("%v", r.Prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { gen.Suffix = s } }
|
||||||
|
if r.ResponseFormat != nil {
|
||||||
|
if r.ResponseFormat.Type == "json" { gen.Format = "json" } else if r.ResponseFormat.Type == "json_schema" { var schema any; _ = json.Unmarshal(r.ResponseFormat.JsonSchema,&schema); gen.Format=schema }
|
||||||
|
}
|
||||||
|
if r.Temperature != nil { gen.Options["temperature"] = r.Temperature }
|
||||||
|
if r.TopP != 0 { gen.Options["top_p"] = r.TopP }
|
||||||
|
if r.TopK != 0 { gen.Options["top_k"] = r.TopK }
|
||||||
|
if r.FrequencyPenalty != 0 { gen.Options["frequency_penalty"] = r.FrequencyPenalty }
|
||||||
|
if r.PresencePenalty != 0 { gen.Options["presence_penalty"] = r.PresencePenalty }
|
||||||
|
if r.Seed != 0 { gen.Options["seed"] = int(r.Seed) }
|
||||||
|
if mt := r.GetMaxTokens(); mt != 0 { gen.Options["num_predict"] = int(mt) }
|
||||||
|
if r.Stop != nil {
|
||||||
|
switch v := r.Stop.(type) {
|
||||||
|
case string: gen.Options["stop"] = []string{v}
|
||||||
|
case []string: gen.Options["stop"] = v
|
||||||
|
case []any: arr:=make([]string,0,len(v)); for _,i:= range v { if s,ok:=i.(string); ok { arr=append(arr,s) } }; if len(arr)>0 { gen.Options["stop"]=arr }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gen, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest {
|
||||||
|
opts := map[string]any{}
|
||||||
|
if r.Temperature != nil { opts["temperature"] = r.Temperature }
|
||||||
|
if r.TopP != 0 { opts["top_p"] = r.TopP }
|
||||||
|
if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty }
|
||||||
|
if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty }
|
||||||
|
if r.Seed != 0 { opts["seed"] = int(r.Seed) }
|
||||||
|
if r.Dimensions != 0 { opts["dimensions"] = r.Dimensions }
|
||||||
|
input := r.ParseInput()
|
||||||
|
if len(input)==1 { return &OllamaEmbeddingRequest{Model:r.Model, Input: input[0], Options: opts, Dimensions:r.Dimensions} }
|
||||||
|
return &OllamaEmbeddingRequest{Model:r.Model, Input: input, Options: opts, Dimensions:r.Dimensions}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||||
var ollamaEmbeddingResponse OllamaEmbeddingResponse
|
var oResp OllamaEmbeddingResponse
|
||||||
responseBody, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
service.CloseResponseBodyGracefully(resp)
|
service.CloseResponseBodyGracefully(resp)
|
||||||
err = common.Unmarshal(responseBody, &ollamaEmbeddingResponse)
|
if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||||
if err != nil {
|
if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
data := make([]dto.OpenAIEmbeddingResponseItem,0,len(oResp.Embeddings))
|
||||||
}
|
for i, emb := range oResp.Embeddings { data = append(data, dto.OpenAIEmbeddingResponseItem{Index:i,Object:"embedding",Embedding:emb}) }
|
||||||
if ollamaEmbeddingResponse.Error != "" {
|
usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount}
|
||||||
return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", ollamaEmbeddingResponse.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage}
|
||||||
}
|
out, _ := common.Marshal(embResp)
|
||||||
flattenedEmbeddings := flattenEmbeddings(ollamaEmbeddingResponse.Embedding)
|
service.IOCopyBytesGracefully(c, resp, out)
|
||||||
data := make([]dto.OpenAIEmbeddingResponseItem, 0, 1)
|
|
||||||
data = append(data, dto.OpenAIEmbeddingResponseItem{
|
|
||||||
Embedding: flattenedEmbeddings,
|
|
||||||
Object: "embedding",
|
|
||||||
})
|
|
||||||
usage := &dto.Usage{
|
|
||||||
TotalTokens: info.PromptTokens,
|
|
||||||
CompletionTokens: 0,
|
|
||||||
PromptTokens: info.PromptTokens,
|
|
||||||
}
|
|
||||||
embeddingResponse := &dto.OpenAIEmbeddingResponse{
|
|
||||||
Object: "list",
|
|
||||||
Data: data,
|
|
||||||
Model: info.UpstreamModelName,
|
|
||||||
Usage: *usage,
|
|
||||||
}
|
|
||||||
doResponseBody, err := common.Marshal(embeddingResponse)
|
|
||||||
if err != nil {
|
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
service.IOCopyBytesGracefully(c, resp, doResponseBody)
|
|
||||||
return usage, nil
|
return usage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func flattenEmbeddings(embeddings [][]float64) []float64 {
|
|
||||||
flattened := []float64{}
|
|
||||||
for _, row := range embeddings {
|
|
||||||
flattened = append(flattened, row...)
|
|
||||||
}
|
|
||||||
return flattened
|
|
||||||
}
|
|
||||||
|
|||||||
210
relay/channel/ollama/stream.go
Normal file
210
relay/channel/ollama/stream.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package ollama
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"one-api/common"
|
||||||
|
"one-api/dto"
|
||||||
|
"one-api/logger"
|
||||||
|
relaycommon "one-api/relay/common"
|
||||||
|
"one-api/relay/helper"
|
||||||
|
"one-api/service"
|
||||||
|
"one-api/types"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ollamaChatStreamChunk struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
// chat
|
||||||
|
Message *struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Thinking json.RawMessage `json:"thinking"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments interface{} `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
} `json:"message"`
|
||||||
|
// generate
|
||||||
|
Response string `json:"response"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
DoneReason string `json:"done_reason"`
|
||||||
|
TotalDuration int64 `json:"total_duration"`
|
||||||
|
LoadDuration int64 `json:"load_duration"`
|
||||||
|
PromptEvalCount int `json:"prompt_eval_count"`
|
||||||
|
EvalCount int `json:"eval_count"`
|
||||||
|
PromptEvalDuration int64 `json:"prompt_eval_duration"`
|
||||||
|
EvalDuration int64 `json:"eval_duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUnix(ts string) int64 {
|
||||||
|
if ts == "" { return time.Now().Unix() }
|
||||||
|
// try time.RFC3339 or with nanoseconds
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, ts)
|
||||||
|
if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() }
|
||||||
|
return t.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||||
|
if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) }
|
||||||
|
defer service.CloseResponseBodyGracefully(resp)
|
||||||
|
|
||||||
|
helper.SetEventStreamHeaders(c)
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
usage := &dto.Usage{}
|
||||||
|
var model = info.UpstreamModelName
|
||||||
|
var responseId = common.GetUUID()
|
||||||
|
var created = time.Now().Unix()
|
||||||
|
var toolCallIndex int
|
||||||
|
start := helper.GenerateStartEmptyResponse(responseId, created, model, nil)
|
||||||
|
if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) }
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" { continue }
|
||||||
|
var chunk ollamaChatStreamChunk
|
||||||
|
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
|
||||||
|
logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line)
|
||||||
|
return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
if chunk.Model != "" { model = chunk.Model }
|
||||||
|
created = toUnix(chunk.CreatedAt)
|
||||||
|
|
||||||
|
if !chunk.Done {
|
||||||
|
// delta content
|
||||||
|
var content string
|
||||||
|
if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response }
|
||||||
|
delta := dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: responseId,
|
||||||
|
Object: "chat.completion.chunk",
|
||||||
|
Created: created,
|
||||||
|
Model: model,
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{ {
|
||||||
|
Index: 0,
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" },
|
||||||
|
} },
|
||||||
|
}
|
||||||
|
if content != "" { delta.Choices[0].Delta.SetContentString(content) }
|
||||||
|
if chunk.Message != nil && len(chunk.Message.Thinking) > 0 {
|
||||||
|
raw := strings.TrimSpace(string(chunk.Message.Thinking))
|
||||||
|
if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) }
|
||||||
|
}
|
||||||
|
// tool calls
|
||||||
|
if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 {
|
||||||
|
delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls))
|
||||||
|
for _, tc := range chunk.Message.ToolCalls {
|
||||||
|
// arguments -> string
|
||||||
|
argBytes, _ := json.Marshal(tc.Function.Arguments)
|
||||||
|
toolId := fmt.Sprintf("call_%d", toolCallIndex)
|
||||||
|
tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}}
|
||||||
|
tr.SetIndex(toolCallIndex)
|
||||||
|
toolCallIndex++
|
||||||
|
delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// done frame
|
||||||
|
// finalize once and break loop
|
||||||
|
usage.PromptTokens = chunk.PromptEvalCount
|
||||||
|
usage.CompletionTokens = chunk.EvalCount
|
||||||
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
|
finishReason := chunk.DoneReason
|
||||||
|
if finishReason == "" { finishReason = "stop" }
|
||||||
|
// emit stop delta
|
||||||
|
if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil {
|
||||||
|
if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) }
|
||||||
|
}
|
||||||
|
// emit usage frame
|
||||||
|
if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil {
|
||||||
|
if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) }
|
||||||
|
}
|
||||||
|
// send [DONE]
|
||||||
|
helper.Done(c)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) }
|
||||||
|
return usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-stream handler for chat/generate
|
||||||
|
func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) }
|
||||||
|
service.CloseResponseBodyGracefully(resp)
|
||||||
|
raw := string(body)
|
||||||
|
if common.DebugEnabled { println("ollama non-stream raw resp:", raw) }
|
||||||
|
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
var (
|
||||||
|
aggContent strings.Builder
|
||||||
|
reasoningBuilder strings.Builder
|
||||||
|
lastChunk ollamaChatStreamChunk
|
||||||
|
parsedAny bool
|
||||||
|
)
|
||||||
|
for _, ln := range lines {
|
||||||
|
ln = strings.TrimSpace(ln)
|
||||||
|
if ln == "" { continue }
|
||||||
|
var ck ollamaChatStreamChunk
|
||||||
|
if err := json.Unmarshal([]byte(ln), &ck); err != nil {
|
||||||
|
if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsedAny = true
|
||||||
|
lastChunk = ck
|
||||||
|
if ck.Message != nil && len(ck.Message.Thinking) > 0 {
|
||||||
|
raw := strings.TrimSpace(string(ck.Message.Thinking))
|
||||||
|
if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) }
|
||||||
|
}
|
||||||
|
if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !parsedAny {
|
||||||
|
var single ollamaChatStreamChunk
|
||||||
|
if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
|
||||||
|
lastChunk = single
|
||||||
|
if single.Message != nil {
|
||||||
|
if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } }
|
||||||
|
aggContent.WriteString(single.Message.Content)
|
||||||
|
} else { aggContent.WriteString(single.Response) }
|
||||||
|
}
|
||||||
|
|
||||||
|
model := lastChunk.Model
|
||||||
|
if model == "" { model = info.UpstreamModelName }
|
||||||
|
created := toUnix(lastChunk.CreatedAt)
|
||||||
|
usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount}
|
||||||
|
content := aggContent.String()
|
||||||
|
finishReason := lastChunk.DoneReason
|
||||||
|
if finishReason == "" { finishReason = "stop" }
|
||||||
|
|
||||||
|
msg := dto.Message{Role: "assistant", Content: contentPtr(content)}
|
||||||
|
if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc }
|
||||||
|
full := dto.OpenAITextResponse{
|
||||||
|
Id: common.GetUUID(),
|
||||||
|
Model: model,
|
||||||
|
Object: "chat.completion",
|
||||||
|
Created: created,
|
||||||
|
Choices: []dto.OpenAITextResponseChoice{ {
|
||||||
|
Index: 0,
|
||||||
|
Message: msg,
|
||||||
|
FinishReason: finishReason,
|
||||||
|
} },
|
||||||
|
Usage: *usage,
|
||||||
|
}
|
||||||
|
out, _ := common.Marshal(full)
|
||||||
|
service.IOCopyBytesGracefully(c, resp, out)
|
||||||
|
return usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentPtr(s string) *string { if s=="" { return nil }; return &s }
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"one-api/constant"
|
"one-api/constant"
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
"one-api/logger"
|
"one-api/logger"
|
||||||
|
"one-api/relay/channel/openrouter"
|
||||||
relaycommon "one-api/relay/common"
|
relaycommon "one-api/relay/common"
|
||||||
"one-api/relay/helper"
|
"one-api/relay/helper"
|
||||||
"one-api/service"
|
"one-api/service"
|
||||||
@@ -185,10 +186,27 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
|||||||
if common.DebugEnabled {
|
if common.DebugEnabled {
|
||||||
println("upstream response body:", string(responseBody))
|
println("upstream response body:", string(responseBody))
|
||||||
}
|
}
|
||||||
|
// Unmarshal to simpleResponse
|
||||||
|
if info.ChannelType == constant.ChannelTypeOpenRouter && info.ChannelOtherSettings.IsOpenRouterEnterprise() {
|
||||||
|
// 尝试解析为 openrouter enterprise
|
||||||
|
var enterpriseResponse openrouter.OpenRouterEnterpriseResponse
|
||||||
|
err = common.Unmarshal(responseBody, &enterpriseResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
if enterpriseResponse.Success {
|
||||||
|
responseBody = enterpriseResponse.Data
|
||||||
|
} else {
|
||||||
|
logger.LogError(c, fmt.Sprintf("openrouter enterprise response success=false, data: %s", enterpriseResponse.Data))
|
||||||
|
return nil, types.NewOpenAIError(fmt.Errorf("openrouter response success=false"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = common.Unmarshal(responseBody, &simpleResponse)
|
err = common.Unmarshal(responseBody, &simpleResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
||||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
|||||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if responsesResponse.HasImageGenerationCall() {
|
||||||
|
c.Set("image_generation_call", true)
|
||||||
|
c.Set("image_generation_call_quality", responsesResponse.GetQuality())
|
||||||
|
c.Set("image_generation_call_size", responsesResponse.GetSize())
|
||||||
|
}
|
||||||
|
|
||||||
// 写入新的 response body
|
// 写入新的 response body
|
||||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||||
|
|
||||||
@@ -80,18 +86,25 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
|||||||
sendResponsesStreamData(c, streamResponse, data)
|
sendResponsesStreamData(c, streamResponse, data)
|
||||||
switch streamResponse.Type {
|
switch streamResponse.Type {
|
||||||
case "response.completed":
|
case "response.completed":
|
||||||
if streamResponse.Response != nil && streamResponse.Response.Usage != nil {
|
if streamResponse.Response != nil {
|
||||||
if streamResponse.Response.Usage.InputTokens != 0 {
|
if streamResponse.Response.Usage != nil {
|
||||||
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
|
if streamResponse.Response.Usage.InputTokens != 0 {
|
||||||
|
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
|
||||||
|
}
|
||||||
|
if streamResponse.Response.Usage.OutputTokens != 0 {
|
||||||
|
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
|
||||||
|
}
|
||||||
|
if streamResponse.Response.Usage.TotalTokens != 0 {
|
||||||
|
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
|
||||||
|
}
|
||||||
|
if streamResponse.Response.Usage.InputTokensDetails != nil {
|
||||||
|
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if streamResponse.Response.Usage.OutputTokens != 0 {
|
if streamResponse.Response.HasImageGenerationCall() {
|
||||||
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
|
c.Set("image_generation_call", true)
|
||||||
}
|
c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())
|
||||||
if streamResponse.Response.Usage.TotalTokens != 0 {
|
c.Set("image_generation_call_size", streamResponse.Response.GetSize())
|
||||||
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
|
|
||||||
}
|
|
||||||
if streamResponse.Response.Usage.InputTokensDetails != nil {
|
|
||||||
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "response.output_text.delta":
|
case "response.output_text.delta":
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package openrouter
|
package openrouter
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
type RequestReasoning struct {
|
type RequestReasoning struct {
|
||||||
// One of the following (not both):
|
// One of the following (not both):
|
||||||
Effort string `json:"effort,omitempty"` // Can be "high", "medium", or "low" (OpenAI-style)
|
Effort string `json:"effort,omitempty"` // Can be "high", "medium", or "low" (OpenAI-style)
|
||||||
@@ -7,3 +9,8 @@ type RequestReasoning struct {
|
|||||||
// Optional: Default is false. All models support this.
|
// Optional: Default is false. All models support this.
|
||||||
Exclude bool `json:"exclude,omitempty"` // Set to true to exclude reasoning tokens from response
|
Exclude bool `json:"exclude,omitempty"` // Set to true to exclude reasoning tokens from response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenRouterEnterpriseResponse struct {
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type requestPayload struct {
|
|||||||
Prompt string `json:"prompt,omitempty"`
|
Prompt string `json:"prompt,omitempty"`
|
||||||
Seed int64 `json:"seed"`
|
Seed int64 `json:"seed"`
|
||||||
AspectRatio string `json:"aspect_ratio"`
|
AspectRatio string `json:"aspect_ratio"`
|
||||||
|
Frames int `json:"frames,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type responsePayload struct {
|
type responsePayload struct {
|
||||||
@@ -93,6 +94,9 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
|
|||||||
|
|
||||||
// BuildRequestURL constructs the upstream URL.
|
// BuildRequestURL constructs the upstream URL.
|
||||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||||
|
if isNewAPIRelay(info.ApiKey) {
|
||||||
|
return fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
|
||||||
|
}
|
||||||
return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
|
return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +104,12 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
|
|||||||
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
return a.signRequest(req, a.accessKey, a.secretKey)
|
if isNewAPIRelay(info.ApiKey) {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
|
||||||
|
} else {
|
||||||
|
return a.signRequest(req, a.accessKey, a.secretKey)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildRequestBody converts request into Jimeng specific format.
|
// BuildRequestBody converts request into Jimeng specific format.
|
||||||
@@ -160,6 +169,9 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
|
|||||||
}
|
}
|
||||||
|
|
||||||
uri := fmt.Sprintf("%s/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl)
|
uri := fmt.Sprintf("%s/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl)
|
||||||
|
if isNewAPIRelay(key) {
|
||||||
|
uri = fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncGetResult&Version=2022-08-31", a.baseURL)
|
||||||
|
}
|
||||||
payload := map[string]string{
|
payload := map[string]string{
|
||||||
"req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774
|
"req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774
|
||||||
"task_id": taskID,
|
"task_id": taskID,
|
||||||
@@ -177,17 +189,20 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
|
|||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
keyParts := strings.Split(key, "|")
|
if isNewAPIRelay(key) {
|
||||||
if len(keyParts) != 2 {
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'")
|
} else {
|
||||||
}
|
keyParts := strings.Split(key, "|")
|
||||||
accessKey := strings.TrimSpace(keyParts[0])
|
if len(keyParts) != 2 {
|
||||||
secretKey := strings.TrimSpace(keyParts[1])
|
return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'")
|
||||||
|
}
|
||||||
|
accessKey := strings.TrimSpace(keyParts[0])
|
||||||
|
secretKey := strings.TrimSpace(keyParts[1])
|
||||||
|
|
||||||
if err := a.signRequest(req, accessKey, secretKey); err != nil {
|
if err := a.signRequest(req, accessKey, secretKey); err != nil {
|
||||||
return nil, errors.Wrap(err, "sign request failed")
|
return nil, errors.Wrap(err, "sign request failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return service.GetHttpClient().Do(req)
|
return service.GetHttpClient().Do(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,10 +326,15 @@ func hmacSHA256(key []byte, data []byte) []byte {
|
|||||||
|
|
||||||
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
|
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
|
||||||
r := requestPayload{
|
r := requestPayload{
|
||||||
ReqKey: "jimeng_vgfm_i2v_l20",
|
ReqKey: req.Model,
|
||||||
Prompt: req.Prompt,
|
Prompt: req.Prompt,
|
||||||
AspectRatio: "16:9", // Default aspect ratio
|
}
|
||||||
Seed: -1, // Default to random
|
|
||||||
|
switch req.Duration {
|
||||||
|
case 10:
|
||||||
|
r.Frames = 241 // 24*10+1 = 241
|
||||||
|
default:
|
||||||
|
r.Frames = 121 // 24*5+1 = 121
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle one-of image_urls or binary_data_base64
|
// Handle one-of image_urls or binary_data_base64
|
||||||
@@ -334,6 +354,22 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "unmarshal metadata failed")
|
return nil, errors.Wrap(err, "unmarshal metadata failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 即梦视频3.0 ReqKey转换
|
||||||
|
// https://www.volcengine.com/docs/85621/1792707
|
||||||
|
if strings.Contains(r.ReqKey, "jimeng_v30") {
|
||||||
|
if len(r.ImageUrls) > 1 {
|
||||||
|
// 多张图片:首尾帧生成
|
||||||
|
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1)
|
||||||
|
} else if len(r.ImageUrls) == 1 {
|
||||||
|
// 单张图片:图生视频
|
||||||
|
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1)
|
||||||
|
} else {
|
||||||
|
// 无图片:文生视频
|
||||||
|
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &r, nil
|
return &r, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,3 +398,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
|
|||||||
taskResult.Url = resTask.Data.VideoUrl
|
taskResult.Url = resTask.Data.VideoUrl
|
||||||
return &taskResult, nil
|
return &taskResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isNewAPIRelay(apiKey string) bool {
|
||||||
|
return strings.HasPrefix(apiKey, "sk-")
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
|
|||||||
// BuildRequestURL constructs the upstream URL.
|
// BuildRequestURL constructs the upstream URL.
|
||||||
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||||
path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
|
path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
|
||||||
|
|
||||||
|
if isNewAPIRelay(info.ApiKey) {
|
||||||
|
return fmt.Sprintf("%s/kling%s", a.baseURL, path), nil
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s%s", a.baseURL, path), nil
|
return fmt.Sprintf("%s%s", a.baseURL, path), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +204,9 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http
|
|||||||
}
|
}
|
||||||
path := lo.Ternary(action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
|
path := lo.Ternary(action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video")
|
||||||
url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID)
|
url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID)
|
||||||
|
if isNewAPIRelay(key) {
|
||||||
|
url = fmt.Sprintf("%s/kling%s/%s", baseUrl, path, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -304,8 +312,13 @@ func (a *TaskAdaptor) createJWTToken() (string, error) {
|
|||||||
//}
|
//}
|
||||||
|
|
||||||
func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
|
func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) {
|
||||||
|
if isNewAPIRelay(apiKey) {
|
||||||
|
return apiKey, nil // new api relay
|
||||||
|
}
|
||||||
keyParts := strings.Split(apiKey, "|")
|
keyParts := strings.Split(apiKey, "|")
|
||||||
|
if len(keyParts) != 2 {
|
||||||
|
return "", errors.New("invalid api_key, required format is accessKey|secretKey")
|
||||||
|
}
|
||||||
accessKey := strings.TrimSpace(keyParts[0])
|
accessKey := strings.TrimSpace(keyParts[0])
|
||||||
if len(keyParts) == 1 {
|
if len(keyParts) == 1 {
|
||||||
return accessKey, nil
|
return accessKey, nil
|
||||||
@@ -352,3 +365,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
|
|||||||
}
|
}
|
||||||
return taskInfo, nil
|
return taskInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isNewAPIRelay(apiKey string) bool {
|
||||||
|
return strings.HasPrefix(apiKey, "sk-")
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,8 +80,7 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
|
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError {
|
||||||
// Use the unified validation method for TaskSubmitReq with image-based action determination
|
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
|
||||||
return relaycommon.ValidateTaskRequestWithImageBinding(c, info)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
|
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) {
|
||||||
@@ -112,6 +111,10 @@ func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, erro
|
|||||||
switch info.Action {
|
switch info.Action {
|
||||||
case constant.TaskActionGenerate:
|
case constant.TaskActionGenerate:
|
||||||
path = "/img2video"
|
path = "/img2video"
|
||||||
|
case constant.TaskActionFirstTailGenerate:
|
||||||
|
path = "/start-end2video"
|
||||||
|
case constant.TaskActionReferenceGenerate:
|
||||||
|
path = "/reference2video"
|
||||||
default:
|
default:
|
||||||
path = "/text2video"
|
path = "/text2video"
|
||||||
}
|
}
|
||||||
@@ -187,14 +190,9 @@ func (a *TaskAdaptor) GetChannelName() string {
|
|||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
|
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
|
||||||
var images []string
|
|
||||||
if req.Image != "" {
|
|
||||||
images = []string{req.Image}
|
|
||||||
}
|
|
||||||
|
|
||||||
r := requestPayload{
|
r := requestPayload{
|
||||||
Model: defaultString(req.Model, "viduq1"),
|
Model: defaultString(req.Model, "viduq1"),
|
||||||
Images: images,
|
Images: req.Images,
|
||||||
Prompt: req.Prompt,
|
Prompt: req.Prompt,
|
||||||
Duration: defaultInt(req.Duration, 5),
|
Duration: defaultInt(req.Duration, 5),
|
||||||
Resolution: defaultString(req.Size, "1080p"),
|
Resolution: defaultString(req.Size, "1080p"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
channelconstant "one-api/constant"
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
"one-api/relay/channel"
|
"one-api/relay/channel"
|
||||||
"one-api/relay/channel/openai"
|
"one-api/relay/channel/openai"
|
||||||
@@ -41,6 +42,8 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
|||||||
|
|
||||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||||
switch info.RelayMode {
|
switch info.RelayMode {
|
||||||
|
case constant.RelayModeImagesGenerations:
|
||||||
|
return request, nil
|
||||||
case constant.RelayModeImagesEdits:
|
case constant.RelayModeImagesEdits:
|
||||||
|
|
||||||
var requestBody bytes.Buffer
|
var requestBody bytes.Buffer
|
||||||
@@ -186,20 +189,26 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||||
|
// 支持自定义域名,如果未设置则使用默认域名
|
||||||
|
baseUrl := info.ChannelBaseUrl
|
||||||
|
if baseUrl == "" {
|
||||||
|
baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
|
||||||
|
}
|
||||||
|
|
||||||
switch info.RelayMode {
|
switch info.RelayMode {
|
||||||
case constant.RelayModeChatCompletions:
|
case constant.RelayModeChatCompletions:
|
||||||
if strings.HasPrefix(info.UpstreamModelName, "bot") {
|
if strings.HasPrefix(info.UpstreamModelName, "bot") {
|
||||||
return fmt.Sprintf("%s/api/v3/bots/chat/completions", info.ChannelBaseUrl), nil
|
return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s/api/v3/chat/completions", info.ChannelBaseUrl), nil
|
return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil
|
||||||
case constant.RelayModeEmbeddings:
|
case constant.RelayModeEmbeddings:
|
||||||
return fmt.Sprintf("%s/api/v3/embeddings", info.ChannelBaseUrl), nil
|
return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
|
||||||
case constant.RelayModeImagesGenerations:
|
case constant.RelayModeImagesGenerations:
|
||||||
return fmt.Sprintf("%s/api/v3/images/generations", info.ChannelBaseUrl), nil
|
return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil
|
||||||
case constant.RelayModeImagesEdits:
|
case constant.RelayModeImagesEdits:
|
||||||
return fmt.Sprintf("%s/api/v3/images/edits", info.ChannelBaseUrl), nil
|
return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
|
||||||
case constant.RelayModeRerank:
|
case constant.RelayModeRerank:
|
||||||
return fmt.Sprintf("%s/api/v3/rerank", info.ChannelBaseUrl), nil
|
return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
|
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ var ModelList = []string{
|
|||||||
"Doubao-lite-32k",
|
"Doubao-lite-32k",
|
||||||
"Doubao-lite-4k",
|
"Doubao-lite-4k",
|
||||||
"Doubao-embedding",
|
"Doubao-embedding",
|
||||||
|
"doubao-seedream-4-0-250828",
|
||||||
|
"seedream-4-0-250828",
|
||||||
|
"doubao-seedance-1-0-pro-250528",
|
||||||
|
"seedance-1-0-pro-250528",
|
||||||
|
"doubao-seed-1-6-thinking-250715",
|
||||||
|
"seed-1-6-thinking-250715",
|
||||||
}
|
}
|
||||||
|
|
||||||
var ChannelName = "volcengine"
|
var ChannelName = "volcengine"
|
||||||
|
|||||||
@@ -207,10 +207,6 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
|
||||||
conn.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
data := requestOpenAI2Xunfei(textRequest, appId, domain)
|
data := requestOpenAI2Xunfei(textRequest, appId, domain)
|
||||||
err = conn.WriteJSON(data)
|
err = conn.WriteJSON(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,6 +216,9 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
|
|||||||
dataChan := make(chan XunfeiChatResponse)
|
dataChan := make(chan XunfeiChatResponse)
|
||||||
stopChan := make(chan bool)
|
stopChan := make(chan bool)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
conn.Close()
|
||||||
|
}()
|
||||||
for {
|
for {
|
||||||
_, msg, err := conn.ReadMessage()
|
_, msg, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
"one-api/common"
|
||||||
|
"one-api/constant"
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
relaycommon "one-api/relay/common"
|
relaycommon "one-api/relay/common"
|
||||||
"one-api/relay/helper"
|
"one-api/relay/helper"
|
||||||
@@ -69,6 +70,31 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
|||||||
info.UpstreamModelName = request.Model
|
info.UpstreamModelName = request.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if info.ChannelSetting.SystemPrompt != "" {
|
||||||
|
if request.System == nil {
|
||||||
|
request.SetStringSystem(info.ChannelSetting.SystemPrompt)
|
||||||
|
} else if info.ChannelSetting.SystemPromptOverride {
|
||||||
|
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
|
||||||
|
if request.IsStringSystem() {
|
||||||
|
existing := strings.TrimSpace(request.GetStringSystem())
|
||||||
|
if existing == "" {
|
||||||
|
request.SetStringSystem(info.ChannelSetting.SystemPrompt)
|
||||||
|
} else {
|
||||||
|
request.SetStringSystem(info.ChannelSetting.SystemPrompt + "\n" + existing)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
systemContents := request.ParseSystem()
|
||||||
|
newSystem := dto.ClaudeMediaMessage{Type: dto.ContentTypeText}
|
||||||
|
newSystem.SetText(info.ChannelSetting.SystemPrompt)
|
||||||
|
if len(systemContents) == 0 {
|
||||||
|
request.System = []dto.ClaudeMediaMessage{newSystem}
|
||||||
|
} else {
|
||||||
|
request.System = append([]dto.ClaudeMediaMessage{newSystem}, systemContents...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var requestBody io.Reader
|
var requestBody io.Reader
|
||||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
|
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
|
||||||
body, err := common.GetRequestBody(c)
|
body, err := common.GetRequestBody(c)
|
||||||
|
|||||||
@@ -79,34 +79,18 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
|
|||||||
req.Images = []string{req.Image}
|
req.Images = []string{req.Image}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.HasImage() {
|
||||||
|
action = constant.TaskActionGenerate
|
||||||
|
if info.ChannelType == constant.ChannelTypeVidu {
|
||||||
|
// vidu 增加 首尾帧生视频和参考图生视频
|
||||||
|
if len(req.Images) == 2 {
|
||||||
|
action = constant.TaskActionFirstTailGenerate
|
||||||
|
} else if len(req.Images) > 2 {
|
||||||
|
action = constant.TaskActionReferenceGenerate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
storeTaskRequest(c, info, action, req)
|
storeTaskRequest(c, info, action, req)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateTaskRequestWithImage(c *gin.Context, info *RelayInfo, requestObj interface{}) *dto.TaskError {
|
|
||||||
hasPrompt, ok := requestObj.(HasPrompt)
|
|
||||||
if !ok {
|
|
||||||
return createTaskError(fmt.Errorf("request must have prompt"), "invalid_request", http.StatusBadRequest, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if taskErr := validatePrompt(hasPrompt.GetPrompt()); taskErr != nil {
|
|
||||||
return taskErr
|
|
||||||
}
|
|
||||||
|
|
||||||
action := constant.TaskActionTextGenerate
|
|
||||||
if hasImage, ok := requestObj.(HasImage); ok && hasImage.HasImage() {
|
|
||||||
action = constant.TaskActionGenerate
|
|
||||||
}
|
|
||||||
|
|
||||||
storeTaskRequest(c, info, action, requestObj)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateTaskRequestWithImageBinding(c *gin.Context, info *RelayInfo) *dto.TaskError {
|
|
||||||
var req TaskSubmitReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
return createTaskError(err, "invalid_request_body", http.StatusBadRequest, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ValidateTaskRequestWithImage(c, info, req)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -90,41 +90,43 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
|||||||
|
|
||||||
if info.ChannelSetting.SystemPrompt != "" {
|
if info.ChannelSetting.SystemPrompt != "" {
|
||||||
// 如果有系统提示,则将其添加到请求中
|
// 如果有系统提示,则将其添加到请求中
|
||||||
request := convertedRequest.(*dto.GeneralOpenAIRequest)
|
request, ok := convertedRequest.(*dto.GeneralOpenAIRequest)
|
||||||
containSystemPrompt := false
|
if ok {
|
||||||
for _, message := range request.Messages {
|
containSystemPrompt := false
|
||||||
if message.Role == request.GetSystemRoleName() {
|
for _, message := range request.Messages {
|
||||||
containSystemPrompt = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !containSystemPrompt {
|
|
||||||
// 如果没有系统提示,则添加系统提示
|
|
||||||
systemMessage := dto.Message{
|
|
||||||
Role: request.GetSystemRoleName(),
|
|
||||||
Content: info.ChannelSetting.SystemPrompt,
|
|
||||||
}
|
|
||||||
request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
|
|
||||||
} else if info.ChannelSetting.SystemPromptOverride {
|
|
||||||
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
|
|
||||||
// 如果有系统提示,且允许覆盖,则拼接到前面
|
|
||||||
for i, message := range request.Messages {
|
|
||||||
if message.Role == request.GetSystemRoleName() {
|
if message.Role == request.GetSystemRoleName() {
|
||||||
if message.IsStringContent() {
|
containSystemPrompt = true
|
||||||
request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
|
|
||||||
} else {
|
|
||||||
contents := message.ParseContent()
|
|
||||||
contents = append([]dto.MediaContent{
|
|
||||||
{
|
|
||||||
Type: dto.ContentTypeText,
|
|
||||||
Text: info.ChannelSetting.SystemPrompt,
|
|
||||||
},
|
|
||||||
}, contents...)
|
|
||||||
request.Messages[i].Content = contents
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !containSystemPrompt {
|
||||||
|
// 如果没有系统提示,则添加系统提示
|
||||||
|
systemMessage := dto.Message{
|
||||||
|
Role: request.GetSystemRoleName(),
|
||||||
|
Content: info.ChannelSetting.SystemPrompt,
|
||||||
|
}
|
||||||
|
request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
|
||||||
|
} else if info.ChannelSetting.SystemPromptOverride {
|
||||||
|
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
|
||||||
|
// 如果有系统提示,且允许覆盖,则拼接到前面
|
||||||
|
for i, message := range request.Messages {
|
||||||
|
if message.Role == request.GetSystemRoleName() {
|
||||||
|
if message.IsStringContent() {
|
||||||
|
request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent())
|
||||||
|
} else {
|
||||||
|
contents := message.ParseContent()
|
||||||
|
contents = append([]dto.MediaContent{
|
||||||
|
{
|
||||||
|
Type: dto.ContentTypeText,
|
||||||
|
Text: info.ChannelSetting.SystemPrompt,
|
||||||
|
},
|
||||||
|
}, contents...)
|
||||||
|
request.Messages[i].Content = contents
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +278,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
|||||||
fileSearchTool.CallCount, dFileSearchQuota.String())
|
fileSearchTool.CallCount, dFileSearchQuota.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var dImageGenerationCallQuota decimal.Decimal
|
||||||
|
var imageGenerationCallPrice float64
|
||||||
|
if ctx.GetBool("image_generation_call") {
|
||||||
|
imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size"))
|
||||||
|
dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit)
|
||||||
|
extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String())
|
||||||
|
}
|
||||||
|
|
||||||
var quotaCalculateDecimal decimal.Decimal
|
var quotaCalculateDecimal decimal.Decimal
|
||||||
|
|
||||||
@@ -331,6 +340,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
|||||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
|
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
|
||||||
// 添加 audio input 独立计费
|
// 添加 audio input 独立计费
|
||||||
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
|
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
|
||||||
|
// 添加 image generation call 计费
|
||||||
|
quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)
|
||||||
|
|
||||||
quota := int(quotaCalculateDecimal.Round(0).IntPart())
|
quota := int(quotaCalculateDecimal.Round(0).IntPart())
|
||||||
totalTokens := promptTokens + completionTokens
|
totalTokens := promptTokens + completionTokens
|
||||||
@@ -429,6 +440,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
|||||||
other["audio_input_token_count"] = audioTokens
|
other["audio_input_token_count"] = audioTokens
|
||||||
other["audio_input_price"] = audioInputPrice
|
other["audio_input_price"] = audioInputPrice
|
||||||
}
|
}
|
||||||
|
if !dImageGenerationCallQuota.IsZero() {
|
||||||
|
other["image_generation_call"] = true
|
||||||
|
other["image_generation_call_price"] = imageGenerationCallPrice
|
||||||
|
}
|
||||||
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
|
||||||
ChannelId: relayInfo.ChannelId,
|
ChannelId: relayInfo.ChannelId,
|
||||||
PromptTokens: promptTokens,
|
PromptTokens: promptTokens,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"one-api/common"
|
"one-api/common"
|
||||||
|
"one-api/constant"
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
"one-api/logger"
|
"one-api/logger"
|
||||||
"one-api/relay/channel/gemini"
|
"one-api/relay/channel/gemini"
|
||||||
@@ -94,6 +95,32 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
|||||||
|
|
||||||
adaptor.Init(info)
|
adaptor.Init(info)
|
||||||
|
|
||||||
|
if info.ChannelSetting.SystemPrompt != "" {
|
||||||
|
if request.SystemInstructions == nil {
|
||||||
|
request.SystemInstructions = &dto.GeminiChatContent{
|
||||||
|
Parts: []dto.GeminiPart{
|
||||||
|
{Text: info.ChannelSetting.SystemPrompt},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if len(request.SystemInstructions.Parts) == 0 {
|
||||||
|
request.SystemInstructions.Parts = []dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}
|
||||||
|
} else if info.ChannelSetting.SystemPromptOverride {
|
||||||
|
common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true)
|
||||||
|
merged := false
|
||||||
|
for i := range request.SystemInstructions.Parts {
|
||||||
|
if request.SystemInstructions.Parts[i].Text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
request.SystemInstructions.Parts[i].Text = info.ChannelSetting.SystemPrompt + "\n" + request.SystemInstructions.Parts[i].Text
|
||||||
|
merged = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !merged {
|
||||||
|
request.SystemInstructions.Parts = append([]dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}, request.SystemInstructions.Parts...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up empty system instruction
|
// Clean up empty system instruction
|
||||||
if request.SystemInstructions != nil {
|
if request.SystemInstructions != nil {
|
||||||
hasContent := false
|
hasContent := false
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
|||||||
var cacheRatio float64
|
var cacheRatio float64
|
||||||
var imageRatio float64
|
var imageRatio float64
|
||||||
var cacheCreationRatio float64
|
var cacheCreationRatio float64
|
||||||
|
var audioRatio float64
|
||||||
|
var audioCompletionRatio float64
|
||||||
if !usePrice {
|
if !usePrice {
|
||||||
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
|
preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota)
|
||||||
if meta.MaxTokens != 0 {
|
if meta.MaxTokens != 0 {
|
||||||
@@ -73,6 +75,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
|||||||
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
|
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
|
||||||
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
|
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
|
||||||
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
|
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
|
||||||
|
audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
|
||||||
|
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
|
||||||
ratio := modelRatio * groupRatioInfo.GroupRatio
|
ratio := modelRatio * groupRatioInfo.GroupRatio
|
||||||
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
|
preConsumedQuota = int(float64(preConsumedTokens) * ratio)
|
||||||
} else {
|
} else {
|
||||||
@@ -90,6 +94,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
|||||||
UsePrice: usePrice,
|
UsePrice: usePrice,
|
||||||
CacheRatio: cacheRatio,
|
CacheRatio: cacheRatio,
|
||||||
ImageRatio: imageRatio,
|
ImageRatio: imageRatio,
|
||||||
|
AudioRatio: audioRatio,
|
||||||
|
AudioCompletionRatio: audioCompletionRatio,
|
||||||
CacheCreationRatio: cacheCreationRatio,
|
CacheCreationRatio: cacheCreationRatio,
|
||||||
ShouldPreConsumedQuota: preConsumedQuota,
|
ShouldPreConsumedQuota: preConsumedQuota,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt
|
|||||||
case types.RelayFormatOpenAI:
|
case types.RelayFormatOpenAI:
|
||||||
request, err = GetAndValidateTextRequest(c, relayMode)
|
request, err = GetAndValidateTextRequest(c, relayMode)
|
||||||
case types.RelayFormatGemini:
|
case types.RelayFormatGemini:
|
||||||
request, err = GetAndValidateGeminiRequest(c)
|
if strings.Contains(c.Request.URL.Path, ":embedContent") || strings.Contains(c.Request.URL.Path, ":batchEmbedContents") {
|
||||||
|
request, err = GetAndValidateGeminiEmbeddingRequest(c)
|
||||||
|
} else {
|
||||||
|
request, err = GetAndValidateGeminiRequest(c)
|
||||||
|
}
|
||||||
case types.RelayFormatClaude:
|
case types.RelayFormatClaude:
|
||||||
request, err = GetAndValidateClaudeRequest(c)
|
request, err = GetAndValidateClaudeRequest(c)
|
||||||
case types.RelayFormatOpenAIResponses:
|
case types.RelayFormatOpenAIResponses:
|
||||||
@@ -288,7 +292,6 @@ func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenA
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) {
|
func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) {
|
||||||
|
|
||||||
request := &dto.GeminiChatRequest{}
|
request := &dto.GeminiChatRequest{}
|
||||||
err := common.UnmarshalBodyReusable(c, request)
|
err := common.UnmarshalBodyReusable(c, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -304,3 +307,12 @@ func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error)
|
|||||||
|
|
||||||
return request, nil
|
return request, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetAndValidateGeminiEmbeddingRequest(c *gin.Context) (*dto.GeminiEmbeddingRequest, error) {
|
||||||
|
request := &dto.GeminiEmbeddingRequest{}
|
||||||
|
err := common.UnmarshalBodyReusable(c, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) {
|
|||||||
return nil, fmt.Errorf("only support https url")
|
return nil, fmt.Errorf("only support https url")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSRF防护:验证请求URL
|
||||||
|
fetchSetting := system_setting.GetFetchSetting()
|
||||||
|
if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||||
|
return nil, fmt.Errorf("request reject: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
workerUrl := system_setting.WorkerUrl
|
workerUrl := system_setting.WorkerUrl
|
||||||
if !strings.HasSuffix(workerUrl, "/") {
|
if !strings.HasSuffix(workerUrl, "/") {
|
||||||
workerUrl += "/"
|
workerUrl += "/"
|
||||||
@@ -51,7 +57,13 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response,
|
|||||||
}
|
}
|
||||||
return DoWorkerRequest(req)
|
return DoWorkerRequest(req)
|
||||||
} else {
|
} else {
|
||||||
common.SysLog(fmt.Sprintf("downloading from origin with worker: %s, reason: %s", originUrl, strings.Join(reason, ", ")))
|
// SSRF防护:验证请求URL(非Worker模式)
|
||||||
|
fetchSetting := system_setting.GetFetchSetting()
|
||||||
|
if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||||
|
return nil, fmt.Errorf("request reject: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", ")))
|
||||||
return http.Get(originUrl)
|
return http.Get(originUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,12 +7,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"one-api/common"
|
"one-api/common"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
var httpClient *http.Client
|
var (
|
||||||
|
httpClient *http.Client
|
||||||
|
proxyClientLock sync.Mutex
|
||||||
|
proxyClients = make(map[string]*http.Client)
|
||||||
|
)
|
||||||
|
|
||||||
func InitHttpClient() {
|
func InitHttpClient() {
|
||||||
if common.RelayTimeout == 0 {
|
if common.RelayTimeout == 0 {
|
||||||
@@ -28,12 +33,31 @@ func GetHttpClient() *http.Client {
|
|||||||
return httpClient
|
return httpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetProxyClientCache 清空代理客户端缓存,确保下次使用时重新初始化
|
||||||
|
func ResetProxyClientCache() {
|
||||||
|
proxyClientLock.Lock()
|
||||||
|
defer proxyClientLock.Unlock()
|
||||||
|
for _, client := range proxyClients {
|
||||||
|
if transport, ok := client.Transport.(*http.Transport); ok && transport != nil {
|
||||||
|
transport.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proxyClients = make(map[string]*http.Client)
|
||||||
|
}
|
||||||
|
|
||||||
// NewProxyHttpClient 创建支持代理的 HTTP 客户端
|
// NewProxyHttpClient 创建支持代理的 HTTP 客户端
|
||||||
func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
|
func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
|
||||||
if proxyURL == "" {
|
if proxyURL == "" {
|
||||||
return http.DefaultClient, nil
|
return http.DefaultClient, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyClientLock.Lock()
|
||||||
|
if client, ok := proxyClients[proxyURL]; ok {
|
||||||
|
proxyClientLock.Unlock()
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
proxyClientLock.Unlock()
|
||||||
|
|
||||||
parsedURL, err := url.Parse(proxyURL)
|
parsedURL, err := url.Parse(proxyURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -41,11 +65,16 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
|
|||||||
|
|
||||||
switch parsedURL.Scheme {
|
switch parsedURL.Scheme {
|
||||||
case "http", "https":
|
case "http", "https":
|
||||||
return &http.Client{
|
client := &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
Proxy: http.ProxyURL(parsedURL),
|
Proxy: http.ProxyURL(parsedURL),
|
||||||
},
|
},
|
||||||
}, nil
|
}
|
||||||
|
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
|
||||||
|
proxyClientLock.Lock()
|
||||||
|
proxyClients[proxyURL] = client
|
||||||
|
proxyClientLock.Unlock()
|
||||||
|
return client, nil
|
||||||
|
|
||||||
case "socks5", "socks5h":
|
case "socks5", "socks5h":
|
||||||
// 获取认证信息
|
// 获取认证信息
|
||||||
@@ -67,13 +96,18 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &http.Client{
|
client := &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
return dialer.Dial(network, addr)
|
return dialer.Dial(network, addr)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil
|
}
|
||||||
|
client.Timeout = time.Duration(common.RelayTimeout) * time.Second
|
||||||
|
proxyClientLock.Lock()
|
||||||
|
proxyClients[proxyURL] = client
|
||||||
|
proxyClientLock.Unlock()
|
||||||
|
return client, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", parsedURL.Scheme)
|
return nil, fmt.Errorf("unsupported proxy scheme: %s", parsedURL.Scheme)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
|||||||
gopool.Go(func() {
|
gopool.Go(func() {
|
||||||
relayInfoCopy := *relayInfo
|
relayInfoCopy := *relayInfo
|
||||||
|
|
||||||
err := PostConsumeQuota(&relayInfoCopy, -relayInfo.FinalPreConsumedQuota, 0, false)
|
err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.SysLog("error return pre-consumed quota: " + err.Error())
|
common.SysLog("error return pre-consumed quota: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
|
|||||||
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
|
return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// SSRF防护:验证Bark URL(非Worker模式)
|
||||||
|
fetchSetting := system_setting.GetFetchSetting()
|
||||||
|
if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||||
|
return fmt.Errorf("request reject: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 直接发送请求
|
// 直接发送请求
|
||||||
req, err = http.NewRequest(http.MethodGet, finalURL, nil)
|
req, err = http.NewRequest(http.MethodGet, finalURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"one-api/common"
|
||||||
"one-api/dto"
|
"one-api/dto"
|
||||||
"one-api/setting/system_setting"
|
"one-api/setting/system_setting"
|
||||||
"time"
|
"time"
|
||||||
@@ -86,6 +87,12 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error
|
|||||||
return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode)
|
return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// SSRF防护:验证Webhook URL(非Worker模式)
|
||||||
|
fetchSetting := system_setting.GetFetchSetting()
|
||||||
|
if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
|
||||||
|
return fmt.Errorf("request reject: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
|
req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create webhook request: %v", err)
|
return fmt.Errorf("failed to create webhook request: %v", err)
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ const (
|
|||||||
FileSearchPrice = 2.5
|
FileSearchPrice = 2.5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
GPTImage1Low1024x1024 = 0.011
|
||||||
|
GPTImage1Low1024x1536 = 0.016
|
||||||
|
GPTImage1Low1536x1024 = 0.016
|
||||||
|
GPTImage1Medium1024x1024 = 0.042
|
||||||
|
GPTImage1Medium1024x1536 = 0.063
|
||||||
|
GPTImage1Medium1536x1024 = 0.063
|
||||||
|
GPTImage1High1024x1024 = 0.167
|
||||||
|
GPTImage1High1024x1536 = 0.25
|
||||||
|
GPTImage1High1536x1024 = 0.25
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Gemini Audio Input Price
|
// Gemini Audio Input Price
|
||||||
Gemini25FlashPreviewInputAudioPrice = 1.00
|
Gemini25FlashPreviewInputAudioPrice = 1.00
|
||||||
@@ -65,3 +77,31 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetGPTImage1PriceOnceCall(quality string, size string) float64 {
|
||||||
|
prices := map[string]map[string]float64{
|
||||||
|
"low": {
|
||||||
|
"1024x1024": GPTImage1Low1024x1024,
|
||||||
|
"1024x1536": GPTImage1Low1024x1536,
|
||||||
|
"1536x1024": GPTImage1Low1536x1024,
|
||||||
|
},
|
||||||
|
"medium": {
|
||||||
|
"1024x1024": GPTImage1Medium1024x1024,
|
||||||
|
"1024x1536": GPTImage1Medium1024x1536,
|
||||||
|
"1536x1024": GPTImage1Medium1536x1024,
|
||||||
|
},
|
||||||
|
"high": {
|
||||||
|
"1024x1024": GPTImage1High1024x1024,
|
||||||
|
"1024x1536": GPTImage1High1024x1536,
|
||||||
|
"1536x1024": GPTImage1High1536x1024,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if qualityMap, exists := prices[quality]; exists {
|
||||||
|
if price, exists := qualityMap[size]; exists {
|
||||||
|
return price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GPTImage1High1024x1024
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ var StripeWebhookSecret = ""
|
|||||||
var StripePriceId = ""
|
var StripePriceId = ""
|
||||||
var StripeUnitPrice = 8.0
|
var StripeUnitPrice = 8.0
|
||||||
var StripeMinTopUp = 1
|
var StripeMinTopUp = 1
|
||||||
|
var StripePromotionCodesEnabled = false
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ var defaultModelRatio = map[string]float64{
|
|||||||
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
|
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
|
||||||
"gemini-2.5-flash-lite-preview-06-17": 0.05,
|
"gemini-2.5-flash-lite-preview-06-17": 0.05,
|
||||||
"gemini-2.5-flash": 0.15,
|
"gemini-2.5-flash": 0.15,
|
||||||
|
"gemini-embedding-001": 0.075,
|
||||||
"text-embedding-004": 0.001,
|
"text-embedding-004": 0.001,
|
||||||
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
|
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
|
||||||
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
|
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
|
||||||
@@ -289,6 +290,18 @@ var defaultModelPrice = map[string]float64{
|
|||||||
"mj_upload": 0.05,
|
"mj_upload": 0.05,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var defaultAudioRatio = map[string]float64{
|
||||||
|
"gpt-4o-audio-preview": 16,
|
||||||
|
"gpt-4o-mini-audio-preview": 66.67,
|
||||||
|
"gpt-4o-realtime-preview": 8,
|
||||||
|
"gpt-4o-mini-realtime-preview": 16.67,
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultAudioCompletionRatio = map[string]float64{
|
||||||
|
"gpt-4o-realtime": 2,
|
||||||
|
"gpt-4o-mini-realtime": 2,
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
modelPriceMap map[string]float64 = nil
|
modelPriceMap map[string]float64 = nil
|
||||||
modelPriceMapMutex = sync.RWMutex{}
|
modelPriceMapMutex = sync.RWMutex{}
|
||||||
@@ -337,6 +350,15 @@ func InitRatioSettings() {
|
|||||||
imageRatioMap = defaultImageRatio
|
imageRatioMap = defaultImageRatio
|
||||||
imageRatioMapMutex.Unlock()
|
imageRatioMapMutex.Unlock()
|
||||||
|
|
||||||
|
// initialize audioRatioMap
|
||||||
|
audioRatioMapMutex.Lock()
|
||||||
|
audioRatioMap = defaultAudioRatio
|
||||||
|
audioRatioMapMutex.Unlock()
|
||||||
|
|
||||||
|
// initialize audioCompletionRatioMap
|
||||||
|
audioCompletionRatioMapMutex.Lock()
|
||||||
|
audioCompletionRatioMap = defaultAudioCompletionRatio
|
||||||
|
audioCompletionRatioMapMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetModelPriceMap() map[string]float64 {
|
func GetModelPriceMap() map[string]float64 {
|
||||||
@@ -428,6 +450,18 @@ func GetDefaultModelRatioMap() map[string]float64 {
|
|||||||
return defaultModelRatio
|
return defaultModelRatio
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetDefaultImageRatioMap() map[string]float64 {
|
||||||
|
return defaultImageRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDefaultAudioRatioMap() map[string]float64 {
|
||||||
|
return defaultAudioRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDefaultAudioCompletionRatioMap() map[string]float64 {
|
||||||
|
return defaultAudioCompletionRatio
|
||||||
|
}
|
||||||
|
|
||||||
func GetCompletionRatioMap() map[string]float64 {
|
func GetCompletionRatioMap() map[string]float64 {
|
||||||
CompletionRatioMutex.RLock()
|
CompletionRatioMutex.RLock()
|
||||||
defer CompletionRatioMutex.RUnlock()
|
defer CompletionRatioMutex.RUnlock()
|
||||||
@@ -595,32 +629,22 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetAudioRatio(name string) float64 {
|
func GetAudioRatio(name string) float64 {
|
||||||
if strings.Contains(name, "-realtime") {
|
audioRatioMapMutex.RLock()
|
||||||
if strings.HasSuffix(name, "gpt-4o-realtime-preview") {
|
defer audioRatioMapMutex.RUnlock()
|
||||||
return 8
|
name = FormatMatchingModelName(name)
|
||||||
} else if strings.Contains(name, "gpt-4o-mini-realtime-preview") {
|
if ratio, ok := audioRatioMap[name]; ok {
|
||||||
return 10 / 0.6
|
return ratio
|
||||||
} else {
|
|
||||||
return 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(name, "-audio") {
|
|
||||||
if strings.HasPrefix(name, "gpt-4o-audio-preview") {
|
|
||||||
return 40 / 2.5
|
|
||||||
} else if strings.HasPrefix(name, "gpt-4o-mini-audio-preview") {
|
|
||||||
return 10 / 0.15
|
|
||||||
} else {
|
|
||||||
return 40
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 20
|
return 20
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAudioCompletionRatio(name string) float64 {
|
func GetAudioCompletionRatio(name string) float64 {
|
||||||
if strings.HasPrefix(name, "gpt-4o-realtime") {
|
audioCompletionRatioMapMutex.RLock()
|
||||||
return 2
|
defer audioCompletionRatioMapMutex.RUnlock()
|
||||||
} else if strings.HasPrefix(name, "gpt-4o-mini-realtime") {
|
name = FormatMatchingModelName(name)
|
||||||
return 2
|
if ratio, ok := audioCompletionRatioMap[name]; ok {
|
||||||
|
|
||||||
|
return ratio
|
||||||
}
|
}
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -641,6 +665,14 @@ var defaultImageRatio = map[string]float64{
|
|||||||
}
|
}
|
||||||
var imageRatioMap map[string]float64
|
var imageRatioMap map[string]float64
|
||||||
var imageRatioMapMutex sync.RWMutex
|
var imageRatioMapMutex sync.RWMutex
|
||||||
|
var (
|
||||||
|
audioRatioMap map[string]float64 = nil
|
||||||
|
audioRatioMapMutex = sync.RWMutex{}
|
||||||
|
)
|
||||||
|
var (
|
||||||
|
audioCompletionRatioMap map[string]float64 = nil
|
||||||
|
audioCompletionRatioMapMutex = sync.RWMutex{}
|
||||||
|
)
|
||||||
|
|
||||||
func ImageRatio2JSONString() string {
|
func ImageRatio2JSONString() string {
|
||||||
imageRatioMapMutex.RLock()
|
imageRatioMapMutex.RLock()
|
||||||
@@ -669,6 +701,71 @@ func GetImageRatio(name string) (float64, bool) {
|
|||||||
return ratio, true
|
return ratio, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AudioRatio2JSONString() string {
|
||||||
|
audioRatioMapMutex.RLock()
|
||||||
|
defer audioRatioMapMutex.RUnlock()
|
||||||
|
jsonBytes, err := common.Marshal(audioRatioMap)
|
||||||
|
if err != nil {
|
||||||
|
common.SysError("error marshalling audio ratio: " + err.Error())
|
||||||
|
}
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateAudioRatioByJSONString(jsonStr string) error {
|
||||||
|
|
||||||
|
tmp := make(map[string]float64)
|
||||||
|
if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
audioRatioMapMutex.Lock()
|
||||||
|
audioRatioMap = tmp
|
||||||
|
audioRatioMapMutex.Unlock()
|
||||||
|
InvalidateExposedDataCache()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAudioRatioCopy() map[string]float64 {
|
||||||
|
audioRatioMapMutex.RLock()
|
||||||
|
defer audioRatioMapMutex.RUnlock()
|
||||||
|
copyMap := make(map[string]float64, len(audioRatioMap))
|
||||||
|
for k, v := range audioRatioMap {
|
||||||
|
copyMap[k] = v
|
||||||
|
}
|
||||||
|
return copyMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func AudioCompletionRatio2JSONString() string {
|
||||||
|
audioCompletionRatioMapMutex.RLock()
|
||||||
|
defer audioCompletionRatioMapMutex.RUnlock()
|
||||||
|
jsonBytes, err := common.Marshal(audioCompletionRatioMap)
|
||||||
|
if err != nil {
|
||||||
|
common.SysError("error marshalling audio completion ratio: " + err.Error())
|
||||||
|
}
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateAudioCompletionRatioByJSONString(jsonStr string) error {
|
||||||
|
tmp := make(map[string]float64)
|
||||||
|
if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
audioCompletionRatioMapMutex.Lock()
|
||||||
|
audioCompletionRatioMap = tmp
|
||||||
|
audioCompletionRatioMapMutex.Unlock()
|
||||||
|
InvalidateExposedDataCache()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAudioCompletionRatioCopy() map[string]float64 {
|
||||||
|
audioCompletionRatioMapMutex.RLock()
|
||||||
|
defer audioCompletionRatioMapMutex.RUnlock()
|
||||||
|
copyMap := make(map[string]float64, len(audioCompletionRatioMap))
|
||||||
|
for k, v := range audioCompletionRatioMap {
|
||||||
|
copyMap[k] = v
|
||||||
|
}
|
||||||
|
return copyMap
|
||||||
|
}
|
||||||
|
|
||||||
func GetModelRatioCopy() map[string]float64 {
|
func GetModelRatioCopy() map[string]float64 {
|
||||||
modelRatioMapMutex.RLock()
|
modelRatioMapMutex.RLock()
|
||||||
defer modelRatioMapMutex.RUnlock()
|
defer modelRatioMapMutex.RUnlock()
|
||||||
|
|||||||
34
setting/system_setting/fetch_setting.go
Normal file
34
setting/system_setting/fetch_setting.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package system_setting
|
||||||
|
|
||||||
|
import "one-api/setting/config"
|
||||||
|
|
||||||
|
type FetchSetting struct {
|
||||||
|
EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
|
||||||
|
AllowPrivateIp bool `json:"allow_private_ip"`
|
||||||
|
DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式
|
||||||
|
IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式
|
||||||
|
DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com
|
||||||
|
IpList []string `json:"ip_list"` // CIDR format
|
||||||
|
AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
|
||||||
|
ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤(实验性)
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultFetchSetting = FetchSetting{
|
||||||
|
EnableSSRFProtection: true, // 默认开启SSRF防护
|
||||||
|
AllowPrivateIp: false,
|
||||||
|
DomainFilterMode: false,
|
||||||
|
IpFilterMode: false,
|
||||||
|
DomainList: []string{},
|
||||||
|
IpList: []string{},
|
||||||
|
AllowedPorts: []string{"80", "443", "8080", "8443"},
|
||||||
|
ApplyIPFilterForDomain: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// 注册到全局配置管理器
|
||||||
|
config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFetchSetting() *FetchSetting {
|
||||||
|
return &defaultFetchSetting
|
||||||
|
}
|
||||||
@@ -122,6 +122,9 @@ func (e *NewAPIError) MaskSensitiveError() string {
|
|||||||
return string(e.errorCode)
|
return string(e.errorCode)
|
||||||
}
|
}
|
||||||
errStr := e.Err.Error()
|
errStr := e.Err.Error()
|
||||||
|
if e.errorCode == ErrorCodeCountTokenFailed {
|
||||||
|
return errStr
|
||||||
|
}
|
||||||
return common.MaskSensitiveInfo(errStr)
|
return common.MaskSensitiveInfo(errStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +156,9 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
|
|||||||
Code: e.errorCode,
|
Code: e.errorCode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if e.errorCode != ErrorCodeCountTokenFailed {
|
||||||
result.Message = common.MaskSensitiveInfo(result.Message)
|
result.Message = common.MaskSensitiveInfo(result.Message)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +182,9 @@ func (e *NewAPIError) ToClaudeError() ClaudeError {
|
|||||||
Type: string(e.errorType),
|
Type: string(e.errorType),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.Message = common.MaskSensitiveInfo(result.Message)
|
if e.errorCode != ErrorCodeCountTokenFailed {
|
||||||
|
result.Message = common.MaskSensitiveInfo(result.Message)
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type PriceData struct {
|
|||||||
CacheRatio float64
|
CacheRatio float64
|
||||||
CacheCreationRatio float64
|
CacheCreationRatio float64
|
||||||
ImageRatio float64
|
ImageRatio float64
|
||||||
|
AudioRatio float64
|
||||||
|
AudioCompletionRatio float64
|
||||||
UsePrice bool
|
UsePrice bool
|
||||||
ShouldPreConsumedQuota int
|
ShouldPreConsumedQuota int
|
||||||
GroupRatioInfo GroupRatioInfo
|
GroupRatioInfo GroupRatioInfo
|
||||||
@@ -27,5 +29,5 @@ type PerCallPriceData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p PriceData) ToSetting() string {
|
func (p PriceData) ToSetting() string {
|
||||||
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio)
|
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure,可用于二次分发管理 key,仅单可执行文件,已打包好 Docker 镜像,一键部署,开箱即用"
|
||||||
/>
|
/>
|
||||||
<title>New API</title>
|
<title>New API</title>
|
||||||
|
<analytics></analytics>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
9
web/jsconfig.json
Normal file
9
web/jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -181,8 +181,8 @@ export function PreCode(props) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
const code =
|
const codeElement = ref.current.querySelector('code');
|
||||||
ref.current.querySelector('code')?.innerText ?? '';
|
const code = codeElement?.textContent ?? '';
|
||||||
copy(code).then((success) => {
|
copy(code).then((success) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
Toast.success(t('代码已复制到剪贴板'));
|
Toast.success(t('代码已复制到剪贴板'));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
|
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
@@ -39,6 +39,7 @@ const UserArea = ({
|
|||||||
navigate,
|
navigate,
|
||||||
t,
|
t,
|
||||||
}) => {
|
}) => {
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<SkeletonWrapper
|
<SkeletonWrapper
|
||||||
@@ -52,90 +53,93 @@ const UserArea = ({
|
|||||||
|
|
||||||
if (userState.user) {
|
if (userState.user) {
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<div className='relative' ref={dropdownRef}>
|
||||||
position='bottomRight'
|
<Dropdown
|
||||||
render={
|
position='bottomRight'
|
||||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
getPopupContainer={() => dropdownRef.current}
|
||||||
<Dropdown.Item
|
render={
|
||||||
onClick={() => {
|
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
||||||
navigate('/console/personal');
|
<Dropdown.Item
|
||||||
}}
|
onClick={() => {
|
||||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
navigate('/console/personal');
|
||||||
>
|
}}
|
||||||
<div className='flex items-center gap-2'>
|
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||||
<IconUserSetting
|
>
|
||||||
size='small'
|
<div className='flex items-center gap-2'>
|
||||||
className='text-gray-500 dark:text-gray-400'
|
<IconUserSetting
|
||||||
/>
|
size='small'
|
||||||
<span>{t('个人设置')}</span>
|
className='text-gray-500 dark:text-gray-400'
|
||||||
</div>
|
/>
|
||||||
</Dropdown.Item>
|
<span>{t('个人设置')}</span>
|
||||||
<Dropdown.Item
|
</div>
|
||||||
onClick={() => {
|
</Dropdown.Item>
|
||||||
navigate('/console/token');
|
<Dropdown.Item
|
||||||
}}
|
onClick={() => {
|
||||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
navigate('/console/token');
|
||||||
>
|
}}
|
||||||
<div className='flex items-center gap-2'>
|
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||||
<IconKey
|
>
|
||||||
size='small'
|
<div className='flex items-center gap-2'>
|
||||||
className='text-gray-500 dark:text-gray-400'
|
<IconKey
|
||||||
/>
|
size='small'
|
||||||
<span>{t('令牌管理')}</span>
|
className='text-gray-500 dark:text-gray-400'
|
||||||
</div>
|
/>
|
||||||
</Dropdown.Item>
|
<span>{t('令牌管理')}</span>
|
||||||
<Dropdown.Item
|
</div>
|
||||||
onClick={() => {
|
</Dropdown.Item>
|
||||||
navigate('/console/topup');
|
<Dropdown.Item
|
||||||
}}
|
onClick={() => {
|
||||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
navigate('/console/topup');
|
||||||
>
|
}}
|
||||||
<div className='flex items-center gap-2'>
|
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||||
<IconCreditCard
|
>
|
||||||
size='small'
|
<div className='flex items-center gap-2'>
|
||||||
className='text-gray-500 dark:text-gray-400'
|
<IconCreditCard
|
||||||
/>
|
size='small'
|
||||||
<span>{t('钱包管理')}</span>
|
className='text-gray-500 dark:text-gray-400'
|
||||||
</div>
|
/>
|
||||||
</Dropdown.Item>
|
<span>{t('钱包管理')}</span>
|
||||||
<Dropdown.Item
|
</div>
|
||||||
onClick={logout}
|
</Dropdown.Item>
|
||||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
|
<Dropdown.Item
|
||||||
>
|
onClick={logout}
|
||||||
<div className='flex items-center gap-2'>
|
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
|
||||||
<IconExit
|
>
|
||||||
size='small'
|
<div className='flex items-center gap-2'>
|
||||||
className='text-gray-500 dark:text-gray-400'
|
<IconExit
|
||||||
/>
|
size='small'
|
||||||
<span>{t('退出')}</span>
|
className='text-gray-500 dark:text-gray-400'
|
||||||
</div>
|
/>
|
||||||
</Dropdown.Item>
|
<span>{t('退出')}</span>
|
||||||
</Dropdown.Menu>
|
</div>
|
||||||
}
|
</Dropdown.Item>
|
||||||
>
|
</Dropdown.Menu>
|
||||||
<Button
|
}
|
||||||
theme='borderless'
|
|
||||||
type='tertiary'
|
|
||||||
className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
|
||||||
>
|
>
|
||||||
<Avatar
|
<Button
|
||||||
size='extra-small'
|
theme='borderless'
|
||||||
color={stringToColor(userState.user.username)}
|
type='tertiary'
|
||||||
className='mr-1'
|
className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
||||||
>
|
>
|
||||||
{userState.user.username[0].toUpperCase()}
|
<Avatar
|
||||||
</Avatar>
|
size='extra-small'
|
||||||
<span className='hidden md:inline'>
|
color={stringToColor(userState.user.username)}
|
||||||
<Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
|
className='mr-1'
|
||||||
{userState.user.username}
|
>
|
||||||
</Typography.Text>
|
{userState.user.username[0].toUpperCase()}
|
||||||
</span>
|
</Avatar>
|
||||||
<ChevronDown
|
<span className='hidden md:inline'>
|
||||||
size={14}
|
<Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
|
||||||
className='text-xs text-semi-color-text-2 dark:text-gray-400'
|
{userState.user.username}
|
||||||
/>
|
</Typography.Text>
|
||||||
</Button>
|
</span>
|
||||||
</Dropdown>
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className='text-xs text-semi-color-text-2 dark:text-gray-400'
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const showRegisterButton = !isSelfUseMode;
|
const showRegisterButton = !isSelfUseMode;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const PaymentSetting = () => {
|
|||||||
StripePriceId: '',
|
StripePriceId: '',
|
||||||
StripeUnitPrice: 8.0,
|
StripeUnitPrice: 8.0,
|
||||||
StripeMinTopUp: 1,
|
StripeMinTopUp: 1,
|
||||||
|
StripePromotionCodesEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let [loading, setLoading] = useState(false);
|
let [loading, setLoading] = useState(false);
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
|
|
||||||
import React, { useContext, useEffect, useState } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
|
import {
|
||||||
|
API,
|
||||||
|
copy,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
showSuccess,
|
||||||
|
setStatusData,
|
||||||
|
} from '../../helpers';
|
||||||
import { UserContext } from '../../context/User';
|
import { UserContext } from '../../context/User';
|
||||||
import { Modal } from '@douyinfe/semi-ui';
|
import { Modal } from '@douyinfe/semi-ui';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -71,18 +78,40 @@ const PersonalSetting = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let status = localStorage.getItem('status');
|
let saved = localStorage.getItem('status');
|
||||||
if (status) {
|
if (saved) {
|
||||||
status = JSON.parse(status);
|
const parsed = JSON.parse(saved);
|
||||||
setStatus(status);
|
setStatus(parsed);
|
||||||
if (status.turnstile_check) {
|
if (parsed.turnstile_check) {
|
||||||
setTurnstileEnabled(true);
|
setTurnstileEnabled(true);
|
||||||
setTurnstileSiteKey(status.turnstile_site_key);
|
setTurnstileSiteKey(parsed.turnstile_site_key);
|
||||||
|
} else {
|
||||||
|
setTurnstileEnabled(false);
|
||||||
|
setTurnstileSiteKey('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getUserData().then((res) => {
|
// Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
|
||||||
console.log(userState);
|
(async () => {
|
||||||
});
|
try {
|
||||||
|
const res = await API.get('/api/status');
|
||||||
|
const { success, data } = res.data;
|
||||||
|
if (success && data) {
|
||||||
|
setStatus(data);
|
||||||
|
setStatusData(data);
|
||||||
|
if (data.turnstile_check) {
|
||||||
|
setTurnstileEnabled(true);
|
||||||
|
setTurnstileSiteKey(data.turnstile_site_key);
|
||||||
|
} else {
|
||||||
|
setTurnstileEnabled(false);
|
||||||
|
setTurnstileSiteKey('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore and keep local status
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
getUserData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const RatioSetting = () => {
|
|||||||
CompletionRatio: '',
|
CompletionRatio: '',
|
||||||
GroupRatio: '',
|
GroupRatio: '',
|
||||||
GroupGroupRatio: '',
|
GroupGroupRatio: '',
|
||||||
|
ImageRatio: '',
|
||||||
|
AudioRatio: '',
|
||||||
|
AudioCompletionRatio: '',
|
||||||
AutoGroups: '',
|
AutoGroups: '',
|
||||||
DefaultUseAutoGroup: false,
|
DefaultUseAutoGroup: false,
|
||||||
ExposeRatioEnabled: false,
|
ExposeRatioEnabled: false,
|
||||||
@@ -61,7 +64,10 @@ const RatioSetting = () => {
|
|||||||
item.key === 'UserUsableGroups' ||
|
item.key === 'UserUsableGroups' ||
|
||||||
item.key === 'CompletionRatio' ||
|
item.key === 'CompletionRatio' ||
|
||||||
item.key === 'ModelPrice' ||
|
item.key === 'ModelPrice' ||
|
||||||
item.key === 'CacheRatio'
|
item.key === 'CacheRatio' ||
|
||||||
|
item.key === 'ImageRatio' ||
|
||||||
|
item.key === 'AudioRatio' ||
|
||||||
|
item.key === 'AudioCompletionRatio'
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
TagInput,
|
TagInput,
|
||||||
Spin,
|
Spin,
|
||||||
Card,
|
Card,
|
||||||
|
Radio,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +45,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
const SystemSetting = () => {
|
const SystemSetting = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
let [inputs, setInputs] = useState({
|
let [inputs, setInputs] = useState({
|
||||||
|
|
||||||
PasswordLoginEnabled: '',
|
PasswordLoginEnabled: '',
|
||||||
PasswordRegisterEnabled: '',
|
PasswordRegisterEnabled: '',
|
||||||
EmailVerificationEnabled: '',
|
EmailVerificationEnabled: '',
|
||||||
@@ -87,6 +89,15 @@ const SystemSetting = () => {
|
|||||||
LinuxDOClientSecret: '',
|
LinuxDOClientSecret: '',
|
||||||
LinuxDOMinimumTrustLevel: '',
|
LinuxDOMinimumTrustLevel: '',
|
||||||
ServerAddress: '',
|
ServerAddress: '',
|
||||||
|
// SSRF防护配置
|
||||||
|
'fetch_setting.enable_ssrf_protection': true,
|
||||||
|
'fetch_setting.allow_private_ip': '',
|
||||||
|
'fetch_setting.domain_filter_mode': false, // true 白名单,false 黑名单
|
||||||
|
'fetch_setting.ip_filter_mode': false, // true 白名单,false 黑名单
|
||||||
|
'fetch_setting.domain_list': [],
|
||||||
|
'fetch_setting.ip_list': [],
|
||||||
|
'fetch_setting.allowed_ports': [],
|
||||||
|
'fetch_setting.apply_ip_filter_for_domain': false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [originInputs, setOriginInputs] = useState({});
|
const [originInputs, setOriginInputs] = useState({});
|
||||||
@@ -98,6 +109,11 @@ const SystemSetting = () => {
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false);
|
const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false);
|
||||||
const [emailToAdd, setEmailToAdd] = useState('');
|
const [emailToAdd, setEmailToAdd] = useState('');
|
||||||
|
const [domainFilterMode, setDomainFilterMode] = useState(true);
|
||||||
|
const [ipFilterMode, setIpFilterMode] = useState(true);
|
||||||
|
const [domainList, setDomainList] = useState([]);
|
||||||
|
const [ipList, setIpList] = useState([]);
|
||||||
|
const [allowedPorts, setAllowedPorts] = useState([]);
|
||||||
|
|
||||||
const getOptions = async () => {
|
const getOptions = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -113,6 +129,37 @@ const SystemSetting = () => {
|
|||||||
case 'EmailDomainWhitelist':
|
case 'EmailDomainWhitelist':
|
||||||
setEmailDomainWhitelist(item.value ? item.value.split(',') : []);
|
setEmailDomainWhitelist(item.value ? item.value.split(',') : []);
|
||||||
break;
|
break;
|
||||||
|
case 'fetch_setting.allow_private_ip':
|
||||||
|
case 'fetch_setting.enable_ssrf_protection':
|
||||||
|
case 'fetch_setting.domain_filter_mode':
|
||||||
|
case 'fetch_setting.ip_filter_mode':
|
||||||
|
case 'fetch_setting.apply_ip_filter_for_domain':
|
||||||
|
item.value = toBoolean(item.value);
|
||||||
|
break;
|
||||||
|
case 'fetch_setting.domain_list':
|
||||||
|
try {
|
||||||
|
const domains = item.value ? JSON.parse(item.value) : [];
|
||||||
|
setDomainList(Array.isArray(domains) ? domains : []);
|
||||||
|
} catch (e) {
|
||||||
|
setDomainList([]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'fetch_setting.ip_list':
|
||||||
|
try {
|
||||||
|
const ips = item.value ? JSON.parse(item.value) : [];
|
||||||
|
setIpList(Array.isArray(ips) ? ips : []);
|
||||||
|
} catch (e) {
|
||||||
|
setIpList([]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'fetch_setting.allowed_ports':
|
||||||
|
try {
|
||||||
|
const ports = item.value ? JSON.parse(item.value) : [];
|
||||||
|
setAllowedPorts(Array.isArray(ports) ? ports : []);
|
||||||
|
} catch (e) {
|
||||||
|
setAllowedPorts(['80', '443', '8080', '8443']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'PasswordLoginEnabled':
|
case 'PasswordLoginEnabled':
|
||||||
case 'PasswordRegisterEnabled':
|
case 'PasswordRegisterEnabled':
|
||||||
case 'EmailVerificationEnabled':
|
case 'EmailVerificationEnabled':
|
||||||
@@ -140,6 +187,13 @@ const SystemSetting = () => {
|
|||||||
});
|
});
|
||||||
setInputs(newInputs);
|
setInputs(newInputs);
|
||||||
setOriginInputs(newInputs);
|
setOriginInputs(newInputs);
|
||||||
|
// 同步模式布尔到本地状态
|
||||||
|
if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') {
|
||||||
|
setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
|
||||||
|
}
|
||||||
|
if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
|
||||||
|
setIpFilterMode(!!newInputs['fetch_setting.ip_filter_mode']);
|
||||||
|
}
|
||||||
if (formApiRef.current) {
|
if (formApiRef.current) {
|
||||||
formApiRef.current.setValues(newInputs);
|
formApiRef.current.setValues(newInputs);
|
||||||
}
|
}
|
||||||
@@ -276,6 +330,46 @@ const SystemSetting = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitSSRF = async () => {
|
||||||
|
const options = [];
|
||||||
|
|
||||||
|
// 处理域名过滤模式与列表
|
||||||
|
options.push({
|
||||||
|
key: 'fetch_setting.domain_filter_mode',
|
||||||
|
value: domainFilterMode,
|
||||||
|
});
|
||||||
|
if (Array.isArray(domainList)) {
|
||||||
|
options.push({
|
||||||
|
key: 'fetch_setting.domain_list',
|
||||||
|
value: JSON.stringify(domainList),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理IP过滤模式与列表
|
||||||
|
options.push({
|
||||||
|
key: 'fetch_setting.ip_filter_mode',
|
||||||
|
value: ipFilterMode,
|
||||||
|
});
|
||||||
|
if (Array.isArray(ipList)) {
|
||||||
|
options.push({
|
||||||
|
key: 'fetch_setting.ip_list',
|
||||||
|
value: JSON.stringify(ipList),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理端口配置
|
||||||
|
if (Array.isArray(allowedPorts)) {
|
||||||
|
options.push({
|
||||||
|
key: 'fetch_setting.allowed_ports',
|
||||||
|
value: JSON.stringify(allowedPorts),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.length > 0) {
|
||||||
|
await updateOptions(options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddEmail = () => {
|
const handleAddEmail = () => {
|
||||||
if (emailToAdd && emailToAdd.trim() !== '') {
|
if (emailToAdd && emailToAdd.trim() !== '') {
|
||||||
const domain = emailToAdd.trim();
|
const domain = emailToAdd.trim();
|
||||||
@@ -587,6 +681,179 @@ const SystemSetting = () => {
|
|||||||
</Form.Section>
|
</Form.Section>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Form.Section text={t('SSRF防护设置')}>
|
||||||
|
<Text extraText={t('SSRF防护详细说明')}>
|
||||||
|
{t('配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全')}
|
||||||
|
</Text>
|
||||||
|
<Row
|
||||||
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
>
|
||||||
|
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||||
|
<Form.Checkbox
|
||||||
|
field='fetch_setting.enable_ssrf_protection'
|
||||||
|
noLabel
|
||||||
|
extraText={t('SSRF防护开关详细说明')}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCheckboxChange('fetch_setting.enable_ssrf_protection', e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('启用SSRF防护(推荐开启以保护服务器安全)')}
|
||||||
|
</Form.Checkbox>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row
|
||||||
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||||
|
<Form.Checkbox
|
||||||
|
field='fetch_setting.allow_private_ip'
|
||||||
|
noLabel
|
||||||
|
extraText={t('私有IP访问详细说明')}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCheckboxChange('fetch_setting.allow_private_ip', e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')}
|
||||||
|
</Form.Checkbox>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row
|
||||||
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||||
|
<Form.Checkbox
|
||||||
|
field='fetch_setting.apply_ip_filter_for_domain'
|
||||||
|
noLabel
|
||||||
|
extraText={t('域名IP过滤详细说明')}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
{t('对域名启用 IP 过滤(实验性)')}
|
||||||
|
</Form.Checkbox>
|
||||||
|
<Text strong>
|
||||||
|
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
{t('支持通配符格式,如:example.com, *.api.example.com')}
|
||||||
|
</Text>
|
||||||
|
<Radio.Group
|
||||||
|
type='button'
|
||||||
|
value={domainFilterMode ? 'whitelist' : 'blacklist'}
|
||||||
|
onChange={(val) => {
|
||||||
|
const selected = val && val.target ? val.target.value : val;
|
||||||
|
const isWhitelist = selected === 'whitelist';
|
||||||
|
setDomainFilterMode(isWhitelist);
|
||||||
|
setInputs(prev => ({
|
||||||
|
...prev,
|
||||||
|
'fetch_setting.domain_filter_mode': isWhitelist,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<Radio value='whitelist'>{t('白名单')}</Radio>
|
||||||
|
<Radio value='blacklist'>{t('黑名单')}</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
<TagInput
|
||||||
|
value={domainList}
|
||||||
|
onChange={(value) => {
|
||||||
|
setDomainList(value);
|
||||||
|
// 触发Form的onChange事件
|
||||||
|
setInputs(prev => ({
|
||||||
|
...prev,
|
||||||
|
'fetch_setting.domain_list': value
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder={t('输入域名后回车,如:example.com')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row
|
||||||
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||||
|
<Text strong>
|
||||||
|
{t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
{t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')}
|
||||||
|
</Text>
|
||||||
|
<Radio.Group
|
||||||
|
type='button'
|
||||||
|
value={ipFilterMode ? 'whitelist' : 'blacklist'}
|
||||||
|
onChange={(val) => {
|
||||||
|
const selected = val && val.target ? val.target.value : val;
|
||||||
|
const isWhitelist = selected === 'whitelist';
|
||||||
|
setIpFilterMode(isWhitelist);
|
||||||
|
setInputs(prev => ({
|
||||||
|
...prev,
|
||||||
|
'fetch_setting.ip_filter_mode': isWhitelist,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
<Radio value='whitelist'>{t('白名单')}</Radio>
|
||||||
|
<Radio value='blacklist'>{t('黑名单')}</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
<TagInput
|
||||||
|
value={ipList}
|
||||||
|
onChange={(value) => {
|
||||||
|
setIpList(value);
|
||||||
|
// 触发Form的onChange事件
|
||||||
|
setInputs(prev => ({
|
||||||
|
...prev,
|
||||||
|
'fetch_setting.ip_list': value
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder={t('输入IP地址后回车,如:8.8.8.8')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row
|
||||||
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||||
|
<Text strong>{t('允许的端口')}</Text>
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
{t('支持单个端口和端口范围,如:80, 443, 8000-8999')}
|
||||||
|
</Text>
|
||||||
|
<TagInput
|
||||||
|
value={allowedPorts}
|
||||||
|
onChange={(value) => {
|
||||||
|
setAllowedPorts(value);
|
||||||
|
// 触发Form的onChange事件
|
||||||
|
setInputs(prev => ({
|
||||||
|
...prev,
|
||||||
|
'fetch_setting.allowed_ports': value
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder={t('输入端口后回车,如:80 或 8000-8999')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
{t('端口配置详细说明')}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Button onClick={submitSSRF} style={{ marginTop: 16 }}>
|
||||||
|
{t('更新SSRF防护设置')}
|
||||||
|
</Button>
|
||||||
|
</Form.Section>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Form.Section text={t('配置登录注册')}>
|
<Form.Section text={t('配置登录注册')}>
|
||||||
<Row
|
<Row
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
TabPane,
|
TabPane,
|
||||||
Popover,
|
Popover,
|
||||||
|
Modal,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import {
|
import {
|
||||||
IconMail,
|
IconMail,
|
||||||
@@ -83,6 +84,9 @@ const AccountManagement = ({
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const isBound = (accountId) => Boolean(accountId);
|
||||||
|
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='!rounded-2xl'>
|
<Card className='!rounded-2xl'>
|
||||||
{/* 卡片头部 */}
|
{/* 卡片头部 */}
|
||||||
@@ -142,7 +146,7 @@ const AccountManagement = ({
|
|||||||
size='small'
|
size='small'
|
||||||
onClick={() => setShowEmailBindModal(true)}
|
onClick={() => setShowEmailBindModal(true)}
|
||||||
>
|
>
|
||||||
{userState.user && userState.user.email !== ''
|
{isBound(userState.user?.email)
|
||||||
? t('修改绑定')
|
? t('修改绑定')
|
||||||
: t('绑定')}
|
: t('绑定')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -165,9 +169,11 @@ const AccountManagement = ({
|
|||||||
{t('微信')}
|
{t('微信')}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
<div className='text-sm text-gray-500 truncate'>
|
||||||
{userState.user && userState.user.wechat_id !== ''
|
{!status.wechat_login
|
||||||
? t('已绑定')
|
? t('未启用')
|
||||||
: t('未绑定')}
|
: isBound(userState.user?.wechat_id)
|
||||||
|
? t('已绑定')
|
||||||
|
: t('未绑定')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +185,7 @@ const AccountManagement = ({
|
|||||||
disabled={!status.wechat_login}
|
disabled={!status.wechat_login}
|
||||||
onClick={() => setShowWeChatBindModal(true)}
|
onClick={() => setShowWeChatBindModal(true)}
|
||||||
>
|
>
|
||||||
{userState.user && userState.user.wechat_id !== ''
|
{isBound(userState.user?.wechat_id)
|
||||||
? t('修改绑定')
|
? t('修改绑定')
|
||||||
: status.wechat_login
|
: status.wechat_login
|
||||||
? t('绑定')
|
? t('绑定')
|
||||||
@@ -220,8 +226,7 @@ const AccountManagement = ({
|
|||||||
onGitHubOAuthClicked(status.github_client_id)
|
onGitHubOAuthClicked(status.github_client_id)
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
(userState.user && userState.user.github_id !== '') ||
|
isBound(userState.user?.github_id) || !status.github_oauth
|
||||||
!status.github_oauth
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status.github_oauth ? t('绑定') : t('未启用')}
|
{status.github_oauth ? t('绑定') : t('未启用')}
|
||||||
@@ -264,8 +269,7 @@ const AccountManagement = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
(userState.user && userState.user.oidc_id !== '') ||
|
isBound(userState.user?.oidc_id) || !status.oidc_enabled
|
||||||
!status.oidc_enabled
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status.oidc_enabled ? t('绑定') : t('未启用')}
|
{status.oidc_enabled ? t('绑定') : t('未启用')}
|
||||||
@@ -298,26 +302,56 @@ const AccountManagement = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex-shrink-0'>
|
<div className='flex-shrink-0'>
|
||||||
{status.telegram_oauth ? (
|
{status.telegram_oauth ? (
|
||||||
userState.user.telegram_id !== '' ? (
|
isBound(userState.user?.telegram_id) ? (
|
||||||
<Button disabled={true} size='small'>
|
<Button
|
||||||
|
disabled
|
||||||
|
size='small'
|
||||||
|
type='primary'
|
||||||
|
theme='outline'
|
||||||
|
>
|
||||||
{t('已绑定')}
|
{t('已绑定')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className='scale-75'>
|
<Button
|
||||||
<TelegramLoginButton
|
type='primary'
|
||||||
dataAuthUrl='/api/oauth/telegram/bind'
|
theme='outline'
|
||||||
botName={status.telegram_bot_name}
|
size='small'
|
||||||
/>
|
onClick={() => setShowTelegramBindModal(true)}
|
||||||
</div>
|
>
|
||||||
|
{t('绑定')}
|
||||||
|
</Button>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Button disabled={true} size='small'>
|
<Button
|
||||||
|
disabled
|
||||||
|
size='small'
|
||||||
|
type='primary'
|
||||||
|
theme='outline'
|
||||||
|
>
|
||||||
{t('未启用')}
|
{t('未启用')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Modal
|
||||||
|
title={t('绑定 Telegram')}
|
||||||
|
visible={showTelegramBindModal}
|
||||||
|
onCancel={() => setShowTelegramBindModal(false)}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<div className='my-3 text-sm text-gray-600'>
|
||||||
|
{t('点击下方按钮通过 Telegram 完成绑定')}
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-center'>
|
||||||
|
<div className='scale-90'>
|
||||||
|
<TelegramLoginButton
|
||||||
|
dataAuthUrl='/api/oauth/telegram/bind'
|
||||||
|
botName={status.telegram_bot_name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* LinuxDO绑定 */}
|
{/* LinuxDO绑定 */}
|
||||||
<Card className='!rounded-xl'>
|
<Card className='!rounded-xl'>
|
||||||
@@ -350,8 +384,7 @@ const AccountManagement = ({
|
|||||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
(userState.user && userState.user.linux_do_id !== '') ||
|
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
|
||||||
!status.linuxdo_oauth
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import CodeViewer from '../../../playground/CodeViewer';
|
|||||||
import { StatusContext } from '../../../../context/Status';
|
import { StatusContext } from '../../../../context/Status';
|
||||||
import { UserContext } from '../../../../context/User';
|
import { UserContext } from '../../../../context/User';
|
||||||
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
|
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
|
||||||
|
import { useSidebar } from '../../../../hooks/common/useSidebar';
|
||||||
|
|
||||||
const NotificationSettings = ({
|
const NotificationSettings = ({
|
||||||
t,
|
t,
|
||||||
@@ -97,6 +98,9 @@ const NotificationSettings = ({
|
|||||||
isSidebarModuleAllowed,
|
isSidebarModuleAllowed,
|
||||||
} = useUserPermissions();
|
} = useUserPermissions();
|
||||||
|
|
||||||
|
// 使用useSidebar钩子获取刷新方法
|
||||||
|
const { refreshUserConfig } = useSidebar();
|
||||||
|
|
||||||
// 左侧边栏设置处理函数
|
// 左侧边栏设置处理函数
|
||||||
const handleSectionChange = (sectionKey) => {
|
const handleSectionChange = (sectionKey) => {
|
||||||
return (checked) => {
|
return (checked) => {
|
||||||
@@ -132,6 +136,9 @@ const NotificationSettings = ({
|
|||||||
});
|
});
|
||||||
if (res.data.success) {
|
if (res.data.success) {
|
||||||
showSuccess(t('侧边栏设置保存成功'));
|
showSuccess(t('侧边栏设置保存成功'));
|
||||||
|
|
||||||
|
// 刷新useSidebar钩子中的用户配置,实现实时更新
|
||||||
|
await refreshUserConfig();
|
||||||
} else {
|
} else {
|
||||||
showError(res.data.message);
|
showError(res.data.message);
|
||||||
}
|
}
|
||||||
@@ -334,7 +341,7 @@ const NotificationSettings = ({
|
|||||||
loading={sidebarLoading}
|
loading={sidebarLoading}
|
||||||
className='!rounded-lg'
|
className='!rounded-lg'
|
||||||
>
|
>
|
||||||
{t('保存边栏设置')}
|
{t('保存设置')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -85,6 +85,26 @@ const REGION_EXAMPLE = {
|
|||||||
'claude-3-5-sonnet-20240620': 'europe-west1',
|
'claude-3-5-sonnet-20240620': 'europe-west1',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 支持并且已适配通过接口获取模型列表的渠道类型
|
||||||
|
const MODEL_FETCHABLE_TYPES = new Set([
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
14,
|
||||||
|
34,
|
||||||
|
17,
|
||||||
|
26,
|
||||||
|
24,
|
||||||
|
47,
|
||||||
|
25,
|
||||||
|
20,
|
||||||
|
23,
|
||||||
|
31,
|
||||||
|
35,
|
||||||
|
40,
|
||||||
|
42,
|
||||||
|
48,
|
||||||
|
]);
|
||||||
|
|
||||||
function type2secretPrompt(type) {
|
function type2secretPrompt(type) {
|
||||||
// inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
|
// inputs.type === 15 ? '按照如下格式输入:APIKey|SecretKey' : (inputs.type === 18 ? '按照如下格式输入:APPID|APISecret|APIKey' : '请输入渠道对应的鉴权密钥')
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -144,6 +164,8 @@ const EditChannelModal = (props) => {
|
|||||||
settings: '',
|
settings: '',
|
||||||
// 仅 Vertex: 密钥格式(存入 settings.vertex_key_type)
|
// 仅 Vertex: 密钥格式(存入 settings.vertex_key_type)
|
||||||
vertex_key_type: 'json',
|
vertex_key_type: 'json',
|
||||||
|
// 企业账户设置
|
||||||
|
is_enterprise_account: false,
|
||||||
};
|
};
|
||||||
const [batch, setBatch] = useState(false);
|
const [batch, setBatch] = useState(false);
|
||||||
const [multiToSingle, setMultiToSingle] = useState(false);
|
const [multiToSingle, setMultiToSingle] = useState(false);
|
||||||
@@ -169,6 +191,7 @@ const EditChannelModal = (props) => {
|
|||||||
const [channelSearchValue, setChannelSearchValue] = useState('');
|
const [channelSearchValue, setChannelSearchValue] = useState('');
|
||||||
const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
|
const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
|
||||||
const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加)
|
const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加)
|
||||||
|
const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户
|
||||||
|
|
||||||
// 2FA验证查看密钥相关状态
|
// 2FA验证查看密钥相关状态
|
||||||
const [twoFAState, setTwoFAState] = useState({
|
const [twoFAState, setTwoFAState] = useState({
|
||||||
@@ -215,7 +238,7 @@ const EditChannelModal = (props) => {
|
|||||||
pass_through_body_enabled: false,
|
pass_through_body_enabled: false,
|
||||||
system_prompt: '',
|
system_prompt: '',
|
||||||
});
|
});
|
||||||
const showApiConfigCard = inputs.type !== 45; // 控制是否显示 API 配置卡片(仅当渠道类型不是 豆包 时显示)
|
const showApiConfigCard = true; // 控制是否显示 API 配置卡片
|
||||||
const getInitValues = () => ({ ...originInputs });
|
const getInitValues = () => ({ ...originInputs });
|
||||||
|
|
||||||
// 处理渠道额外设置的更新
|
// 处理渠道额外设置的更新
|
||||||
@@ -322,6 +345,10 @@ const EditChannelModal = (props) => {
|
|||||||
case 36:
|
case 36:
|
||||||
localModels = ['suno_music', 'suno_lyrics'];
|
localModels = ['suno_music', 'suno_lyrics'];
|
||||||
break;
|
break;
|
||||||
|
case 45:
|
||||||
|
localModels = getChannelModels(value);
|
||||||
|
setInputs((prevInputs) => ({ ...prevInputs, base_url: 'https://ark.cn-beijing.volces.com' }));
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
localModels = getChannelModels(value);
|
localModels = getChannelModels(value);
|
||||||
break;
|
break;
|
||||||
@@ -413,15 +440,27 @@ const EditChannelModal = (props) => {
|
|||||||
parsedSettings.azure_responses_version || '';
|
parsedSettings.azure_responses_version || '';
|
||||||
// 读取 Vertex 密钥格式
|
// 读取 Vertex 密钥格式
|
||||||
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
|
data.vertex_key_type = parsedSettings.vertex_key_type || 'json';
|
||||||
|
// 读取企业账户设置
|
||||||
|
data.is_enterprise_account = parsedSettings.openrouter_enterprise === true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('解析其他设置失败:', error);
|
console.error('解析其他设置失败:', error);
|
||||||
data.azure_responses_version = '';
|
data.azure_responses_version = '';
|
||||||
data.region = '';
|
data.region = '';
|
||||||
data.vertex_key_type = 'json';
|
data.vertex_key_type = 'json';
|
||||||
|
data.is_enterprise_account = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
|
// 兼容历史数据:老渠道没有 settings 时,默认按 json 展示
|
||||||
data.vertex_key_type = 'json';
|
data.vertex_key_type = 'json';
|
||||||
|
data.is_enterprise_account = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.type === 45 &&
|
||||||
|
(!data.base_url ||
|
||||||
|
(typeof data.base_url === 'string' && data.base_url.trim() === ''))
|
||||||
|
) {
|
||||||
|
data.base_url = 'https://ark.cn-beijing.volces.com';
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputs(data);
|
setInputs(data);
|
||||||
@@ -433,6 +472,8 @@ const EditChannelModal = (props) => {
|
|||||||
} else {
|
} else {
|
||||||
setAutoBan(true);
|
setAutoBan(true);
|
||||||
}
|
}
|
||||||
|
// 同步企业账户状态
|
||||||
|
setIsEnterpriseAccount(data.is_enterprise_account || false);
|
||||||
setBasicModels(getChannelModels(data.type));
|
setBasicModels(getChannelModels(data.type));
|
||||||
// 同步更新channelSettings状态显示
|
// 同步更新channelSettings状态显示
|
||||||
setChannelSettings({
|
setChannelSettings({
|
||||||
@@ -692,6 +733,8 @@ const EditChannelModal = (props) => {
|
|||||||
});
|
});
|
||||||
// 重置密钥模式状态
|
// 重置密钥模式状态
|
||||||
setKeyMode('append');
|
setKeyMode('append');
|
||||||
|
// 重置企业账户状态
|
||||||
|
setIsEnterpriseAccount(false);
|
||||||
// 清空表单中的key_mode字段
|
// 清空表单中的key_mode字段
|
||||||
if (formApiRef.current) {
|
if (formApiRef.current) {
|
||||||
formApiRef.current.setValue('key_mode', undefined);
|
formApiRef.current.setValue('key_mode', undefined);
|
||||||
@@ -802,7 +845,9 @@ const EditChannelModal = (props) => {
|
|||||||
delete localInputs.key;
|
delete localInputs.key;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
localInputs.key = batch ? JSON.stringify(keys) : JSON.stringify(keys[0]);
|
localInputs.key = batch
|
||||||
|
? JSON.stringify(keys)
|
||||||
|
: JSON.stringify(keys[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -822,6 +867,10 @@ const EditChannelModal = (props) => {
|
|||||||
showInfo(t('请至少选择一个模型!'));
|
showInfo(t('请至少选择一个模型!'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (localInputs.type === 45 && (!localInputs.base_url || localInputs.base_url.trim() === '')) {
|
||||||
|
showInfo(t('请输入API地址!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
localInputs.model_mapping &&
|
localInputs.model_mapping &&
|
||||||
localInputs.model_mapping !== '' &&
|
localInputs.model_mapping !== '' &&
|
||||||
@@ -851,6 +900,21 @@ const EditChannelModal = (props) => {
|
|||||||
};
|
};
|
||||||
localInputs.setting = JSON.stringify(channelExtraSettings);
|
localInputs.setting = JSON.stringify(channelExtraSettings);
|
||||||
|
|
||||||
|
// 处理type === 20的企业账户设置
|
||||||
|
if (localInputs.type === 20) {
|
||||||
|
let settings = {};
|
||||||
|
if (localInputs.settings) {
|
||||||
|
try {
|
||||||
|
settings = JSON.parse(localInputs.settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析settings失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 设置企业账户标识,无论是true还是false都要传到后端
|
||||||
|
settings.openrouter_enterprise = localInputs.is_enterprise_account === true;
|
||||||
|
localInputs.settings = JSON.stringify(settings);
|
||||||
|
}
|
||||||
|
|
||||||
// 清理不需要发送到后端的字段
|
// 清理不需要发送到后端的字段
|
||||||
delete localInputs.force_format;
|
delete localInputs.force_format;
|
||||||
delete localInputs.thinking_to_content;
|
delete localInputs.thinking_to_content;
|
||||||
@@ -858,6 +922,7 @@ const EditChannelModal = (props) => {
|
|||||||
delete localInputs.pass_through_body_enabled;
|
delete localInputs.pass_through_body_enabled;
|
||||||
delete localInputs.system_prompt;
|
delete localInputs.system_prompt;
|
||||||
delete localInputs.system_prompt_override;
|
delete localInputs.system_prompt_override;
|
||||||
|
delete localInputs.is_enterprise_account;
|
||||||
// 顶层的 vertex_key_type 不应发送给后端
|
// 顶层的 vertex_key_type 不应发送给后端
|
||||||
delete localInputs.vertex_key_type;
|
delete localInputs.vertex_key_type;
|
||||||
|
|
||||||
@@ -899,6 +964,56 @@ const EditChannelModal = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 密钥去重函数
|
||||||
|
const deduplicateKeys = () => {
|
||||||
|
const currentKey = formApiRef.current?.getValue('key') || inputs.key || '';
|
||||||
|
|
||||||
|
if (!currentKey.trim()) {
|
||||||
|
showInfo(t('请先输入密钥'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按行分割密钥
|
||||||
|
const keyLines = currentKey.split('\n');
|
||||||
|
const beforeCount = keyLines.length;
|
||||||
|
|
||||||
|
// 使用哈希表去重,保持原有顺序
|
||||||
|
const keySet = new Set();
|
||||||
|
const deduplicatedKeys = [];
|
||||||
|
|
||||||
|
keyLines.forEach((line) => {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine && !keySet.has(trimmedLine)) {
|
||||||
|
keySet.add(trimmedLine);
|
||||||
|
deduplicatedKeys.push(trimmedLine);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const afterCount = deduplicatedKeys.length;
|
||||||
|
const deduplicatedKeyText = deduplicatedKeys.join('\n');
|
||||||
|
|
||||||
|
// 更新表单和状态
|
||||||
|
if (formApiRef.current) {
|
||||||
|
formApiRef.current.setValue('key', deduplicatedKeyText);
|
||||||
|
}
|
||||||
|
handleInputChange('key', deduplicatedKeyText);
|
||||||
|
|
||||||
|
// 显示去重结果
|
||||||
|
const message = t(
|
||||||
|
'去重完成:去重前 {{before}} 个密钥,去重后 {{after}} 个密钥',
|
||||||
|
{
|
||||||
|
before: beforeCount,
|
||||||
|
after: afterCount,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (beforeCount === afterCount) {
|
||||||
|
showInfo(t('未发现重复密钥'));
|
||||||
|
} else {
|
||||||
|
showSuccess(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addCustomModels = () => {
|
const addCustomModels = () => {
|
||||||
if (customModel.trim() === '') return;
|
if (customModel.trim() === '') return;
|
||||||
const modelArray = customModel.split(',').map((model) => model.trim());
|
const modelArray = customModel.split(',').map((model) => model.trim());
|
||||||
@@ -994,24 +1109,41 @@ const EditChannelModal = (props) => {
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
)}
|
)}
|
||||||
{batch && (
|
{batch && (
|
||||||
<Checkbox
|
<>
|
||||||
disabled={isEdit}
|
<Checkbox
|
||||||
checked={multiToSingle}
|
disabled={isEdit}
|
||||||
onChange={() => {
|
checked={multiToSingle}
|
||||||
setMultiToSingle((prev) => !prev);
|
onChange={() => {
|
||||||
setInputs((prev) => {
|
setMultiToSingle((prev) => {
|
||||||
const newInputs = { ...prev };
|
const nextValue = !prev;
|
||||||
if (!multiToSingle) {
|
setInputs((prevInputs) => {
|
||||||
newInputs.multi_key_mode = multiKeyMode;
|
const newInputs = { ...prevInputs };
|
||||||
} else {
|
if (nextValue) {
|
||||||
delete newInputs.multi_key_mode;
|
newInputs.multi_key_mode = multiKeyMode;
|
||||||
}
|
} else {
|
||||||
return newInputs;
|
delete newInputs.multi_key_mode;
|
||||||
});
|
}
|
||||||
}}
|
return newInputs;
|
||||||
>
|
});
|
||||||
{t('密钥聚合模式')}
|
return nextValue;
|
||||||
</Checkbox>
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('密钥聚合模式')}
|
||||||
|
</Checkbox>
|
||||||
|
|
||||||
|
{inputs.type !== 41 && (
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
type='tertiary'
|
||||||
|
theme='outline'
|
||||||
|
onClick={deduplicateKeys}
|
||||||
|
style={{ textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
{t('密钥去重')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -1175,6 +1307,21 @@ const EditChannelModal = (props) => {
|
|||||||
onChange={(value) => handleInputChange('type', value)}
|
onChange={(value) => handleInputChange('type', value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{inputs.type === 20 && (
|
||||||
|
<Form.Switch
|
||||||
|
field='is_enterprise_account'
|
||||||
|
label={t('是否为企业账户')}
|
||||||
|
checkedText={t('是')}
|
||||||
|
uncheckedText={t('否')}
|
||||||
|
onChange={(value) => {
|
||||||
|
setIsEnterpriseAccount(value);
|
||||||
|
handleInputChange('is_enterprise_account', value);
|
||||||
|
}}
|
||||||
|
extraText={t('企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选')}
|
||||||
|
initValue={inputs.is_enterprise_account}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Input
|
<Form.Input
|
||||||
field='name'
|
field='name'
|
||||||
label={t('名称')}
|
label={t('名称')}
|
||||||
@@ -1198,7 +1345,10 @@ const EditChannelModal = (props) => {
|
|||||||
value={inputs.vertex_key_type || 'json'}
|
value={inputs.vertex_key_type || 'json'}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
// 更新设置中的 vertex_key_type
|
// 更新设置中的 vertex_key_type
|
||||||
handleChannelOtherSettingsChange('vertex_key_type', value);
|
handleChannelOtherSettingsChange(
|
||||||
|
'vertex_key_type',
|
||||||
|
value,
|
||||||
|
);
|
||||||
// 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
|
// 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件
|
||||||
if (value === 'api_key') {
|
if (value === 'api_key') {
|
||||||
setBatch(false);
|
setBatch(false);
|
||||||
@@ -1218,7 +1368,8 @@ const EditChannelModal = (props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{batch ? (
|
{batch ? (
|
||||||
inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
|
inputs.type === 41 &&
|
||||||
|
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||||
<Form.Upload
|
<Form.Upload
|
||||||
field='vertex_files'
|
field='vertex_files'
|
||||||
label={t('密钥文件 (.json)')}
|
label={t('密钥文件 (.json)')}
|
||||||
@@ -1254,7 +1405,7 @@ const EditChannelModal = (props) => {
|
|||||||
autoComplete='new-password'
|
autoComplete='new-password'
|
||||||
onChange={(value) => handleInputChange('key', value)}
|
onChange={(value) => handleInputChange('key', value)}
|
||||||
extraText={
|
extraText={
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2 flex-wrap'>
|
||||||
{isEdit &&
|
{isEdit &&
|
||||||
isMultiKeyChannel &&
|
isMultiKeyChannel &&
|
||||||
keyMode === 'append' && (
|
keyMode === 'append' && (
|
||||||
@@ -1282,7 +1433,8 @@ const EditChannelModal = (props) => {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{inputs.type === 41 && (inputs.vertex_key_type || 'json') === 'json' ? (
|
{inputs.type === 41 &&
|
||||||
|
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||||
<>
|
<>
|
||||||
{!batch && (
|
{!batch && (
|
||||||
<div className='flex items-center justify-between mb-3'>
|
<div className='flex items-center justify-between mb-3'>
|
||||||
@@ -1789,6 +1941,30 @@ const EditChannelModal = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{inputs.type === 45 && (
|
||||||
|
<div>
|
||||||
|
<Form.Select
|
||||||
|
field='base_url'
|
||||||
|
label={t('API地址')}
|
||||||
|
placeholder={t('请选择API地址')}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleInputChange('base_url', value)
|
||||||
|
}
|
||||||
|
optionList={[
|
||||||
|
{
|
||||||
|
value: 'https://ark.cn-beijing.volces.com',
|
||||||
|
label: 'https://ark.cn-beijing.volces.com'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'https://ark.ap-southeast.bytepluses.com',
|
||||||
|
label: 'https://ark.ap-southeast.bytepluses.com'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
defaultValue='https://ark.cn-beijing.volces.com'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1872,13 +2048,15 @@ const EditChannelModal = (props) => {
|
|||||||
>
|
>
|
||||||
{t('填入所有模型')}
|
{t('填入所有模型')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{MODEL_FETCHABLE_TYPES.has(inputs.type) && (
|
||||||
size='small'
|
<Button
|
||||||
type='tertiary'
|
size='small'
|
||||||
onClick={() => fetchUpstreamModelList('models')}
|
type='tertiary'
|
||||||
>
|
onClick={() => fetchUpstreamModelList('models')}
|
||||||
{t('获取模型列表')}
|
>
|
||||||
</Button>
|
{t('获取模型列表')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size='small'
|
size='small'
|
||||||
type='warning'
|
type='warning'
|
||||||
|
|||||||
@@ -247,6 +247,32 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Delete a specific key
|
||||||
|
const handleDeleteKey = async (keyIndex) => {
|
||||||
|
const operationId = `delete_${keyIndex}`;
|
||||||
|
setOperationLoading((prev) => ({ ...prev, [operationId]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/channel/multi_key/manage', {
|
||||||
|
channel_id: channel.id,
|
||||||
|
action: 'delete_key',
|
||||||
|
key_index: keyIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
showSuccess(t('密钥已删除'));
|
||||||
|
await loadKeyStatus(currentPage, pageSize); // Reload current page
|
||||||
|
onRefresh && onRefresh(); // Refresh parent component
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('删除密钥失败'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading((prev) => ({ ...prev, [operationId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle page change
|
// Handle page change
|
||||||
const handlePageChange = (page) => {
|
const handlePageChange = (page) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
@@ -384,7 +410,7 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
|
|||||||
title: t('操作'),
|
title: t('操作'),
|
||||||
key: 'action',
|
key: 'action',
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
width: 100,
|
width: 150,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space>
|
<Space>
|
||||||
{record.status === 1 ? (
|
{record.status === 1 ? (
|
||||||
@@ -406,6 +432,21 @@ const MultiKeyManageModal = ({ visible, onCancel, channel, onRefresh }) => {
|
|||||||
{t('启用')}
|
{t('启用')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Popconfirm
|
||||||
|
title={t('确定要删除此密钥吗?')}
|
||||||
|
content={t('此操作不可撤销,将永久删除该密钥')}
|
||||||
|
onConfirm={() => handleDeleteKey(record.index)}
|
||||||
|
okType={'danger'}
|
||||||
|
position={'topRight'}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
size='small'
|
||||||
|
loading={operationLoading[`delete_${record.index}`]}
|
||||||
|
>
|
||||||
|
{t('删除')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import React from 'react';
|
|||||||
import { Button, Form } from '@douyinfe/semi-ui';
|
import { Button, Form } from '@douyinfe/semi-ui';
|
||||||
import { IconSearch } from '@douyinfe/semi-icons';
|
import { IconSearch } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
|
||||||
|
|
||||||
const MjLogsFilters = ({
|
const MjLogsFilters = ({
|
||||||
formInitValues,
|
formInitValues,
|
||||||
setFormApi,
|
setFormApi,
|
||||||
@@ -54,6 +56,11 @@ const MjLogsFilters = ({
|
|||||||
showClear
|
showClear
|
||||||
pure
|
pure
|
||||||
size='small'
|
size='small'
|
||||||
|
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||||
|
text: t(preset.text),
|
||||||
|
start: preset.start(),
|
||||||
|
end: preset.end()
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
TASK_ACTION_GENERATE,
|
TASK_ACTION_FIRST_TAIL_GENERATE,
|
||||||
TASK_ACTION_TEXT_GENERATE,
|
TASK_ACTION_GENERATE, TASK_ACTION_REFERENCE_GENERATE,
|
||||||
|
TASK_ACTION_TEXT_GENERATE
|
||||||
} from '../../../constants/common.constant';
|
} from '../../../constants/common.constant';
|
||||||
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
|
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
|
||||||
|
|
||||||
@@ -111,6 +112,18 @@ const renderType = (type, t) => {
|
|||||||
{t('文生视频')}
|
{t('文生视频')}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
|
case TASK_ACTION_FIRST_TAIL_GENERATE:
|
||||||
|
return (
|
||||||
|
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||||
|
{t('首尾生视频')}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case TASK_ACTION_REFERENCE_GENERATE:
|
||||||
|
return (
|
||||||
|
<Tag color='blue' shape='circle' prefixIcon={<Sparkles size={14} />}>
|
||||||
|
{t('参照生视频')}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||||
@@ -343,7 +356,9 @@ export const getTaskLogsColumns = ({
|
|||||||
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
|
// 仅当为视频生成任务且成功,且 fail_reason 是 URL 时显示可点击链接
|
||||||
const isVideoTask =
|
const isVideoTask =
|
||||||
record.action === TASK_ACTION_GENERATE ||
|
record.action === TASK_ACTION_GENERATE ||
|
||||||
record.action === TASK_ACTION_TEXT_GENERATE;
|
record.action === TASK_ACTION_TEXT_GENERATE ||
|
||||||
|
record.action === TASK_ACTION_FIRST_TAIL_GENERATE ||
|
||||||
|
record.action === TASK_ACTION_REFERENCE_GENERATE;
|
||||||
const isSuccess = record.status === 'SUCCESS';
|
const isSuccess = record.status === 'SUCCESS';
|
||||||
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
|
const isUrl = typeof text === 'string' && /^https?:\/\//.test(text);
|
||||||
if (isSuccess && isVideoTask && isUrl) {
|
if (isSuccess && isVideoTask && isUrl) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import React from 'react';
|
|||||||
import { Button, Form } from '@douyinfe/semi-ui';
|
import { Button, Form } from '@douyinfe/semi-ui';
|
||||||
import { IconSearch } from '@douyinfe/semi-icons';
|
import { IconSearch } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
|
||||||
|
|
||||||
const TaskLogsFilters = ({
|
const TaskLogsFilters = ({
|
||||||
formInitValues,
|
formInitValues,
|
||||||
setFormApi,
|
setFormApi,
|
||||||
@@ -54,6 +56,11 @@ const TaskLogsFilters = ({
|
|||||||
showClear
|
showClear
|
||||||
pure
|
pure
|
||||||
size='small'
|
size='small'
|
||||||
|
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||||
|
text: t(preset.text),
|
||||||
|
start: preset.start(),
|
||||||
|
end: preset.end()
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Modal } from '@douyinfe/semi-ui';
|
import { Modal, Button, Typography, Spin } from '@douyinfe/semi-ui';
|
||||||
|
import { IconExternalOpen, IconCopy } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
const ContentModal = ({
|
const ContentModal = ({
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
@@ -26,17 +29,120 @@ const ContentModal = ({
|
|||||||
modalContent,
|
modalContent,
|
||||||
isVideo,
|
isVideo,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [videoError, setVideoError] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isModalOpen && isVideo) {
|
||||||
|
setVideoError(false);
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
}, [isModalOpen, isVideo]);
|
||||||
|
|
||||||
|
const handleVideoError = () => {
|
||||||
|
setVideoError(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoLoaded = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = () => {
|
||||||
|
navigator.clipboard.writeText(modalContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenInNewTab = () => {
|
||||||
|
window.open(modalContent, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderVideoContent = () => {
|
||||||
|
if (videoError) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px' }}>
|
||||||
|
视频无法在当前浏览器中播放,这可能是由于:
|
||||||
|
</Text>
|
||||||
|
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
||||||
|
• 视频服务商的跨域限制
|
||||||
|
</Text>
|
||||||
|
<Text type="tertiary" style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
|
||||||
|
• 需要特定的请求头或认证
|
||||||
|
</Text>
|
||||||
|
<Text type="tertiary" style={{ display: 'block', marginBottom: '16px', fontSize: '12px' }}>
|
||||||
|
• 防盗链保护机制
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '20px' }}>
|
||||||
|
<Button
|
||||||
|
icon={<IconExternalOpen />}
|
||||||
|
onClick={handleOpenInNewTab}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
>
|
||||||
|
在新标签页中打开
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<IconCopy />}
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
>
|
||||||
|
复制链接
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '16px', padding: '8px', backgroundColor: '#f8f9fa', borderRadius: '4px' }}>
|
||||||
|
<Text
|
||||||
|
type="tertiary"
|
||||||
|
style={{ fontSize: '10px', wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{modalContent}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{isLoading && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
zIndex: 10
|
||||||
|
}}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<video
|
||||||
|
src={modalContent}
|
||||||
|
controls
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
autoPlay
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
onError={handleVideoError}
|
||||||
|
onLoadedData={handleVideoLoaded}
|
||||||
|
onLoadStart={() => setIsLoading(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={isModalOpen}
|
visible={isModalOpen}
|
||||||
onOk={() => setIsModalOpen(false)}
|
onOk={() => setIsModalOpen(false)}
|
||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
closable={null}
|
closable={null}
|
||||||
bodyStyle={{ height: '400px', overflow: 'auto' }}
|
bodyStyle={{
|
||||||
|
height: isVideo ? '450px' : '400px',
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: isVideo && videoError ? '0' : '24px'
|
||||||
|
}}
|
||||||
width={800}
|
width={800}
|
||||||
>
|
>
|
||||||
{isVideo ? (
|
{isVideo ? (
|
||||||
<video src={modalContent} controls style={{ width: '100%' }} autoPlay />
|
renderVideoContent()
|
||||||
) : (
|
) : (
|
||||||
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
<p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import React from 'react';
|
|||||||
import { Button, Form } from '@douyinfe/semi-ui';
|
import { Button, Form } from '@douyinfe/semi-ui';
|
||||||
import { IconSearch } from '@douyinfe/semi-icons';
|
import { IconSearch } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
import { DATE_RANGE_PRESETS } from '../../../constants/console.constants';
|
||||||
|
|
||||||
const LogsFilters = ({
|
const LogsFilters = ({
|
||||||
formInitValues,
|
formInitValues,
|
||||||
setFormApi,
|
setFormApi,
|
||||||
@@ -55,6 +57,11 @@ const LogsFilters = ({
|
|||||||
showClear
|
showClear
|
||||||
pure
|
pure
|
||||||
size='small'
|
size='small'
|
||||||
|
presets={DATE_RANGE_PRESETS.map(preset => ({
|
||||||
|
text: t(preset.text),
|
||||||
|
start: preset.start(),
|
||||||
|
end: preset.end()
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,3 +40,5 @@ export const API_ENDPOINTS = [
|
|||||||
|
|
||||||
export const TASK_ACTION_GENERATE = 'generate';
|
export const TASK_ACTION_GENERATE = 'generate';
|
||||||
export const TASK_ACTION_TEXT_GENERATE = 'textGenerate';
|
export const TASK_ACTION_TEXT_GENERATE = 'textGenerate';
|
||||||
|
export const TASK_ACTION_FIRST_TAIL_GENERATE = 'firstTailGenerate';
|
||||||
|
export const TASK_ACTION_REFERENCE_GENERATE = 'referenceGenerate';
|
||||||
|
|||||||
49
web/src/constants/console.constants.js
Normal file
49
web/src/constants/console.constants.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
// ========== 日期预设常量 ==========
|
||||||
|
export const DATE_RANGE_PRESETS = [
|
||||||
|
{
|
||||||
|
text: '今天',
|
||||||
|
start: () => dayjs().startOf('day').toDate(),
|
||||||
|
end: () => dayjs().endOf('day').toDate()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '近 7 天',
|
||||||
|
start: () => dayjs().subtract(6, 'day').startOf('day').toDate(),
|
||||||
|
end: () => dayjs().endOf('day').toDate()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '本周',
|
||||||
|
start: () => dayjs().startOf('week').toDate(),
|
||||||
|
end: () => dayjs().endOf('week').toDate()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '近 30 天',
|
||||||
|
start: () => dayjs().subtract(29, 'day').startOf('day').toDate(),
|
||||||
|
end: () => dayjs().endOf('day').toDate()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '本月',
|
||||||
|
start: () => dayjs().startOf('month').toDate(),
|
||||||
|
end: () => dayjs().endOf('month').toDate()
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -118,7 +118,6 @@ export const buildApiPayload = (
|
|||||||
model: inputs.model,
|
model: inputs.model,
|
||||||
group: inputs.group,
|
group: inputs.group,
|
||||||
messages: processedMessages,
|
messages: processedMessages,
|
||||||
group: inputs.group,
|
|
||||||
stream: inputs.stream,
|
stream: inputs.stream,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,13 +131,15 @@ export const buildApiPayload = (
|
|||||||
seed: 'seed',
|
seed: 'seed',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
Object.entries(parameterMappings).forEach(([key, param]) => {
|
Object.entries(parameterMappings).forEach(([key, param]) => {
|
||||||
if (
|
const enabled = parameterEnabled[key];
|
||||||
parameterEnabled[key] &&
|
const value = inputs[param];
|
||||||
inputs[param] !== undefined &&
|
const hasValue = value !== undefined && value !== null;
|
||||||
inputs[param] !== null
|
|
||||||
) {
|
|
||||||
payload[param] = inputs[param];
|
if (enabled && hasValue) {
|
||||||
|
payload[param] = value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1030,6 +1030,8 @@ export function renderModelPrice(
|
|||||||
audioInputSeperatePrice = false,
|
audioInputSeperatePrice = false,
|
||||||
audioInputTokens = 0,
|
audioInputTokens = 0,
|
||||||
audioInputPrice = 0,
|
audioInputPrice = 0,
|
||||||
|
imageGenerationCall = false,
|
||||||
|
imageGenerationCallPrice = 0,
|
||||||
) {
|
) {
|
||||||
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
|
||||||
groupRatio,
|
groupRatio,
|
||||||
@@ -1072,7 +1074,8 @@ export function renderModelPrice(
|
|||||||
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
|
(audioInputTokens / 1000000) * audioInputPrice * groupRatio +
|
||||||
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
(completionTokens / 1000000) * completionRatioPrice * groupRatio +
|
||||||
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
|
(webSearchCallCount / 1000) * webSearchPrice * groupRatio +
|
||||||
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio;
|
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
|
||||||
|
(imageGenerationCallPrice * groupRatio);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1134,7 +1137,13 @@ export function renderModelPrice(
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p></p>
|
{imageGenerationCall && imageGenerationCallPrice > 0 && (
|
||||||
|
<p>
|
||||||
|
{i18next.t('图片生成调用:${{price}} / 1次', {
|
||||||
|
price: imageGenerationCallPrice,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p>
|
<p>
|
||||||
{(() => {
|
{(() => {
|
||||||
// 构建输入部分描述
|
// 构建输入部分描述
|
||||||
@@ -1214,6 +1223,16 @@ export function renderModelPrice(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
: '',
|
: '',
|
||||||
|
imageGenerationCall && imageGenerationCallPrice > 0
|
||||||
|
? i18next.t(
|
||||||
|
' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
|
||||||
|
{
|
||||||
|
price: imageGenerationCallPrice,
|
||||||
|
ratio: groupRatio,
|
||||||
|
ratioType: ratioLabel,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: '',
|
||||||
].join('');
|
].join('');
|
||||||
|
|
||||||
return i18next.t(
|
return i18next.t(
|
||||||
|
|||||||
@@ -75,13 +75,17 @@ export async function copy(text) {
|
|||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
// 构建input 执行 复制命令
|
// 构建 textarea 执行复制命令,保留多行文本格式
|
||||||
var _input = window.document.createElement('input');
|
const textarea = window.document.createElement('textarea');
|
||||||
_input.value = text;
|
textarea.value = text;
|
||||||
window.document.body.appendChild(_input);
|
textarea.setAttribute('readonly', '');
|
||||||
_input.select();
|
textarea.style.position = 'fixed';
|
||||||
window.document.execCommand('Copy');
|
textarea.style.left = '-9999px';
|
||||||
window.document.body.removeChild(_input);
|
textarea.style.top = '-9999px';
|
||||||
|
window.document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
window.document.execCommand('copy');
|
||||||
|
window.document.body.removeChild(textarea);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
okay = false;
|
okay = false;
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|||||||
@@ -25,13 +25,9 @@ import {
|
|||||||
showInfo,
|
showInfo,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
loadChannelModels,
|
loadChannelModels,
|
||||||
copy,
|
copy
|
||||||
} from '../../helpers';
|
} from '../../helpers';
|
||||||
import {
|
import { CHANNEL_OPTIONS, ITEMS_PER_PAGE, MODEL_TABLE_PAGE_SIZE } from '../../constants';
|
||||||
CHANNEL_OPTIONS,
|
|
||||||
ITEMS_PER_PAGE,
|
|
||||||
MODEL_TABLE_PAGE_SIZE,
|
|
||||||
} from '../../constants';
|
|
||||||
import { useIsMobile } from '../common/useIsMobile';
|
import { useIsMobile } from '../common/useIsMobile';
|
||||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||||
import { Modal } from '@douyinfe/semi-ui';
|
import { Modal } from '@douyinfe/semi-ui';
|
||||||
@@ -68,7 +64,7 @@ export const useChannelsData = () => {
|
|||||||
|
|
||||||
// Status filter
|
// Status filter
|
||||||
const [statusFilter, setStatusFilter] = useState(
|
const [statusFilter, setStatusFilter] = useState(
|
||||||
localStorage.getItem('channel-status-filter') || 'all',
|
localStorage.getItem('channel-status-filter') || 'all'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Type tabs states
|
// Type tabs states
|
||||||
@@ -83,9 +79,10 @@ export const useChannelsData = () => {
|
|||||||
const [testingModels, setTestingModels] = useState(new Set());
|
const [testingModels, setTestingModels] = useState(new Set());
|
||||||
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
||||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||||
const [testQueue, setTestQueue] = useState([]);
|
|
||||||
const [isProcessingQueue, setIsProcessingQueue] = useState(false);
|
|
||||||
const [modelTablePage, setModelTablePage] = useState(1);
|
const [modelTablePage, setModelTablePage] = useState(1);
|
||||||
|
|
||||||
|
// 使用 ref 来避免闭包问题,类似旧版实现
|
||||||
|
const shouldStopBatchTestingRef = useRef(false);
|
||||||
|
|
||||||
// Multi-key management states
|
// Multi-key management states
|
||||||
const [showMultiKeyManageModal, setShowMultiKeyManageModal] = useState(false);
|
const [showMultiKeyManageModal, setShowMultiKeyManageModal] = useState(false);
|
||||||
@@ -119,12 +116,9 @@ export const useChannelsData = () => {
|
|||||||
// Initialize from localStorage
|
// Initialize from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
const localIdSort = localStorage.getItem('id-sort') === 'true';
|
||||||
const localPageSize =
|
const localPageSize = parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
||||||
parseInt(localStorage.getItem('page-size')) || ITEMS_PER_PAGE;
|
const localEnableTagMode = localStorage.getItem('enable-tag-mode') === 'true';
|
||||||
const localEnableTagMode =
|
const localEnableBatchDelete = localStorage.getItem('enable-batch-delete') === 'true';
|
||||||
localStorage.getItem('enable-tag-mode') === 'true';
|
|
||||||
const localEnableBatchDelete =
|
|
||||||
localStorage.getItem('enable-batch-delete') === 'true';
|
|
||||||
|
|
||||||
setIdSort(localIdSort);
|
setIdSort(localIdSort);
|
||||||
setPageSize(localPageSize);
|
setPageSize(localPageSize);
|
||||||
@@ -182,10 +176,7 @@ export const useChannelsData = () => {
|
|||||||
// Save column preferences
|
// Save column preferences
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(visibleColumns).length > 0) {
|
if (Object.keys(visibleColumns).length > 0) {
|
||||||
localStorage.setItem(
|
localStorage.setItem('channels-table-columns', JSON.stringify(visibleColumns));
|
||||||
'channels-table-columns',
|
|
||||||
JSON.stringify(visibleColumns),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [visibleColumns]);
|
}, [visibleColumns]);
|
||||||
|
|
||||||
@@ -299,21 +290,14 @@ export const useChannelsData = () => {
|
|||||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||||
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
|
if (searchKeyword !== '' || searchGroup !== '' || searchModel !== '') {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await searchChannels(
|
await searchChannels(enableTagMode, typeKey, statusF, page, pageSize, idSort);
|
||||||
enableTagMode,
|
|
||||||
typeKey,
|
|
||||||
statusF,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
idSort,
|
|
||||||
);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reqId = ++requestCounter.current;
|
const reqId = ++requestCounter.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
|
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||||
const res = await API.get(
|
const res = await API.get(
|
||||||
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
|
`/api/channel/?p=${page}&page_size=${pageSize}&id_sort=${idSort}&tag_mode=${enableTagMode}${typeParam}${statusParam}`,
|
||||||
@@ -327,10 +311,7 @@ export const useChannelsData = () => {
|
|||||||
if (success) {
|
if (success) {
|
||||||
const { items, total, type_counts } = data;
|
const { items, total, type_counts } = data;
|
||||||
if (type_counts) {
|
if (type_counts) {
|
||||||
const sumAll = Object.values(type_counts).reduce(
|
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
||||||
(acc, v) => acc + v,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
setTypeCounts({ ...type_counts, all: sumAll });
|
setTypeCounts({ ...type_counts, all: sumAll });
|
||||||
}
|
}
|
||||||
setChannelFormat(items, enableTagMode);
|
setChannelFormat(items, enableTagMode);
|
||||||
@@ -354,18 +335,11 @@ export const useChannelsData = () => {
|
|||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||||
await loadChannels(
|
await loadChannels(page, pageSz, sortFlag, enableTagMode, typeKey, statusF);
|
||||||
page,
|
|
||||||
pageSz,
|
|
||||||
sortFlag,
|
|
||||||
enableTagMode,
|
|
||||||
typeKey,
|
|
||||||
statusF,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeParam = typeKey !== 'all' ? `&type=${typeKey}` : '';
|
const typeParam = (typeKey !== 'all') ? `&type=${typeKey}` : '';
|
||||||
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
const statusParam = statusF !== 'all' ? `&status=${statusF}` : '';
|
||||||
const res = await API.get(
|
const res = await API.get(
|
||||||
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
|
`/api/channel/search?keyword=${searchKeyword}&group=${searchGroup}&model=${searchModel}&id_sort=${sortFlag}&tag_mode=${enableTagMode}&p=${page}&page_size=${pageSz}${typeParam}${statusParam}`,
|
||||||
@@ -373,10 +347,7 @@ export const useChannelsData = () => {
|
|||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
const { items = [], total = 0, type_counts = {} } = data;
|
const { items = [], total = 0, type_counts = {} } = data;
|
||||||
const sumAll = Object.values(type_counts).reduce(
|
const sumAll = Object.values(type_counts).reduce((acc, v) => acc + v, 0);
|
||||||
(acc, v) => acc + v,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
setTypeCounts({ ...type_counts, all: sumAll });
|
setTypeCounts({ ...type_counts, all: sumAll });
|
||||||
setChannelFormat(items, enableTagMode);
|
setChannelFormat(items, enableTagMode);
|
||||||
setChannelCount(total);
|
setChannelCount(total);
|
||||||
@@ -395,14 +366,7 @@ export const useChannelsData = () => {
|
|||||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||||
await loadChannels(page, pageSize, idSort, enableTagMode);
|
await loadChannels(page, pageSize, idSort, enableTagMode);
|
||||||
} else {
|
} else {
|
||||||
await searchChannels(
|
await searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
||||||
enableTagMode,
|
|
||||||
activeTypeKey,
|
|
||||||
statusFilter,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
idSort,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -488,16 +452,9 @@ export const useChannelsData = () => {
|
|||||||
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
const { searchKeyword, searchGroup, searchModel } = getFormValues();
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
if (searchKeyword === '' && searchGroup === '' && searchModel === '') {
|
||||||
loadChannels(page, pageSize, idSort, enableTagMode).then(() => {});
|
loadChannels(page, pageSize, idSort, enableTagMode).then(() => { });
|
||||||
} else {
|
} else {
|
||||||
searchChannels(
|
searchChannels(enableTagMode, activeTypeKey, statusFilter, page, pageSize, idSort);
|
||||||
enableTagMode,
|
|
||||||
activeTypeKey,
|
|
||||||
statusFilter,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
idSort,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -513,14 +470,7 @@ export const useChannelsData = () => {
|
|||||||
showError(reason);
|
showError(reason);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
searchChannels(
|
searchChannels(enableTagMode, activeTypeKey, statusFilter, 1, size, idSort);
|
||||||
enableTagMode,
|
|
||||||
activeTypeKey,
|
|
||||||
statusFilter,
|
|
||||||
1,
|
|
||||||
size,
|
|
||||||
idSort,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -551,10 +501,7 @@ export const useChannelsData = () => {
|
|||||||
showError(res?.data?.message || t('渠道复制失败'));
|
showError(res?.data?.message || t('渠道复制失败'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(
|
showError(t('渠道复制失败: ') + (error?.response?.data?.message || error?.message || error));
|
||||||
t('渠道复制失败: ') +
|
|
||||||
(error?.response?.data?.message || error?.message || error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -593,11 +540,7 @@ export const useChannelsData = () => {
|
|||||||
data.priority = parseInt(data.priority);
|
data.priority = parseInt(data.priority);
|
||||||
break;
|
break;
|
||||||
case 'weight':
|
case 'weight':
|
||||||
if (
|
if (data.weight === undefined || data.weight < 0 || data.weight === '') {
|
||||||
data.weight === undefined ||
|
|
||||||
data.weight < 0 ||
|
|
||||||
data.weight === ''
|
|
||||||
) {
|
|
||||||
showInfo('权重必须是非负整数!');
|
showInfo('权重必须是非负整数!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -740,136 +683,226 @@ export const useChannelsData = () => {
|
|||||||
const res = await API.post(`/api/channel/fix`);
|
const res = await API.post(`/api/channel/fix`);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
showSuccess(
|
showSuccess(t('已修复 ${success} 个通道,失败 ${fails} 个通道。').replace('${success}', data.success).replace('${fails}', data.fails));
|
||||||
t('已修复 ${success} 个通道,失败 ${fails} 个通道。')
|
|
||||||
.replace('${success}', data.success)
|
|
||||||
.replace('${fails}', data.fails),
|
|
||||||
);
|
|
||||||
await refresh();
|
await refresh();
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test channel
|
// Test channel - 单个模型测试,参考旧版实现
|
||||||
const testChannel = async (record, model) => {
|
const testChannel = async (record, model) => {
|
||||||
setTestQueue((prev) => [...prev, { channel: record, model }]);
|
const testKey = `${record.id}-${model}`;
|
||||||
if (!isProcessingQueue) {
|
|
||||||
setIsProcessingQueue(true);
|
// 检查是否应该停止批量测试
|
||||||
|
if (shouldStopBatchTestingRef.current && isBatchTesting) {
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Process test queue
|
// 添加到正在测试的模型集合
|
||||||
const processTestQueue = async () => {
|
setTestingModels(prev => new Set([...prev, model]));
|
||||||
if (!isProcessingQueue || testQueue.length === 0) return;
|
|
||||||
|
|
||||||
const { channel, model, indexInFiltered } = testQueue[0];
|
|
||||||
|
|
||||||
if (currentTestChannel && currentTestChannel.id === channel.id) {
|
|
||||||
let pageNo;
|
|
||||||
if (indexInFiltered !== undefined) {
|
|
||||||
pageNo = Math.floor(indexInFiltered / MODEL_TABLE_PAGE_SIZE) + 1;
|
|
||||||
} else {
|
|
||||||
const filteredModelsList = currentTestChannel.models
|
|
||||||
.split(',')
|
|
||||||
.filter((m) =>
|
|
||||||
m.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
|
||||||
);
|
|
||||||
const modelIdx = filteredModelsList.indexOf(model);
|
|
||||||
pageNo =
|
|
||||||
modelIdx !== -1
|
|
||||||
? Math.floor(modelIdx / MODEL_TABLE_PAGE_SIZE) + 1
|
|
||||||
: 1;
|
|
||||||
}
|
|
||||||
setModelTablePage(pageNo);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTestingModels((prev) => new Set([...prev, model]));
|
const res = await API.get(`/api/channel/test/${record.id}?model=${model}`);
|
||||||
const res = await API.get(
|
|
||||||
`/api/channel/test/${channel.id}?model=${model}`,
|
// 检查是否在请求期间被停止
|
||||||
);
|
if (shouldStopBatchTestingRef.current && isBatchTesting) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
const { success, message, time } = res.data;
|
const { success, message, time } = res.data;
|
||||||
|
|
||||||
setModelTestResults((prev) => ({
|
// 更新测试结果
|
||||||
|
setModelTestResults(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[`${channel.id}-${model}`]: { success, time },
|
[testKey]: {
|
||||||
|
success,
|
||||||
|
message,
|
||||||
|
time: time || 0,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
updateChannelProperty(channel.id, (ch) => {
|
// 更新渠道响应时间
|
||||||
ch.response_time = time * 1000;
|
updateChannelProperty(record.id, (channel) => {
|
||||||
ch.test_time = Date.now() / 1000;
|
channel.response_time = time * 1000;
|
||||||
|
channel.test_time = Date.now() / 1000;
|
||||||
});
|
});
|
||||||
if (!model) {
|
|
||||||
|
if (!model || model === '') {
|
||||||
showInfo(
|
showInfo(
|
||||||
t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
|
t('通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。')
|
||||||
.replace('${name}', channel.name)
|
.replace('${name}', record.name)
|
||||||
|
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showInfo(
|
||||||
|
t('通道 ${name} 测试成功,模型 ${model} 耗时 ${time.toFixed(2)} 秒。')
|
||||||
|
.replace('${name}', record.name)
|
||||||
|
.replace('${model}', model)
|
||||||
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
.replace('${time.toFixed(2)}', time.toFixed(2)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(`${t('模型')} ${model}: ${message}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error.message);
|
// 处理网络错误
|
||||||
|
const testKey = `${record.id}-${model}`;
|
||||||
|
setModelTestResults(prev => ({
|
||||||
|
...prev,
|
||||||
|
[testKey]: {
|
||||||
|
success: false,
|
||||||
|
message: error.message || t('网络错误'),
|
||||||
|
time: 0,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
showError(`${t('模型')} ${model}: ${error.message || t('测试失败')}`);
|
||||||
} finally {
|
} finally {
|
||||||
setTestingModels((prev) => {
|
// 从正在测试的模型集合中移除
|
||||||
|
setTestingModels(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(model);
|
newSet.delete(model);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setTestQueue((prev) => prev.slice(1));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Monitor queue changes
|
// 批量测试单个渠道的所有模型,参考旧版实现
|
||||||
useEffect(() => {
|
|
||||||
if (testQueue.length > 0 && isProcessingQueue) {
|
|
||||||
processTestQueue();
|
|
||||||
} else if (testQueue.length === 0 && isProcessingQueue) {
|
|
||||||
setIsProcessingQueue(false);
|
|
||||||
setIsBatchTesting(false);
|
|
||||||
}
|
|
||||||
}, [testQueue, isProcessingQueue]);
|
|
||||||
|
|
||||||
// Batch test models
|
|
||||||
const batchTestModels = async () => {
|
const batchTestModels = async () => {
|
||||||
if (!currentTestChannel) return;
|
if (!currentTestChannel || !currentTestChannel.models) {
|
||||||
|
showError(t('渠道模型信息不完整'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = currentTestChannel.models.split(',').filter(model =>
|
||||||
|
model.toLowerCase().includes(modelSearchKeyword.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
showError(t('没有找到匹配的模型'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsBatchTesting(true);
|
setIsBatchTesting(true);
|
||||||
setModelTablePage(1);
|
shouldStopBatchTestingRef.current = false; // 重置停止标志
|
||||||
|
|
||||||
const filteredModels = currentTestChannel.models
|
// 清空该渠道之前的测试结果
|
||||||
.split(',')
|
setModelTestResults(prev => {
|
||||||
.filter((model) =>
|
const newResults = { ...prev };
|
||||||
model.toLowerCase().includes(modelSearchKeyword.toLowerCase()),
|
models.forEach(model => {
|
||||||
);
|
const testKey = `${currentTestChannel.id}-${model}`;
|
||||||
|
delete newResults[testKey];
|
||||||
|
});
|
||||||
|
return newResults;
|
||||||
|
});
|
||||||
|
|
||||||
setTestQueue(
|
try {
|
||||||
filteredModels.map((model, idx) => ({
|
showInfo(t('开始批量测试 ${count} 个模型,已清空上次结果...').replace('${count}', models.length));
|
||||||
channel: currentTestChannel,
|
|
||||||
model,
|
// 提高并发数量以加快测试速度,参考旧版的并发限制
|
||||||
indexInFiltered: idx,
|
const concurrencyLimit = 5;
|
||||||
})),
|
const results = [];
|
||||||
);
|
|
||||||
setIsProcessingQueue(true);
|
for (let i = 0; i < models.length; i += concurrencyLimit) {
|
||||||
|
// 检查是否应该停止
|
||||||
|
if (shouldStopBatchTestingRef.current) {
|
||||||
|
showInfo(t('批量测试已停止'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = models.slice(i, i + concurrencyLimit);
|
||||||
|
showInfo(t('正在测试第 ${current} - ${end} 个模型 (共 ${total} 个)')
|
||||||
|
.replace('${current}', i + 1)
|
||||||
|
.replace('${end}', Math.min(i + concurrencyLimit, models.length))
|
||||||
|
.replace('${total}', models.length)
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchPromises = batch.map(model => testChannel(currentTestChannel, model));
|
||||||
|
const batchResults = await Promise.allSettled(batchPromises);
|
||||||
|
results.push(...batchResults);
|
||||||
|
|
||||||
|
// 再次检查是否应该停止
|
||||||
|
if (shouldStopBatchTestingRef.current) {
|
||||||
|
showInfo(t('批量测试已停止'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暂延迟避免过于频繁的请求
|
||||||
|
if (i + concurrencyLimit < models.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldStopBatchTestingRef.current) {
|
||||||
|
// 等待一小段时间确保所有结果都已更新
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// 使用当前状态重新计算结果统计
|
||||||
|
setModelTestResults(currentResults => {
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
models.forEach(model => {
|
||||||
|
const testKey = `${currentTestChannel.id}-${model}`;
|
||||||
|
const result = currentResults[testKey];
|
||||||
|
if (result && result.success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示完成消息
|
||||||
|
setTimeout(() => {
|
||||||
|
showSuccess(t('批量测试完成!成功: ${success}, 失败: ${fail}, 总计: ${total}')
|
||||||
|
.replace('${success}', successCount)
|
||||||
|
.replace('${fail}', failCount)
|
||||||
|
.replace('${total}', models.length)
|
||||||
|
);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return currentResults; // 不修改状态,只是为了获取最新值
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('批量测试过程中发生错误: ') + error.message);
|
||||||
|
} finally {
|
||||||
|
setIsBatchTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 停止批量测试
|
||||||
|
const stopBatchTesting = () => {
|
||||||
|
shouldStopBatchTestingRef.current = true;
|
||||||
|
setIsBatchTesting(false);
|
||||||
|
setTestingModels(new Set());
|
||||||
|
showInfo(t('已停止批量测试'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清空测试结果
|
||||||
|
const clearTestResults = () => {
|
||||||
|
setModelTestResults({});
|
||||||
|
showInfo(t('已清空测试结果'));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle close modal
|
// Handle close modal
|
||||||
const handleCloseModal = () => {
|
const handleCloseModal = () => {
|
||||||
|
// 如果正在批量测试,先停止测试
|
||||||
if (isBatchTesting) {
|
if (isBatchTesting) {
|
||||||
setTestQueue([]);
|
shouldStopBatchTestingRef.current = true;
|
||||||
setIsProcessingQueue(false);
|
showInfo(t('关闭弹窗,已停止批量测试'));
|
||||||
setIsBatchTesting(false);
|
|
||||||
showSuccess(t('已停止测试'));
|
|
||||||
} else {
|
|
||||||
setShowModelTestModal(false);
|
|
||||||
setModelSearchKeyword('');
|
|
||||||
setSelectedModelKeys([]);
|
|
||||||
setModelTablePage(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setShowModelTestModal(false);
|
||||||
|
setModelSearchKeyword('');
|
||||||
|
setIsBatchTesting(false);
|
||||||
|
setTestingModels(new Set());
|
||||||
|
setSelectedModelKeys([]);
|
||||||
|
setModelTablePage(1);
|
||||||
|
// 可选择性保留测试结果,这里不清空以便用户查看
|
||||||
};
|
};
|
||||||
|
|
||||||
// Type counts
|
// Type counts
|
||||||
@@ -1012,4 +1045,4 @@ export const useChannelsData = () => {
|
|||||||
setCompactMode,
|
setCompactMode,
|
||||||
setActivePage,
|
setActivePage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -21,6 +21,10 @@ import { useState, useEffect, useMemo, useContext } from 'react';
|
|||||||
import { StatusContext } from '../../context/Status';
|
import { StatusContext } from '../../context/Status';
|
||||||
import { API } from '../../helpers';
|
import { API } from '../../helpers';
|
||||||
|
|
||||||
|
// 创建一个全局事件系统来同步所有useSidebar实例
|
||||||
|
const sidebarEventTarget = new EventTarget();
|
||||||
|
const SIDEBAR_REFRESH_EVENT = 'sidebar-refresh';
|
||||||
|
|
||||||
export const useSidebar = () => {
|
export const useSidebar = () => {
|
||||||
const [statusState] = useContext(StatusContext);
|
const [statusState] = useContext(StatusContext);
|
||||||
const [userConfig, setUserConfig] = useState(null);
|
const [userConfig, setUserConfig] = useState(null);
|
||||||
@@ -124,9 +128,12 @@ export const useSidebar = () => {
|
|||||||
|
|
||||||
// 刷新用户配置的方法(供外部调用)
|
// 刷新用户配置的方法(供外部调用)
|
||||||
const refreshUserConfig = async () => {
|
const refreshUserConfig = async () => {
|
||||||
if (Object.keys(adminConfig).length > 0) {
|
if (Object.keys(adminConfig).length > 0) {
|
||||||
await loadUserConfig();
|
await loadUserConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发全局刷新事件,通知所有useSidebar实例更新
|
||||||
|
sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载用户配置
|
// 加载用户配置
|
||||||
@@ -137,6 +144,21 @@ export const useSidebar = () => {
|
|||||||
}
|
}
|
||||||
}, [adminConfig]);
|
}, [adminConfig]);
|
||||||
|
|
||||||
|
// 监听全局刷新事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (Object.keys(adminConfig).length > 0) {
|
||||||
|
loadUserConfig();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sidebarEventTarget.addEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sidebarEventTarget.removeEventListener(SIDEBAR_REFRESH_EVENT, handleRefresh);
|
||||||
|
};
|
||||||
|
}, [adminConfig]);
|
||||||
|
|
||||||
// 计算最终的显示配置
|
// 计算最终的显示配置
|
||||||
const finalConfig = useMemo(() => {
|
const finalConfig = useMemo(() => {
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const useDashboardStats = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('统计Tokens'),
|
title: t('统计Tokens'),
|
||||||
value: isNaN(consumeTokens) ? 0 : consumeTokens,
|
value: isNaN(consumeTokens) ? 0 : consumeTokens.toLocaleString(),
|
||||||
icon: <IconTextStroked />,
|
icon: <IconTextStroked />,
|
||||||
avatarColor: 'pink',
|
avatarColor: 'pink',
|
||||||
trendData: trendData.tokens,
|
trendData: trendData.tokens,
|
||||||
|
|||||||
@@ -447,6 +447,8 @@ export const useLogsData = () => {
|
|||||||
other?.audio_input_seperate_price || false,
|
other?.audio_input_seperate_price || false,
|
||||||
other?.audio_input_token_count || 0,
|
other?.audio_input_token_count || 0,
|
||||||
other?.audio_input_price || 0,
|
other?.audio_input_price || 0,
|
||||||
|
other?.image_generation_call || false,
|
||||||
|
other?.image_generation_call_price || 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
expandDataLocal.push({
|
expandDataLocal.push({
|
||||||
|
|||||||
@@ -837,6 +837,7 @@
|
|||||||
"确定要充值 $": "Confirm to top up $",
|
"确定要充值 $": "Confirm to top up $",
|
||||||
"微信/支付宝 实付金额:": "WeChat/Alipay actual payment amount:",
|
"微信/支付宝 实付金额:": "WeChat/Alipay actual payment amount:",
|
||||||
"Stripe 实付金额:": "Stripe actual payment amount:",
|
"Stripe 实付金额:": "Stripe actual payment amount:",
|
||||||
|
"允许在 Stripe 支付中输入促销码": "Allow entering promotion codes during Stripe checkout",
|
||||||
"支付中...": "Paying",
|
"支付中...": "Paying",
|
||||||
"支付宝": "Alipay",
|
"支付宝": "Alipay",
|
||||||
"收益统计": "Income statistics",
|
"收益统计": "Income statistics",
|
||||||
@@ -1889,6 +1890,10 @@
|
|||||||
"确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?",
|
"确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?",
|
||||||
"此操作不可撤销,将永久删除已自动禁用的密钥": "This operation cannot be undone, and all automatically disabled keys will be permanently deleted.",
|
"此操作不可撤销,将永久删除已自动禁用的密钥": "This operation cannot be undone, and all automatically disabled keys will be permanently deleted.",
|
||||||
"删除自动禁用密钥": "Delete auto disabled keys",
|
"删除自动禁用密钥": "Delete auto disabled keys",
|
||||||
|
"确定要删除此密钥吗?": "Are you sure you want to delete this key?",
|
||||||
|
"此操作不可撤销,将永久删除该密钥": "This operation cannot be undone, and the key will be permanently deleted.",
|
||||||
|
"密钥已删除": "Key has been deleted",
|
||||||
|
"删除密钥失败": "Failed to delete key",
|
||||||
"图标": "Icon",
|
"图标": "Icon",
|
||||||
"模型图标": "Model icon",
|
"模型图标": "Model icon",
|
||||||
"请输入图标名称": "Please enter the icon name",
|
"请输入图标名称": "Please enter the icon name",
|
||||||
@@ -1999,6 +2004,16 @@
|
|||||||
"查看渠道密钥": "View channel key",
|
"查看渠道密钥": "View channel key",
|
||||||
"渠道密钥信息": "Channel key information",
|
"渠道密钥信息": "Channel key information",
|
||||||
"密钥获取成功": "Key acquisition successful",
|
"密钥获取成功": "Key acquisition successful",
|
||||||
|
"模型补全倍率(仅对自定义模型有效)": "Model completion ratio (only effective for custom models)",
|
||||||
|
"图片倍率": "Image ratio",
|
||||||
|
"音频倍率": "Audio ratio",
|
||||||
|
"音频补全倍率": "Audio completion ratio",
|
||||||
|
"图片输入相关的倍率设置,键为模型名称,值为倍率": "Image input related ratio settings, key is model name, value is ratio",
|
||||||
|
"音频输入相关的倍率设置,键为模型名称,值为倍率": "Audio input related ratio settings, key is model name, value is ratio",
|
||||||
|
"音频输出补全相关的倍率设置,键为模型名称,值为倍率": "Audio output completion related ratio settings, key is model name, value is ratio",
|
||||||
|
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-image-1\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-image-1\": 2}",
|
||||||
|
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-audio-preview\": 16}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-audio-preview\": 16}",
|
||||||
|
"为一个 JSON 文本,键为模型名称,值为倍率,例如:{\"gpt-4o-realtime\": 2}": "A JSON text with model name as key and ratio as value, e.g.: {\"gpt-4o-realtime\": 2}",
|
||||||
"顶栏管理": "Header Management",
|
"顶栏管理": "Header Management",
|
||||||
"控制顶栏模块显示状态,全局生效": "Control header module display status, global effect",
|
"控制顶栏模块显示状态,全局生效": "Control header module display status, global effect",
|
||||||
"用户主页,展示系统信息": "User homepage, displaying system information",
|
"用户主页,展示系统信息": "User homepage, displaying system information",
|
||||||
@@ -2058,7 +2073,7 @@
|
|||||||
"需要登录访问": "Require Login",
|
"需要登录访问": "Require Login",
|
||||||
"开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace",
|
"开启后未登录用户无法访问模型广场": "When enabled, unauthenticated users cannot access the model marketplace",
|
||||||
"参与官方同步": "Participate in official sync",
|
"参与官方同步": "Participate in official sync",
|
||||||
"关闭后,此模型将不会被“同步官方”自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)",
|
"关闭后,此模型将不会被\"同步官方\"自动覆盖或创建": "When turned off, this model will be skipped by Sync official (no auto create/overwrite)",
|
||||||
"同步": "Sync",
|
"同步": "Sync",
|
||||||
"同步向导": "Sync Wizard",
|
"同步向导": "Sync Wizard",
|
||||||
"选择方式": "Select method",
|
"选择方式": "Select method",
|
||||||
@@ -2084,5 +2099,36 @@
|
|||||||
"原价": "Original price",
|
"原价": "Original price",
|
||||||
"优惠": "Discount",
|
"优惠": "Discount",
|
||||||
"折": "% off",
|
"折": "% off",
|
||||||
"节省": "Save"
|
"节省": "Save",
|
||||||
|
"今天": "Today",
|
||||||
|
"近 7 天": "Last 7 Days",
|
||||||
|
"本周": "This Week",
|
||||||
|
"本月": "This Month",
|
||||||
|
"近 30 天": "Last 30 Days",
|
||||||
|
"代理设置": "Proxy Settings",
|
||||||
|
"更新Worker设置": "Update Worker Settings",
|
||||||
|
"SSRF防护设置": "SSRF Protection Settings",
|
||||||
|
"配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全": "Configure Server-Side Request Forgery (SSRF) protection to secure internal network resources",
|
||||||
|
"SSRF防护详细说明": "SSRF protection prevents malicious users from using your server to access internal network resources. Configure whitelists for trusted domains/IPs and restrict allowed ports. Applies to file downloads, webhooks, and notifications.",
|
||||||
|
"启用SSRF防护(推荐开启以保护服务器安全)": "Enable SSRF Protection (Recommended for server security)",
|
||||||
|
"SSRF防护开关详细说明": "Master switch controls whether SSRF protection is enabled. When disabled, all SSRF checks are bypassed, allowing access to any URL. ⚠️ Only disable this feature in completely trusted environments.",
|
||||||
|
"允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)": "Allow access to private IP addresses (127.0.0.1, 192.168.x.x and other internal addresses)",
|
||||||
|
"私有IP访问详细说明": "⚠️ Security Warning: Enabling this allows access to internal network resources (localhost, private networks). Only enable if you need to access internal services and understand the security implications.",
|
||||||
|
"域名白名单": "Domain Whitelist",
|
||||||
|
"支持通配符格式,如:example.com, *.api.example.com": "Supports wildcard format, e.g.: example.com, *.api.example.com",
|
||||||
|
"域名白名单详细说明": "Whitelisted domains bypass all SSRF checks and are allowed direct access. Supports exact domains (example.com) or wildcards (*.api.example.com) for subdomains. When whitelist is empty, all domains go through SSRF validation.",
|
||||||
|
"输入域名后回车,如:example.com": "Enter domain and press Enter, e.g.: example.com",
|
||||||
|
"支持CIDR格式,如:8.8.8.8, 192.168.1.0/24": "Supports CIDR format, e.g.: 8.8.8.8, 192.168.1.0/24",
|
||||||
|
"IP白名单详细说明": "Controls which IP addresses are allowed access. Use single IPs (8.8.8.8) or CIDR notation (192.168.1.0/24). Empty whitelist allows all IPs (subject to private IP settings), non-empty whitelist only allows listed IPs.",
|
||||||
|
"输入IP地址后回车,如:8.8.8.8": "Enter IP address and press Enter, e.g.: 8.8.8.8",
|
||||||
|
"允许的端口": "Allowed Ports",
|
||||||
|
"支持单个端口和端口范围,如:80, 443, 8000-8999": "Supports single ports and port ranges, e.g.: 80, 443, 8000-8999",
|
||||||
|
"端口配置详细说明": "Restrict external requests to specific ports. Use single ports (80, 443) or ranges (8000-8999). Empty list allows all ports. Default includes common web ports.",
|
||||||
|
"输入端口后回车,如:80 或 8000-8999": "Enter port and press Enter, e.g.: 80 or 8000-8999",
|
||||||
|
"更新SSRF防护设置": "Update SSRF Protection Settings",
|
||||||
|
"对域名启用 IP 过滤(实验性)": "Enable IP filtering for domains (experimental)",
|
||||||
|
"域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
|
||||||
|
"域名黑名单": "Domain Blacklist",
|
||||||
|
"白名单": "Whitelist",
|
||||||
|
"黑名单": "Blacklist"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,29 @@
|
|||||||
"语言": "语言",
|
"语言": "语言",
|
||||||
"展开侧边栏": "展开侧边栏",
|
"展开侧边栏": "展开侧边栏",
|
||||||
"关闭侧边栏": "关闭侧边栏",
|
"关闭侧边栏": "关闭侧边栏",
|
||||||
"注销成功!": "注销成功!"
|
"注销成功!": "注销成功!",
|
||||||
|
"代理设置": "代理设置",
|
||||||
|
"更新Worker设置": "更新Worker设置",
|
||||||
|
"SSRF防护设置": "SSRF防护设置",
|
||||||
|
"配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全": "配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全",
|
||||||
|
"SSRF防护详细说明": "SSRF防护可防止恶意用户利用您的服务器访问内网资源。您可以配置受信任域名/IP的白名单,并限制允许的端口。适用于文件下载、Webhook回调和通知功能。",
|
||||||
|
"启用SSRF防护(推荐开启以保护服务器安全)": "启用SSRF防护(推荐开启以保护服务器安全)",
|
||||||
|
"SSRF防护开关详细说明": "总开关控制是否启用SSRF防护功能。关闭后将跳过所有SSRF检查,允许访问任意URL。⚠️ 仅在完全信任环境中关闭此功能。",
|
||||||
|
"允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)": "允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)",
|
||||||
|
"私有IP访问详细说明": "⚠️ 安全警告:启用此选项将允许访问内网资源(本地主机、私有网络)。仅在需要访问内部服务且了解安全风险的情况下启用。",
|
||||||
|
"域名白名单": "域名白名单",
|
||||||
|
"支持通配符格式,如:example.com, *.api.example.com": "支持通配符格式,如:example.com, *.api.example.com",
|
||||||
|
"域名白名单详细说明": "白名单中的域名将绕过所有SSRF检查,直接允许访问。支持精确域名(example.com)或通配符(*.api.example.com)匹配子域名。白名单为空时,所有域名都需要通过SSRF检查。",
|
||||||
|
"输入域名后回车,如:example.com": "输入域名后回车,如:example.com",
|
||||||
|
"IP白名单": "IP白名单",
|
||||||
|
"支持CIDR格式,如:8.8.8.8, 192.168.1.0/24": "支持CIDR格式,如:8.8.8.8, 192.168.1.0/24",
|
||||||
|
"IP白名单详细说明": "控制允许访问的IP地址。支持单个IP(8.8.8.8)或CIDR网段(192.168.1.0/24)。空白名单允许所有IP(但仍受私有IP设置限制),非空白名单仅允许列表中的IP访问。",
|
||||||
|
"输入IP地址后回车,如:8.8.8.8": "输入IP地址后回车,如:8.8.8.8",
|
||||||
|
"允许的端口": "允许的端口",
|
||||||
|
"支持单个端口和端口范围,如:80, 443, 8000-8999": "支持单个端口和端口范围,如:80, 443, 8000-8999",
|
||||||
|
"端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。",
|
||||||
|
"输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999",
|
||||||
|
"更新SSRF防护设置": "更新SSRF防护设置",
|
||||||
|
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
|
||||||
|
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
StripePriceId: '',
|
StripePriceId: '',
|
||||||
StripeUnitPrice: 8.0,
|
StripeUnitPrice: 8.0,
|
||||||
StripeMinTopUp: 1,
|
StripeMinTopUp: 1,
|
||||||
|
StripePromotionCodesEnabled: false,
|
||||||
});
|
});
|
||||||
const [originInputs, setOriginInputs] = useState({});
|
const [originInputs, setOriginInputs] = useState({});
|
||||||
const formApiRef = useRef(null);
|
const formApiRef = useRef(null);
|
||||||
@@ -63,6 +64,10 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
props.options.StripeMinTopUp !== undefined
|
props.options.StripeMinTopUp !== undefined
|
||||||
? parseFloat(props.options.StripeMinTopUp)
|
? parseFloat(props.options.StripeMinTopUp)
|
||||||
: 1,
|
: 1,
|
||||||
|
StripePromotionCodesEnabled:
|
||||||
|
props.options.StripePromotionCodesEnabled !== undefined
|
||||||
|
? props.options.StripePromotionCodesEnabled
|
||||||
|
: false,
|
||||||
};
|
};
|
||||||
setInputs(currentInputs);
|
setInputs(currentInputs);
|
||||||
setOriginInputs({ ...currentInputs });
|
setOriginInputs({ ...currentInputs });
|
||||||
@@ -114,6 +119,16 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
value: inputs.StripeMinTopUp.toString(),
|
value: inputs.StripeMinTopUp.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
originInputs['StripePromotionCodesEnabled'] !==
|
||||||
|
inputs.StripePromotionCodesEnabled &&
|
||||||
|
inputs.StripePromotionCodesEnabled !== undefined
|
||||||
|
) {
|
||||||
|
options.push({
|
||||||
|
key: 'StripePromotionCodesEnabled',
|
||||||
|
value: inputs.StripePromotionCodesEnabled ? 'true' : 'false',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
const requestQueue = options.map((opt) =>
|
const requestQueue = options.map((opt) =>
|
||||||
@@ -225,6 +240,15 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
placeholder={t('例如:2,就是最低充值2$')}
|
placeholder={t('例如:2,就是最低充值2$')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||||
|
<Form.Switch
|
||||||
|
field='StripePromotionCodesEnabled'
|
||||||
|
size='default'
|
||||||
|
checkedText='|'
|
||||||
|
uncheckedText='〇'
|
||||||
|
label={t('允许在 Stripe 支付中输入促销码')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Button onClick={submitStripeSetting}>{t('更新 Stripe 设置')}</Button>
|
<Button onClick={submitStripeSetting}>{t('更新 Stripe 设置')}</Button>
|
||||||
</Form.Section>
|
</Form.Section>
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export default function ModelRatioSettings(props) {
|
|||||||
ModelRatio: '',
|
ModelRatio: '',
|
||||||
CacheRatio: '',
|
CacheRatio: '',
|
||||||
CompletionRatio: '',
|
CompletionRatio: '',
|
||||||
|
ImageRatio: '',
|
||||||
|
AudioRatio: '',
|
||||||
|
AudioCompletionRatio: '',
|
||||||
ExposeRatioEnabled: false,
|
ExposeRatioEnabled: false,
|
||||||
});
|
});
|
||||||
const refForm = useRef();
|
const refForm = useRef();
|
||||||
@@ -219,6 +222,72 @@ export default function ModelRatioSettings(props) {
|
|||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={16}>
|
||||||
|
<Form.TextArea
|
||||||
|
label={t('图片输入倍率(仅部分模型支持该计费)')}
|
||||||
|
extraText={t('图片输入相关的倍率设置,键为模型名称,值为倍率,仅部分模型支持该计费')}
|
||||||
|
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-image-1": 2}')}
|
||||||
|
field={'ImageRatio'}
|
||||||
|
autosize={{ minRows: 6, maxRows: 12 }}
|
||||||
|
trigger='blur'
|
||||||
|
stopValidateWithError
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (rule, value) => verifyJSON(value),
|
||||||
|
message: '不是合法的 JSON 字符串',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(value) =>
|
||||||
|
setInputs({ ...inputs, ImageRatio: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={16}>
|
||||||
|
<Form.TextArea
|
||||||
|
label={t('音频倍率(仅部分模型支持该计费)')}
|
||||||
|
extraText={t('音频输入相关的倍率设置,键为模型名称,值为倍率')}
|
||||||
|
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-audio-preview": 16}')}
|
||||||
|
field={'AudioRatio'}
|
||||||
|
autosize={{ minRows: 6, maxRows: 12 }}
|
||||||
|
trigger='blur'
|
||||||
|
stopValidateWithError
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (rule, value) => verifyJSON(value),
|
||||||
|
message: '不是合法的 JSON 字符串',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(value) =>
|
||||||
|
setInputs({ ...inputs, AudioRatio: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} sm={16}>
|
||||||
|
<Form.TextArea
|
||||||
|
label={t('音频补全倍率(仅部分模型支持该计费)')}
|
||||||
|
extraText={t('音频输出补全相关的倍率设置,键为模型名称,值为倍率')}
|
||||||
|
placeholder={t('为一个 JSON 文本,键为模型名称,值为倍率,例如:{"gpt-4o-realtime": 2}')}
|
||||||
|
field={'AudioCompletionRatio'}
|
||||||
|
autosize={{ minRows: 6, maxRows: 12 }}
|
||||||
|
trigger='blur'
|
||||||
|
stopValidateWithError
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (rule, value) => verifyJSON(value),
|
||||||
|
message: '不是合法的 JSON 字符串',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(value) =>
|
||||||
|
setInputs({ ...inputs, AudioCompletionRatio: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={16}>
|
<Col span={16}>
|
||||||
<Form.Switch
|
<Form.Switch
|
||||||
|
|||||||
@@ -20,10 +20,16 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig, transformWithEsbuild } from 'vite';
|
import { defineConfig, transformWithEsbuild } from 'vite';
|
||||||
import pkg from '@douyinfe/vite-plugin-semi';
|
import pkg from '@douyinfe/vite-plugin-semi';
|
||||||
|
import path from 'path';
|
||||||
const { vitePluginSemi } = pkg;
|
const { vitePluginSemi } = pkg;
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
{
|
{
|
||||||
name: 'treat-js-files-as-jsx',
|
name: 'treat-js-files-as-jsx',
|
||||||
|
|||||||
Reference in New Issue
Block a user