commit
eeb6b6c7ed
@ -1,5 +1,5 @@
|
|||||||
<p align="right">
|
<p align="right">
|
||||||
<a href="./README.md">中文</a> | <strong>English</strong>
|
<a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a>
|
||||||
</p>
|
</p>
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
|||||||
216
README.fr.md
Normal file
216
README.fr.md
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
<p align="right">
|
||||||
|
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong>
|
||||||
|
</p>
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# New API
|
||||||
|
|
||||||
|
🍥 Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA
|
||||||
|
|
||||||
|
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
||||||
|
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="licence">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
|
||||||
|
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="version">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
|
||||||
|
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
|
||||||
|
</a>
|
||||||
|
<a href="https://hub.docker.com/r/CalciumIon/new-api">
|
||||||
|
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
|
||||||
|
</a>
|
||||||
|
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
|
||||||
|
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 📝 Description du projet
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique.
|
||||||
|
> - Les utilisateurs doivent se conformer aux [Conditions d'utilisation](https://openai.com/policies/terms-of-use) d'OpenAI et aux **lois et réglementations applicables**, et ne doivent pas l'utiliser à des fins illégales.
|
||||||
|
> - Conformément aux [《Mesures provisoires pour la gestion des services d'intelligence artificielle générative》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), veuillez ne fournir aucun service d'IA générative non enregistré au public en Chine.
|
||||||
|
|
||||||
|
<h2>🤝 Partenaires de confiance</h2>
|
||||||
|
<p id="premium-sponsors"> </p>
|
||||||
|
<p align="center"><strong>Sans ordre particulier</strong></p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.cherry-ai.com/" target=_blank><img
|
||||||
|
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
||||||
|
src="./docs/images/pku.png" alt="Université de Pékin" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
||||||
|
src="./docs/images/ucloud.png" alt="UCloud" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://www.aliyun.com/" target=_blank><img
|
||||||
|
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
|
||||||
|
/></a>
|
||||||
|
<a href="https://io.net/" target=_blank><img
|
||||||
|
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
||||||
|
/></a>
|
||||||
|
</p>
|
||||||
|
<p> </p>
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
Pour une documentation détaillée, veuillez consulter notre Wiki officiel : [https://docs.newapi.pro/](https://docs.newapi.pro/)
|
||||||
|
|
||||||
|
Vous pouvez également accéder au DeepWiki généré par l'IA :
|
||||||
|
[](https://deepwiki.com/QuantumNous/new-api)
|
||||||
|
|
||||||
|
## ✨ Fonctionnalités clés
|
||||||
|
|
||||||
|
New API offre un large éventail de fonctionnalités, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) pour plus de détails :
|
||||||
|
|
||||||
|
1. 🎨 Nouvelle interface utilisateur
|
||||||
|
2. 🌍 Prise en charge multilingue
|
||||||
|
3. 💰 Fonctionnalité de recharge en ligne (YiPay)
|
||||||
|
4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||||
|
5. 🔄 Compatible avec la base de données originale de One API
|
||||||
|
6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation
|
||||||
|
7. ⚖️ Prise en charge de la sélection aléatoire pondérée des canaux
|
||||||
|
8. 📈 Tableau de bord des données (console)
|
||||||
|
9. 🔒 Regroupement de jetons et restrictions de modèles
|
||||||
|
10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC)
|
||||||
|
11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
|
12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime)
|
||||||
|
13. ⚡ Prise en charge du format Claude Messages, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
14. Prise en charge de l'accès à l'interface de discussion via la route /chat2link
|
||||||
|
15. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
|
||||||
|
1. Modèles de la série o d'OpenAI
|
||||||
|
- Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`)
|
||||||
|
- Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`)
|
||||||
|
- Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`)
|
||||||
|
2. Modèles de pensée de Claude
|
||||||
|
- Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`)
|
||||||
|
16. 🔄 Fonctionnalité de la pensée au contenu
|
||||||
|
17. 🔄 Limitation du débit du modèle pour les utilisateurs
|
||||||
|
18. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
|
||||||
|
1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement`
|
||||||
|
2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint
|
||||||
|
3. Canaux pris en charge :
|
||||||
|
- [x] OpenAI
|
||||||
|
- [x] Azure
|
||||||
|
- [x] DeepSeek
|
||||||
|
- [x] Claude
|
||||||
|
|
||||||
|
## Prise en charge des modèles
|
||||||
|
|
||||||
|
Cette version prend en charge plusieurs modèles, veuillez vous référer à [Documentation de l'API-Interface de relais](https://docs.newapi.pro/api) pour plus de détails :
|
||||||
|
|
||||||
|
1. Modèles tiers **gpts** (gpt-4-gizmo-*)
|
||||||
|
2. Canal tiers [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy), [Documentation de l'API](https://docs.newapi.pro/api/midjourney-proxy-image)
|
||||||
|
3. Canal tiers [Suno API](https://github.com/Suno-API/Suno-API), [Documentation de l'API](https://docs.newapi.pro/api/suno-music)
|
||||||
|
4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel
|
||||||
|
5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
|
6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
7. Dify, ne prend actuellement en charge que chatflow
|
||||||
|
|
||||||
|
## Configuration des variables d'environnement
|
||||||
|
|
||||||
|
Pour des instructions de configuration détaillées, veuillez vous référer à [Guide d'installation-Configuration des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) :
|
||||||
|
|
||||||
|
- `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false`
|
||||||
|
- `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes
|
||||||
|
- `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true`
|
||||||
|
- `FORCE_STREAM_OPTION` : S'il faut remplacer le paramètre client stream_options, la valeur par défaut est `true`
|
||||||
|
- `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true`
|
||||||
|
- `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true`
|
||||||
|
- `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true`
|
||||||
|
- `COHERE_SAFETY_SETTING` : Paramètres de sécurité du modèle Cohere, les options sont `NONE`, `CONTEXTUAL`, `STRICT`, la valeur par défaut est `NONE`
|
||||||
|
- `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16`
|
||||||
|
- `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20`
|
||||||
|
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données
|
||||||
|
- `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview`
|
||||||
|
- `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes
|
||||||
|
- `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2`
|
||||||
|
- `ERROR_LOG_ENABLED=true` : S'il faut enregistrer et afficher les journaux d'erreurs, la valeur par défaut est `false`
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
Pour des guides de déploiement détaillés, veuillez vous référer à [Guide d'installation-Méthodes de déploiement](https://docs.newapi.pro/installation) :
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Dernière image Docker : `calciumion/new-api:latest`
|
||||||
|
|
||||||
|
### Considérations sur le déploiement multi-machines
|
||||||
|
- La variable d'environnement `SESSION_SECRET` doit être définie, sinon l'état de connexion sera incohérent sur plusieurs machines
|
||||||
|
- Si vous partagez Redis, `CRYPTO_SECRET` doit être défini, sinon le contenu de Redis ne pourra pas être consulté sur plusieurs machines
|
||||||
|
|
||||||
|
### Exigences de déploiement
|
||||||
|
- Base de données locale (par défaut) : SQLite (le déploiement Docker doit monter le répertoire `/data`)
|
||||||
|
- Base de données distante : MySQL version >= 5.7.8, PgSQL version >= 9.6
|
||||||
|
|
||||||
|
### Méthodes de déploiement
|
||||||
|
|
||||||
|
#### Utilisation de la fonctionnalité Docker du panneau BaoTa
|
||||||
|
Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
|
||||||
|
[Tutoriel avec des images](./docs/BT.md)
|
||||||
|
|
||||||
|
#### Utilisation de Docker Compose (recommandé)
|
||||||
|
```shell
|
||||||
|
# Télécharger le projet
|
||||||
|
git clone https://github.com/Calcium-Ion/new-api.git
|
||||||
|
cd new-api
|
||||||
|
# Modifier docker-compose.yml si nécessaire
|
||||||
|
# Démarrer
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Utilisation directe de l'image Docker
|
||||||
|
```shell
|
||||||
|
# Utilisation de SQLite
|
||||||
|
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||||
|
|
||||||
|
# Utilisation de MySQL
|
||||||
|
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nouvelle tentative de canal et cache
|
||||||
|
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux`. Il est **recommandé d'activer la mise en cache**.
|
||||||
|
|
||||||
|
### Méthode de configuration du cache
|
||||||
|
1. `REDIS_CONN_STRING` : Définir Redis comme cache
|
||||||
|
2. `MEMORY_CACHE_ENABLED` : Activer le cache mémoire (pas besoin de le définir manuellement si Redis est défini)
|
||||||
|
|
||||||
|
## Documentation de l'API
|
||||||
|
|
||||||
|
Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) :
|
||||||
|
|
||||||
|
- [API de discussion](https://docs.newapi.pro/api/openai-chat)
|
||||||
|
- [API d'image](https://docs.newapi.pro/api/openai-image)
|
||||||
|
- [API de rerank](https://docs.newapi.pro/api/jinaai-rerank)
|
||||||
|
- [API en temps réel](https://docs.newapi.pro/api/openai-realtime)
|
||||||
|
- [API de discussion Claude (messages)](https://docs.newapi.pro/api/anthropic-chat)
|
||||||
|
|
||||||
|
## Projets connexes
|
||||||
|
- [One API](https://github.com/songquanpeng/one-api) : Projet original
|
||||||
|
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney
|
||||||
|
- [chatnio](https://github.com/Deeptrain-Community/chatnio) : Solution B/C unique d'IA de nouvelle génération
|
||||||
|
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé
|
||||||
|
|
||||||
|
Autres projets basés sur New API :
|
||||||
|
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API
|
||||||
|
- [VoAPI](https://github.com/VoAPI/VoAPI) : Version embellie du frontend basée sur New API
|
||||||
|
|
||||||
|
## Aide et support
|
||||||
|
|
||||||
|
Si vous avez des questions, veuillez vous référer à [Aide et support](https://docs.newapi.pro/support) :
|
||||||
|
- [Interaction avec la communauté](https://docs.newapi.pro/support/community-interaction)
|
||||||
|
- [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues)
|
||||||
|
- [FAQ](https://docs.newapi.pro/support/faq)
|
||||||
|
|
||||||
|
## 🌟 Historique des étoiles
|
||||||
|
|
||||||
|
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<p align="right">
|
<p align="right">
|
||||||
<strong>中文</strong> | <a href="./README.en.md">English</a>
|
<strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a>
|
||||||
</p>
|
</p>
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
|||||||
@ -67,6 +67,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
|
|||||||
apiType = constant.APITypeJimeng
|
apiType = constant.APITypeJimeng
|
||||||
case constant.ChannelTypeMoonshot:
|
case constant.ChannelTypeMoonshot:
|
||||||
apiType = constant.APITypeMoonshot
|
apiType = constant.APITypeMoonshot
|
||||||
|
case constant.ChannelTypeSubmodel:
|
||||||
|
apiType = constant.APITypeSubmodel
|
||||||
}
|
}
|
||||||
if apiType == -1 {
|
if apiType == -1 {
|
||||||
return constant.APITypeOpenAI, false
|
return constant.APITypeOpenAI, false
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -31,6 +31,7 @@ const (
|
|||||||
APITypeXai
|
APITypeXai
|
||||||
APITypeCoze
|
APITypeCoze
|
||||||
APITypeJimeng
|
APITypeJimeng
|
||||||
APITypeMoonshot // this one is only for count, do not add any channel after this
|
APITypeMoonshot
|
||||||
APITypeDummy // this one is only for count, do not add any channel after this
|
APITypeSubmodel
|
||||||
|
APITypeDummy // this one is only for count, do not add any channel after this
|
||||||
)
|
)
|
||||||
|
|||||||
@ -50,8 +50,10 @@ const (
|
|||||||
ChannelTypeKling = 50
|
ChannelTypeKling = 50
|
||||||
ChannelTypeJimeng = 51
|
ChannelTypeJimeng = 51
|
||||||
ChannelTypeVidu = 52
|
ChannelTypeVidu = 52
|
||||||
|
ChannelTypeSubmodel = 53
|
||||||
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
ChannelTypeDummy // this one is only for count, do not add any channel after this
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var ChannelBaseURLs = []string{
|
var ChannelBaseURLs = []string{
|
||||||
@ -108,4 +110,5 @@ var ChannelBaseURLs = []string{
|
|||||||
"https://api.klingai.com", //50
|
"https://api.klingai.com", //50
|
||||||
"https://visual.volcengineapi.com", //51
|
"https://visual.volcengineapi.com", //51
|
||||||
"https://api.vidu.cn", //52
|
"https://api.vidu.cn", //52
|
||||||
|
"https://llm.submodel.ai", //53
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
@ -633,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": "",
|
||||||
@ -894,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{
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
6
main.go
6
main.go
@ -18,6 +18,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/bytedance/gopkg/util/gopool"
|
"github.com/bytedance/gopkg/util/gopool"
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
@ -35,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 {
|
||||||
@ -168,6 +170,10 @@ func main() {
|
|||||||
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())
|
||||||
|
|||||||
@ -24,7 +24,7 @@ type Task struct {
|
|||||||
ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"`
|
ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"`
|
||||||
CreatedAt int64 `json:"created_at" gorm:"index"`
|
CreatedAt int64 `json:"created_at" gorm:"index"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
TaskID string `json:"task_id" gorm:"type:varchar(50);index"` // 第三方id,不一定有/ song id\ Task id
|
TaskID string `json:"task_id" gorm:"type:varchar(191);index"` // 第三方id,不一定有/ song id\ Task id
|
||||||
Platform constant.TaskPlatform `json:"platform" gorm:"type:varchar(30);index"` // 平台
|
Platform constant.TaskPlatform `json:"platform" gorm:"type:varchar(30);index"` // 平台
|
||||||
UserId int `json:"user_id" gorm:"index"`
|
UserId int `json:"user_id" gorm:"index"`
|
||||||
ChannelId int `json:"channel_id" gorm:"index"`
|
ChannelId int `json:"channel_id" gorm:"index"`
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import (
|
|||||||
// Otherwise, the sensitive information will be saved on local storage in plain text!
|
// Otherwise, the sensitive information will be saved on local storage in plain text!
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
Username string `json:"username" gorm:"unique;index" validate:"max=12"`
|
Username string `json:"username" gorm:"unique;index" validate:"max=20"`
|
||||||
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
|
Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"`
|
||||||
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
|
OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database!
|
||||||
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
|
DisplayName string `json:"display_name" gorm:"index" validate:"max=20"`
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
82
relay/channel/submodel/adaptor.go
Normal file
82
relay/channel/submodel/adaptor.go
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
package submodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"one-api/dto"
|
||||||
|
"one-api/relay/channel"
|
||||||
|
"one-api/relay/channel/openai"
|
||||||
|
relaycommon "one-api/relay/common"
|
||||||
|
"one-api/types"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Adaptor struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
|
||||||
|
return nil, errors.New("submodel channel: endpoint not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||||
|
return nil, errors.New("submodel channel: endpoint not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||||
|
return nil, errors.New("submodel channel: endpoint not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||||
|
return relaycommon.GetFullRequestURL(info.BaseUrl, info.RequestURLPath, info.ChannelType), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
|
||||||
|
channel.SetupApiRequestHeader(info, c, req)
|
||||||
|
req.Set("Authorization", "Bearer "+info.ApiKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||||
|
if request == nil {
|
||||||
|
return nil, errors.New("request is nil")
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||||
|
return nil, errors.New("submodel channel: endpoint not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
|
||||||
|
return nil, errors.New("submodel channel: endpoint not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||||
|
return nil, errors.New("submodel channel: endpoint not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||||
|
return channel.DoApiRequest(a, c, info, requestBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||||
|
if info.IsStream {
|
||||||
|
usage, err = openai.OaiStreamHandler(c, info, resp)
|
||||||
|
} else {
|
||||||
|
usage, err = openai.OpenaiHandler(c, info, resp)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) GetModelList() []string {
|
||||||
|
return ModelList
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adaptor) GetChannelName() string {
|
||||||
|
return ChannelName
|
||||||
|
}
|
||||||
16
relay/channel/submodel/constants.go
Normal file
16
relay/channel/submodel/constants.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package submodel
|
||||||
|
|
||||||
|
var ModelList = []string{
|
||||||
|
"NousResearch/Hermes-4-405B-FP8",
|
||||||
|
"Qwen/Qwen3-235B-A22B-Thinking-2507",
|
||||||
|
"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8",
|
||||||
|
"Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||||
|
"zai-org/GLM-4.5-FP8",
|
||||||
|
"openai/gpt-oss-120b",
|
||||||
|
"deepseek-ai/DeepSeek-R1-0528",
|
||||||
|
"deepseek-ai/DeepSeek-R1",
|
||||||
|
"deepseek-ai/DeepSeek-V3-0324",
|
||||||
|
"deepseek-ai/DeepSeek-V3.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChannelName = "submodel"
|
||||||
@ -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"
|
||||||
@ -188,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)
|
||||||
|
|||||||
@ -9,6 +9,11 @@ var ModelList = []string{
|
|||||||
"Doubao-lite-4k",
|
"Doubao-lite-4k",
|
||||||
"Doubao-embedding",
|
"Doubao-embedding",
|
||||||
"doubao-seedream-4-0-250828",
|
"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 {
|
||||||
|
|||||||
@ -37,7 +37,7 @@ import (
|
|||||||
"one-api/relay/channel/zhipu"
|
"one-api/relay/channel/zhipu"
|
||||||
"one-api/relay/channel/zhipu_4v"
|
"one-api/relay/channel/zhipu_4v"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"one-api/relay/channel/submodel"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -103,6 +103,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
|
|||||||
return &jimeng.Adaptor{}
|
return &jimeng.Adaptor{}
|
||||||
case constant.APITypeMoonshot:
|
case constant.APITypeMoonshot:
|
||||||
return &moonshot.Adaptor{} // Moonshot uses Claude API
|
return &moonshot.Adaptor{} // Moonshot uses Claude API
|
||||||
|
case constant.APITypeSubmodel:
|
||||||
|
return &submodel.Adaptor{}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,15 +96,20 @@ 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, must be http, https, socks5 or socks5h", parsedURL.Scheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -251,6 +251,17 @@ var defaultModelRatio = map[string]float64{
|
|||||||
"grok-vision-beta": 2.5,
|
"grok-vision-beta": 2.5,
|
||||||
"grok-3-fast-beta": 2.5,
|
"grok-3-fast-beta": 2.5,
|
||||||
"grok-3-mini-fast-beta": 0.3,
|
"grok-3-mini-fast-beta": 0.3,
|
||||||
|
// submodel
|
||||||
|
"NousResearch/Hermes-4-405B-FP8": 0.8,
|
||||||
|
"Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6,
|
||||||
|
"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8,
|
||||||
|
"Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3,
|
||||||
|
"zai-org/GLM-4.5-FP8": 0.8,
|
||||||
|
"openai/gpt-oss-120b": 0.5,
|
||||||
|
"deepseek-ai/DeepSeek-R1-0528": 0.8,
|
||||||
|
"deepseek-ai/DeepSeek-R1": 0.8,
|
||||||
|
"deepseek-ai/DeepSeek-V3-0324": 0.8,
|
||||||
|
"deepseek-ai/DeepSeek-V3.1": 0.8,
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultModelPrice = map[string]float64{
|
var defaultModelPrice = map[string]float64{
|
||||||
@ -501,7 +512,6 @@ func GetCompletionRatio(name string) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||||
lowercaseName := strings.ToLower(name)
|
|
||||||
|
|
||||||
isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*")
|
isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*")
|
||||||
if isReservedModel {
|
if isReservedModel {
|
||||||
@ -594,9 +604,6 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// hint 只给官方上4倍率,由于开源模型供应商自行定价,不对其进行补全倍率进行强制对齐
|
// hint 只给官方上4倍率,由于开源模型供应商自行定价,不对其进行补全倍率进行强制对齐
|
||||||
if lowercaseName == "deepseek-chat" || lowercaseName == "deepseek-reasoner" {
|
|
||||||
return 4, true
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(name, "ERNIE-Speed-") {
|
if strings.HasPrefix(name, "ERNIE-Speed-") {
|
||||||
return 2, true
|
return 2, true
|
||||||
} else if strings.HasPrefix(name, "ERNIE-Lite-") {
|
} else if strings.HasPrefix(name, "ERNIE-Lite-") {
|
||||||
|
|||||||
@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, Dropdown } from '@douyinfe/semi-ui';
|
import { Button, Dropdown } from '@douyinfe/semi-ui';
|
||||||
import { Languages } from 'lucide-react';
|
import { Languages } from 'lucide-react';
|
||||||
import { CN, GB } from 'country-flag-icons/react/3x2';
|
import { CN, GB, FR } from 'country-flag-icons/react/3x2';
|
||||||
|
|
||||||
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
||||||
return (
|
return (
|
||||||
@ -42,12 +42,19 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
|||||||
<GB title='English' className='!w-5 !h-auto' />
|
<GB title='English' className='!w-5 !h-auto' />
|
||||||
<span>English</span>
|
<span>English</span>
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item
|
||||||
|
onClick={() => onLanguageChange('fr')}
|
||||||
|
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'fr' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||||
|
>
|
||||||
|
<FR title='Français' className='!w-5 !h-auto' />
|
||||||
|
<span>Français</span>
|
||||||
|
</Dropdown.Item>
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
icon={<Languages size={18} />}
|
icon={<Languages size={18} />}
|
||||||
aria-label={t('切换语言')}
|
aria-label={t('common.changeLanguage')}
|
||||||
theme='borderless'
|
theme='borderless'
|
||||||
type='tertiary'
|
type='tertiary'
|
||||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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,10 +169,11 @@ const AccountManagement = ({
|
|||||||
{t('微信')}
|
{t('微信')}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm text-gray-500 truncate'>
|
<div className='text-sm text-gray-500 truncate'>
|
||||||
{renderAccountInfo(
|
{!status.wechat_login
|
||||||
userState.user?.wechat_id,
|
? t('未启用')
|
||||||
t('微信 ID'),
|
: isBound(userState.user?.wechat_id)
|
||||||
)}
|
? t('已绑定')
|
||||||
|
: t('未绑定')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -180,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('绑定')
|
||||||
@ -221,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('未启用')}
|
||||||
@ -265,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('未启用')}
|
||||||
@ -299,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'>
|
||||||
@ -351,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('未启用')}
|
||||||
|
|||||||
@ -103,6 +103,7 @@ const MODEL_FETCHABLE_TYPES = new Set([
|
|||||||
40,
|
40,
|
||||||
42,
|
42,
|
||||||
48,
|
48,
|
||||||
|
43,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function type2secretPrompt(type) {
|
function type2secretPrompt(type) {
|
||||||
@ -164,6 +165,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);
|
||||||
@ -189,6 +192,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({
|
||||||
@ -235,7 +239,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 });
|
||||||
|
|
||||||
// 处理渠道额外设置的更新
|
// 处理渠道额外设置的更新
|
||||||
@ -342,6 +346,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;
|
||||||
@ -433,15 +441,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);
|
||||||
@ -453,6 +473,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({
|
||||||
@ -712,6 +734,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);
|
||||||
@ -844,6 +868,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 !== '' &&
|
||||||
@ -873,6 +901,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;
|
||||||
@ -880,6 +923,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;
|
||||||
|
|
||||||
@ -1264,6 +1308,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('名称')}
|
||||||
@ -1883,6 +1942,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -118,6 +118,9 @@ const EditTagModal = (props) => {
|
|||||||
case 36:
|
case 36:
|
||||||
localModels = ['suno_music', 'suno_lyrics'];
|
localModels = ['suno_music', 'suno_lyrics'];
|
||||||
break;
|
break;
|
||||||
|
case 53:
|
||||||
|
localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1'];
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
localModels = getChannelModels(value);
|
localModels = getChannelModels(value);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -159,6 +159,11 @@ export const CHANNEL_OPTIONS = [
|
|||||||
color: 'purple',
|
color: 'purple',
|
||||||
label: 'Vidu',
|
label: 'Vidu',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 53,
|
||||||
|
color: 'blue',
|
||||||
|
label: 'SubModel',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MODEL_TABLE_PAGE_SIZE = 10;
|
export const MODEL_TABLE_PAGE_SIZE = 10;
|
||||||
|
|||||||
@ -1200,25 +1200,25 @@ export function renderModelPrice(
|
|||||||
const extraServices = [
|
const extraServices = [
|
||||||
webSearch && webSearchCallCount > 0
|
webSearch && webSearchCallCount > 0
|
||||||
? i18next.t(
|
? i18next.t(
|
||||||
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||||
{
|
{
|
||||||
count: webSearchCallCount,
|
count: webSearchCallCount,
|
||||||
price: webSearchPrice,
|
price: webSearchPrice,
|
||||||
ratio: groupRatio,
|
ratio: groupRatio,
|
||||||
ratioType: ratioLabel,
|
ratioType: ratioLabel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: '',
|
: '',
|
||||||
fileSearch && fileSearchCallCount > 0
|
fileSearch && fileSearchCallCount > 0
|
||||||
? i18next.t(
|
? i18next.t(
|
||||||
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
|
||||||
{
|
{
|
||||||
count: fileSearchCallCount,
|
count: fileSearchCallCount,
|
||||||
price: fileSearchPrice,
|
price: fileSearchPrice,
|
||||||
ratio: groupRatio,
|
ratio: groupRatio,
|
||||||
ratioType: ratioLabel,
|
ratioType: ratioLabel,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: '',
|
: '',
|
||||||
imageGenerationCall && imageGenerationCallPrice > 0
|
imageGenerationCall && imageGenerationCallPrice > 0
|
||||||
? i18next.t(
|
? i18next.t(
|
||||||
@ -1398,10 +1398,10 @@ export function renderAudioModelPrice(
|
|||||||
let audioPrice =
|
let audioPrice =
|
||||||
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
|
(audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
|
||||||
(audioCompletionTokens / 1000000) *
|
(audioCompletionTokens / 1000000) *
|
||||||
inputRatioPrice *
|
inputRatioPrice *
|
||||||
audioRatio *
|
audioRatio *
|
||||||
audioCompletionRatio *
|
audioCompletionRatio *
|
||||||
groupRatio;
|
groupRatio;
|
||||||
let price = textPrice + audioPrice;
|
let price = textPrice + audioPrice;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -1457,27 +1457,27 @@ export function renderAudioModelPrice(
|
|||||||
<p>
|
<p>
|
||||||
{cacheTokens > 0
|
{cacheTokens > 0
|
||||||
? i18next.t(
|
? i18next.t(
|
||||||
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
'文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||||
{
|
{
|
||||||
nonCacheInput: inputTokens - cacheTokens,
|
nonCacheInput: inputTokens - cacheTokens,
|
||||||
cacheInput: cacheTokens,
|
cacheInput: cacheTokens,
|
||||||
cachePrice: inputRatioPrice * cacheRatio,
|
cachePrice: inputRatioPrice * cacheRatio,
|
||||||
price: inputRatioPrice,
|
price: inputRatioPrice,
|
||||||
completion: completionTokens,
|
completion: completionTokens,
|
||||||
compPrice: completionRatioPrice,
|
compPrice: completionRatioPrice,
|
||||||
total: textPrice.toFixed(6),
|
total: textPrice.toFixed(6),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: i18next.t(
|
: i18next.t(
|
||||||
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
'文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
|
||||||
{
|
{
|
||||||
input: inputTokens,
|
input: inputTokens,
|
||||||
price: inputRatioPrice,
|
price: inputRatioPrice,
|
||||||
completion: completionTokens,
|
completion: completionTokens,
|
||||||
compPrice: completionRatioPrice,
|
compPrice: completionRatioPrice,
|
||||||
total: textPrice.toFixed(6),
|
total: textPrice.toFixed(6),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{i18next.t(
|
{i18next.t(
|
||||||
@ -1617,35 +1617,35 @@ export function renderClaudeModelPrice(
|
|||||||
<p>
|
<p>
|
||||||
{cacheTokens > 0 || cacheCreationTokens > 0
|
{cacheTokens > 0 || cacheCreationTokens > 0
|
||||||
? i18next.t(
|
? i18next.t(
|
||||||
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
'提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||||
{
|
{
|
||||||
nonCacheInput: nonCachedTokens,
|
nonCacheInput: nonCachedTokens,
|
||||||
cacheInput: cacheTokens,
|
cacheInput: cacheTokens,
|
||||||
cacheRatio: cacheRatio,
|
cacheRatio: cacheRatio,
|
||||||
cacheCreationInput: cacheCreationTokens,
|
cacheCreationInput: cacheCreationTokens,
|
||||||
cacheCreationRatio: cacheCreationRatio,
|
cacheCreationRatio: cacheCreationRatio,
|
||||||
cachePrice: cacheRatioPrice,
|
cachePrice: cacheRatioPrice,
|
||||||
cacheCreationPrice: cacheCreationRatioPrice,
|
cacheCreationPrice: cacheCreationRatioPrice,
|
||||||
price: inputRatioPrice,
|
price: inputRatioPrice,
|
||||||
completion: completionTokens,
|
completion: completionTokens,
|
||||||
compPrice: completionRatioPrice,
|
compPrice: completionRatioPrice,
|
||||||
ratio: groupRatio,
|
ratio: groupRatio,
|
||||||
ratioType: ratioLabel,
|
ratioType: ratioLabel,
|
||||||
total: price.toFixed(6),
|
total: price.toFixed(6),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: i18next.t(
|
: i18next.t(
|
||||||
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
'提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
|
||||||
{
|
{
|
||||||
input: inputTokens,
|
input: inputTokens,
|
||||||
price: inputRatioPrice,
|
price: inputRatioPrice,
|
||||||
completion: completionTokens,
|
completion: completionTokens,
|
||||||
compPrice: completionRatioPrice,
|
compPrice: completionRatioPrice,
|
||||||
ratio: groupRatio,
|
ratio: groupRatio,
|
||||||
ratioType: ratioLabel,
|
ratioType: ratioLabel,
|
||||||
total: price.toFixed(6),
|
total: price.toFixed(6),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
|
<p>{i18next.t('仅供参考,以实际扣费为准')}</p>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@ -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,10 +79,11 @@ 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);
|
||||||
const [currentMultiKeyChannel, setCurrentMultiKeyChannel] = useState(null);
|
const [currentMultiKeyChannel, setCurrentMultiKeyChannel] = useState(null);
|
||||||
@ -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
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { initReactI18next } from 'react-i18next';
|
|||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
import enTranslation from './locales/en.json';
|
import enTranslation from './locales/en.json';
|
||||||
|
import frTranslation from './locales/fr.json';
|
||||||
import zhTranslation from './locales/zh.json';
|
import zhTranslation from './locales/zh.json';
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
@ -36,6 +37,9 @@ i18n
|
|||||||
zh: {
|
zh: {
|
||||||
translation: zhTranslation,
|
translation: zhTranslation,
|
||||||
},
|
},
|
||||||
|
fr: {
|
||||||
|
translation: frTranslation,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fallbackLng: 'zh',
|
fallbackLng: 'zh',
|
||||||
interpolation: {
|
interpolation: {
|
||||||
|
|||||||
@ -2130,5 +2130,8 @@
|
|||||||
"域名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.",
|
"域名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",
|
"域名黑名单": "Domain Blacklist",
|
||||||
"白名单": "Whitelist",
|
"白名单": "Whitelist",
|
||||||
"黑名单": "Blacklist"
|
"黑名单": "Blacklist",
|
||||||
|
"common": {
|
||||||
|
"changeLanguage": "Change Language"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2140
web/src/i18n/locales/fr.json
Normal file
2140
web/src/i18n/locales/fr.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -33,5 +33,8 @@
|
|||||||
"输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999",
|
"输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999",
|
||||||
"更新SSRF防护设置": "更新SSRF防护设置",
|
"更新SSRF防护设置": "更新SSRF防护设置",
|
||||||
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
|
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
|
||||||
|
"common": {
|
||||||
|
"changeLanguage": "切换语言"
|
||||||
|
},
|
||||||
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
|
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user