diff --git a/README.en.md b/README.en.md
index 69fd32f8b..2349104aa 100644
--- a/README.en.md
+++ b/README.en.md
@@ -1,5 +1,5 @@
diff --git a/README.fr.md b/README.fr.md
new file mode 100644
index 000000000..de788ede4
--- /dev/null
+++ b/README.fr.md
@@ -0,0 +1,216 @@
+
+ 中文 | English | Français
+
+
+
+
+
+# New API
+
+🍥 Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## 📝 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.
+
+
🤝 Partenaires de confiance
+
+
Sans ordre particulier
+
+
+
+
+
+
+
+
+
+## 📚 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)
\ No newline at end of file
diff --git a/README.md b/README.md
index d68b3e135..2103fe8fc 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
- 中文 | English
+ 中文 | English | Français
diff --git a/common/api_type.go b/common/api_type.go
index 5ac46c863..855eef84f 100644
--- a/common/api_type.go
+++ b/common/api_type.go
@@ -67,6 +67,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeJimeng
case constant.ChannelTypeMoonshot:
apiType = constant.APITypeMoonshot
+ case constant.ChannelTypeSubmodel:
+ apiType = constant.APITypeSubmodel
}
if apiType == -1 {
return constant.APITypeOpenAI, false
diff --git a/common/sys_log.go b/common/sys_log.go
index 478015f07..b29adc3e6 100644
--- a/common/sys_log.go
+++ b/common/sys_log.go
@@ -2,9 +2,10 @@ package common
import (
"fmt"
- "github.com/gin-gonic/gin"
"os"
"time"
+
+ "github.com/gin-gonic/gin"
)
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)
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")
+}
diff --git a/common/utils.go b/common/utils.go
index 883abfd1a..21f72ec6a 100644
--- a/common/utils.go
+++ b/common/utils.go
@@ -68,6 +68,78 @@ func GetIp() (ip string) {
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 sizeMB = sizeKB * 1024
var sizeGB = sizeMB * 1024
diff --git a/constant/api_type.go b/constant/api_type.go
index f62d91d53..0ea5048f2 100644
--- a/constant/api_type.go
+++ b/constant/api_type.go
@@ -31,6 +31,7 @@ const (
APITypeXai
APITypeCoze
APITypeJimeng
- APITypeMoonshot // this one is only for count, do not add any channel after this
- APITypeDummy // this one is only for count, do not add any channel after this
+ APITypeMoonshot
+ APITypeSubmodel
+ APITypeDummy // this one is only for count, do not add any channel after this
)
diff --git a/constant/channel.go b/constant/channel.go
index 2e1cc5b07..34fb20f46 100644
--- a/constant/channel.go
+++ b/constant/channel.go
@@ -50,8 +50,10 @@ const (
ChannelTypeKling = 50
ChannelTypeJimeng = 51
ChannelTypeVidu = 52
+ ChannelTypeSubmodel = 53
ChannelTypeDummy // this one is only for count, do not add any channel after this
+
)
var ChannelBaseURLs = []string{
@@ -108,4 +110,5 @@ var ChannelBaseURLs = []string{
"https://api.klingai.com", //50
"https://visual.volcengineapi.com", //51
"https://api.vidu.cn", //52
+ "https://llm.submodel.ai", //53
}
diff --git a/controller/channel.go b/controller/channel.go
index 480d5b4f3..5d075f3c5 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -8,6 +8,7 @@ import (
"one-api/constant"
"one-api/dto"
"one-api/model"
+ "one-api/service"
"strconv"
"strings"
@@ -633,6 +634,7 @@ func AddChannel(c *gin.Context) {
common.ApiError(c, err)
return
}
+ service.ResetProxyClientCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -894,6 +896,7 @@ func UpdateChannel(c *gin.Context) {
return
}
model.InitChannelCache()
+ service.ResetProxyClientCache()
channel.Key = ""
clearChannelInfo(&channel.Channel)
c.JSON(http.StatusOK, gin.H{
diff --git a/dto/channel_settings.go b/dto/channel_settings.go
index 8791f516e..d6d6e0848 100644
--- a/dto/channel_settings.go
+++ b/dto/channel_settings.go
@@ -19,4 +19,12 @@ const (
type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
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
}
diff --git a/main.go b/main.go
index 6f4616955..e12dddc5a 100644
--- a/main.go
+++ b/main.go
@@ -18,6 +18,7 @@ import (
"os"
"strconv"
"strings"
+ "time"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-contrib/sessions"
@@ -35,6 +36,7 @@ var buildFS embed.FS
var indexPage []byte
func main() {
+ startTime := time.Now()
err := InitResources()
if err != nil {
@@ -168,6 +170,10 @@ func main() {
if port == "" {
port = strconv.Itoa(*common.Port)
}
+
+ // Log startup success message
+ common.LogStartupSuccess(startTime, port)
+
err = server.Run(":" + port)
if err != nil {
common.FatalLog("failed to start HTTP server: " + err.Error())
@@ -222,4 +228,4 @@ func InitResources() error {
return err
}
return nil
-}
\ No newline at end of file
+}
diff --git a/model/task.go b/model/task.go
index 4c64a5293..8e2b6d0be 100644
--- a/model/task.go
+++ b/model/task.go
@@ -24,7 +24,7 @@ type Task struct {
ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"`
CreatedAt int64 `json:"created_at" gorm:"index"`
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"` // 平台
UserId int `json:"user_id" gorm:"index"`
ChannelId int `json:"channel_id" gorm:"index"`
diff --git a/model/user.go b/model/user.go
index ea0584c5a..d3e40fa36 100644
--- a/model/user.go
+++ b/model/user.go
@@ -18,7 +18,7 @@ import (
// Otherwise, the sensitive information will be saved on local storage in plain text!
type User struct {
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"`
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"`
diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go
index a065caff7..79a0f7060 100644
--- a/relay/channel/api_request.go
+++ b/relay/channel/api_request.go
@@ -265,6 +265,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
resp, err := client.Do(req)
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"))
}
if resp == nil {
diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go
index d6b5b697e..bafe73b92 100644
--- a/relay/channel/ollama/adaptor.go
+++ b/relay/channel/ollama/adaptor.go
@@ -10,6 +10,7 @@ import (
relaycommon "one-api/relay/common"
relayconstant "one-api/relay/constant"
"one-api/types"
+ "strings"
"github.com/gin-gonic/gin"
)
@@ -17,10 +18,7 @@ import (
type Adaptor struct {
}
-func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
- //TODO implement me
- return nil, errors.New("not implemented")
-}
+func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
openaiAdaptor := openai.Adaptor{}
@@ -31,32 +29,21 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
openaiRequest.(*dto.GeneralOpenAIRequest).StreamOptions = &dto.StreamOptions{
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) {
- //TODO implement me
- return nil, errors.New("not implemented")
-}
+func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { return nil, errors.New("not implemented") }
-func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
- //TODO implement me
- return nil, errors.New("not implemented")
-}
+func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
- if info.RelayFormat == types.RelayFormatClaude {
- return info.ChannelBaseUrl + "/v1/chat/completions", nil
- }
- switch info.RelayMode {
- case relayconstant.RelayModeEmbeddings:
- return info.ChannelBaseUrl + "/api/embed", nil
- default:
- return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
- }
+ if info.RelayMode == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil }
+ if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil }
+ return info.ChannelBaseUrl + "/api/chat", nil
}
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) {
- if request == nil {
- return nil, errors.New("request is nil")
+ if request == 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) {
@@ -80,10 +69,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
return requestOpenAI2Embeddings(request), nil
}
-func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
- // TODO implement me
- return nil, errors.New("not implemented")
-}
+func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { return nil, errors.New("not implemented") }
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
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) {
switch info.RelayMode {
case relayconstant.RelayModeEmbeddings:
- usage, err = ollamaEmbeddingHandler(c, info, resp)
+ return ollamaEmbeddingHandler(c, info, resp)
default:
if info.IsStream {
- usage, err = openai.OaiStreamHandler(c, info, resp)
- } else {
- usage, err = openai.OpenaiHandler(c, info, resp)
+ return ollamaStreamHandler(c, info, resp)
}
+ return ollamaChatHandler(c, info, resp)
}
- return
}
func (a *Adaptor) GetModelList() []string {
diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go
index 317c2a4a1..45e49ab43 100644
--- a/relay/channel/ollama/dto.go
+++ b/relay/channel/ollama/dto.go
@@ -2,48 +2,69 @@ package ollama
import (
"encoding/json"
- "one-api/dto"
)
-type OllamaRequest struct {
- Model string `json:"model,omitempty"`
- Messages []dto.Message `json:"messages,omitempty"`
- Stream bool `json:"stream,omitempty"`
- Temperature *float64 `json:"temperature,omitempty"`
- Seed float64 `json:"seed,omitempty"`
- Topp float64 `json:"top_p,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 OllamaChatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content,omitempty"`
+ Images []string `json:"images,omitempty"`
+ ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"`
+ ToolName string `json:"tool_name,omitempty"`
+ Thinking json.RawMessage `json:"thinking,omitempty"`
}
-type Options struct {
- Seed int `json:"seed,omitempty"`
- Temperature *float64 `json:"temperature,omitempty"`
- TopK int `json:"top_k,omitempty"`
- TopP float64 `json:"top_p,omitempty"`
- FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
- PresencePenalty float64 `json:"presence_penalty,omitempty"`
- NumPredict int `json:"num_predict,omitempty"`
- NumCtx int `json:"num_ctx,omitempty"`
+type OllamaToolFunction struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters interface{} `json:"parameters,omitempty"`
+}
+
+type OllamaTool struct {
+ Type string `json:"type"`
+ 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 {
- Model string `json:"model,omitempty"`
- Input []string `json:"input"`
- Options *Options `json:"options,omitempty"`
+ Model string `json:"model"`
+ Input interface{} `json:"input"`
+ Options map[string]any `json:"options,omitempty"`
+ Dimensions int `json:"dimensions,omitempty"`
}
type OllamaEmbeddingResponse struct {
- Error string `json:"error,omitempty"`
- Model string `json:"model"`
- Embedding [][]float64 `json:"embeddings,omitempty"`
+ Error string `json:"error,omitempty"`
+ Model string `json:"model"`
+ Embeddings [][]float64 `json:"embeddings"`
+ PromptEvalCount int `json:"prompt_eval_count,omitempty"`
}
+
diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go
index 27c67b4ec..3b67f9525 100644
--- a/relay/channel/ollama/relay-ollama.go
+++ b/relay/channel/ollama/relay-ollama.go
@@ -1,6 +1,7 @@
package ollama
import (
+ "encoding/json"
"fmt"
"io"
"net/http"
@@ -14,121 +15,176 @@ import (
"github.com/gin-gonic/gin"
)
-func requestOpenAI2Ollama(c *gin.Context, request *dto.GeneralOpenAIRequest) (*OllamaRequest, error) {
- messages := make([]dto.Message, 0, len(request.Messages))
- for _, message := range request.Messages {
- if !message.IsStringContent() {
- mediaMessages := message.ParseContent()
- for j, mediaMessage := range mediaMessages {
- if mediaMessage.Type == dto.ContentTypeImageURL {
- imageUrl := mediaMessage.GetImageMedia()
- // check if not base64
- if strings.HasPrefix(imageUrl.Url, "http") {
- fileData, err := service.GetFileBase64FromUrl(c, imageUrl.Url, "formatting image for Ollama")
- if err != nil {
- return nil, err
+func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaChatRequest, error) {
+ chatReq := &OllamaChatRequest{
+ Model: r.Model,
+ Stream: r.Stream,
+ Options: map[string]any{},
+ Think: r.Think,
+ }
+ if r.ResponseFormat != nil {
+ if r.ResponseFormat.Type == "json" {
+ chatReq.Format = "json"
+ } else if r.ResponseFormat.Type == "json_schema" {
+ if len(r.ResponseFormat.JsonSchema) > 0 {
+ 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
- mediaMessages[j] = mediaMessage
+ } else if part.Type == dto.ContentTypeText {
+ textBuilder.WriteString(part.Text)
}
}
- message.SetMediaContent(mediaMessages)
}
- messages = append(messages, dto.Message{
- Role: message.Role,
- Content: message.Content,
- ToolCalls: message.ToolCalls,
- ToolCallId: message.ToolCallId,
- })
+ cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()}
+ if len(images)>0 { cm.Images = images }
+ if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name }
+ if m.ToolCalls != nil && len(m.ToolCalls) > 0 {
+ 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)
- 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
+ return chatReq, nil
}
-func requestOpenAI2Embeddings(request dto.EmbeddingRequest) *OllamaEmbeddingRequest {
- return &OllamaEmbeddingRequest{
- Model: request.Model,
- Input: request.ParseInput(),
- Options: &Options{
- Seed: int(request.Seed),
- Temperature: request.Temperature,
- TopP: request.TopP,
- FrequencyPenalty: request.FrequencyPenalty,
- PresencePenalty: request.PresencePenalty,
- },
+// openAIToGenerate converts OpenAI completions request to Ollama generate
+func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGenerateRequest, error) {
+ gen := &OllamaGenerateRequest{
+ Model: r.Model,
+ Stream: r.Stream,
+ Options: map[string]any{},
+ Think: r.Think,
}
+ // 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) {
- var ollamaEmbeddingResponse OllamaEmbeddingResponse
- responseBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
+ var oResp OllamaEmbeddingResponse
+ body, err := io.ReadAll(resp.Body)
+ if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
service.CloseResponseBodyGracefully(resp)
- err = common.Unmarshal(responseBody, &ollamaEmbeddingResponse)
- if err != nil {
- return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
- if ollamaEmbeddingResponse.Error != "" {
- return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", ollamaEmbeddingResponse.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
- }
- flattenedEmbeddings := flattenEmbeddings(ollamaEmbeddingResponse.Embedding)
- 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)
+ if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) }
+ if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), 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}) }
+ usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount}
+ embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage}
+ out, _ := common.Marshal(embResp)
+ service.IOCopyBytesGracefully(c, resp, out)
return usage, nil
}
-func flattenEmbeddings(embeddings [][]float64) []float64 {
- flattened := []float64{}
- for _, row := range embeddings {
- flattened = append(flattened, row...)
- }
- return flattened
-}
diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go
new file mode 100644
index 000000000..964f11d90
--- /dev/null
+++ b/relay/channel/ollama/stream.go
@@ -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 }
diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go
index 4b13a7df1..a88b68502 100644
--- a/relay/channel/openai/relay-openai.go
+++ b/relay/channel/openai/relay-openai.go
@@ -12,6 +12,7 @@ import (
"one-api/constant"
"one-api/dto"
"one-api/logger"
+ "one-api/relay/channel/openrouter"
relaycommon "one-api/relay/common"
"one-api/relay/helper"
"one-api/service"
@@ -185,10 +186,27 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
if common.DebugEnabled {
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)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
+
if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
diff --git a/relay/channel/openrouter/dto.go b/relay/channel/openrouter/dto.go
index 607f495bf..a32499852 100644
--- a/relay/channel/openrouter/dto.go
+++ b/relay/channel/openrouter/dto.go
@@ -1,5 +1,7 @@
package openrouter
+import "encoding/json"
+
type RequestReasoning struct {
// One of the following (not both):
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.
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"`
+}
diff --git a/relay/channel/submodel/adaptor.go b/relay/channel/submodel/adaptor.go
new file mode 100644
index 000000000..152391d04
--- /dev/null
+++ b/relay/channel/submodel/adaptor.go
@@ -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
+}
diff --git a/relay/channel/submodel/constants.go b/relay/channel/submodel/constants.go
new file mode 100644
index 000000000..f5e1feb84
--- /dev/null
+++ b/relay/channel/submodel/constants.go
@@ -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"
\ No newline at end of file
diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go
index eb88412af..21d6e1705 100644
--- a/relay/channel/volcengine/adaptor.go
+++ b/relay/channel/volcengine/adaptor.go
@@ -9,6 +9,7 @@ import (
"mime/multipart"
"net/http"
"net/textproto"
+ channelconstant "one-api/constant"
"one-api/dto"
"one-api/relay/channel"
"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) {
+ // 支持自定义域名,如果未设置则使用默认域名
+ baseUrl := info.ChannelBaseUrl
+ if baseUrl == "" {
+ baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
+ }
+
switch info.RelayMode {
case constant.RelayModeChatCompletions:
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:
- return fmt.Sprintf("%s/api/v3/embeddings", info.ChannelBaseUrl), nil
+ return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil
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:
- return fmt.Sprintf("%s/api/v3/images/edits", info.ChannelBaseUrl), nil
+ return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil
case constant.RelayModeRerank:
- return fmt.Sprintf("%s/api/v3/rerank", info.ChannelBaseUrl), nil
+ return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil
default:
}
return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode)
diff --git a/relay/channel/volcengine/constants.go b/relay/channel/volcengine/constants.go
index fca10e7c1..87a12b27c 100644
--- a/relay/channel/volcengine/constants.go
+++ b/relay/channel/volcengine/constants.go
@@ -9,6 +9,11 @@ var ModelList = []string{
"Doubao-lite-4k",
"Doubao-embedding",
"doubao-seedream-4-0-250828",
+ "seedream-4-0-250828",
+ "doubao-seedance-1-0-pro-250528",
+ "seedance-1-0-pro-250528",
+ "doubao-seed-1-6-thinking-250715",
+ "seed-1-6-thinking-250715",
}
var ChannelName = "volcengine"
diff --git a/relay/channel/xunfei/relay-xunfei.go b/relay/channel/xunfei/relay-xunfei.go
index 9d5c190fe..9503d5d39 100644
--- a/relay/channel/xunfei/relay-xunfei.go
+++ b/relay/channel/xunfei/relay-xunfei.go
@@ -207,10 +207,6 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
return nil, nil, err
}
- defer func() {
- conn.Close()
- }()
-
data := requestOpenAI2Xunfei(textRequest, appId, domain)
err = conn.WriteJSON(data)
if err != nil {
@@ -220,6 +216,9 @@ func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, ap
dataChan := make(chan XunfeiChatResponse)
stopChan := make(chan bool)
go func() {
+ defer func() {
+ conn.Close()
+ }()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go
index 0c271210b..406074c58 100644
--- a/relay/relay_adaptor.go
+++ b/relay/relay_adaptor.go
@@ -37,7 +37,7 @@ import (
"one-api/relay/channel/zhipu"
"one-api/relay/channel/zhipu_4v"
"strconv"
-
+ "one-api/relay/channel/submodel"
"github.com/gin-gonic/gin"
)
@@ -103,6 +103,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
return &jimeng.Adaptor{}
case constant.APITypeMoonshot:
return &moonshot.Adaptor{} // Moonshot uses Claude API
+ case constant.APITypeSubmodel:
+ return &submodel.Adaptor{}
}
return nil
}
diff --git a/service/http_client.go b/service/http_client.go
index b191ddd78..c1d6880c9 100644
--- a/service/http_client.go
+++ b/service/http_client.go
@@ -7,12 +7,17 @@ import (
"net/http"
"net/url"
"one-api/common"
+ "sync"
"time"
"golang.org/x/net/proxy"
)
-var httpClient *http.Client
+var (
+ httpClient *http.Client
+ proxyClientLock sync.Mutex
+ proxyClients = make(map[string]*http.Client)
+)
func InitHttpClient() {
if common.RelayTimeout == 0 {
@@ -28,12 +33,31 @@ func GetHttpClient() *http.Client {
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 客户端
func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
if proxyURL == "" {
return http.DefaultClient, nil
}
+ proxyClientLock.Lock()
+ if client, ok := proxyClients[proxyURL]; ok {
+ proxyClientLock.Unlock()
+ return client, nil
+ }
+ proxyClientLock.Unlock()
+
parsedURL, err := url.Parse(proxyURL)
if err != nil {
return nil, err
@@ -41,11 +65,16 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
switch parsedURL.Scheme {
case "http", "https":
- return &http.Client{
+ client := &http.Client{
Transport: &http.Transport{
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":
// 获取认证信息
@@ -67,15 +96,20 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
return nil, err
}
- return &http.Client{
+ client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
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:
- 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)
}
}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 362c6fa1a..aea6594b0 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -251,6 +251,17 @@ var defaultModelRatio = map[string]float64{
"grok-vision-beta": 2.5,
"grok-3-fast-beta": 2.5,
"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{
@@ -501,7 +512,6 @@ func GetCompletionRatio(name string) float64 {
}
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
- lowercaseName := strings.ToLower(name)
isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*")
if isReservedModel {
@@ -594,9 +604,6 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
}
}
// hint 只给官方上4倍率,由于开源模型供应商自行定价,不对其进行补全倍率进行强制对齐
- if lowercaseName == "deepseek-chat" || lowercaseName == "deepseek-reasoner" {
- return 4, true
- }
if strings.HasPrefix(name, "ERNIE-Speed-") {
return 2, true
} else if strings.HasPrefix(name, "ERNIE-Lite-") {
diff --git a/web/src/components/layout/headerbar/LanguageSelector.jsx b/web/src/components/layout/headerbar/LanguageSelector.jsx
index cbfd69b35..17bfe5c50 100644
--- a/web/src/components/layout/headerbar/LanguageSelector.jsx
+++ b/web/src/components/layout/headerbar/LanguageSelector.jsx
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Button, Dropdown } from '@douyinfe/semi-ui';
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 }) => {
return (
@@ -42,12 +42,19 @@ const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
English
+ 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'}`}
+ >
+
+ Français
+
}
>
}
- aria-label={t('切换语言')}
+ aria-label={t('common.changeLanguage')}
theme='borderless'
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'
diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx
index 3ba8dcfd3..15dfbd973 100644
--- a/web/src/components/settings/PersonalSetting.jsx
+++ b/web/src/components/settings/PersonalSetting.jsx
@@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useContext, useEffect, useState } from 'react';
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 { Modal } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
@@ -71,18 +78,40 @@ const PersonalSetting = () => {
});
useEffect(() => {
- let status = localStorage.getItem('status');
- if (status) {
- status = JSON.parse(status);
- setStatus(status);
- if (status.turnstile_check) {
+ let saved = localStorage.getItem('status');
+ if (saved) {
+ const parsed = JSON.parse(saved);
+ setStatus(parsed);
+ if (parsed.turnstile_check) {
setTurnstileEnabled(true);
- setTurnstileSiteKey(status.turnstile_site_key);
+ setTurnstileSiteKey(parsed.turnstile_site_key);
+ } else {
+ setTurnstileEnabled(false);
+ setTurnstileSiteKey('');
}
}
- getUserData().then((res) => {
- console.log(userState);
- });
+ // Always refresh status from server to avoid stale flags (e.g., admin just enabled OAuth)
+ (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(() => {
diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx
index 235b4f3ff..017e7c1e6 100644
--- a/web/src/components/settings/personal/cards/AccountManagement.jsx
+++ b/web/src/components/settings/personal/cards/AccountManagement.jsx
@@ -28,6 +28,7 @@ import {
Tabs,
TabPane,
Popover,
+ Modal,
} from '@douyinfe/semi-ui';
import {
IconMail,
@@ -83,6 +84,9 @@ const AccountManagement = ({
);
};
+ const isBound = (accountId) => Boolean(accountId);
+ const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
+
return (
{/* 卡片头部 */}
@@ -142,7 +146,7 @@ const AccountManagement = ({
size='small'
onClick={() => setShowEmailBindModal(true)}
>
- {userState.user && userState.user.email !== ''
+ {isBound(userState.user?.email)
? t('修改绑定')
: t('绑定')}
@@ -165,10 +169,11 @@ const AccountManagement = ({
{t('微信')}
- {renderAccountInfo(
- userState.user?.wechat_id,
- t('微信 ID'),
- )}
+ {!status.wechat_login
+ ? t('未启用')
+ : isBound(userState.user?.wechat_id)
+ ? t('已绑定')
+ : t('未绑定')}
@@ -180,7 +185,7 @@ const AccountManagement = ({
disabled={!status.wechat_login}
onClick={() => setShowWeChatBindModal(true)}
>
- {userState.user && userState.user?.wechat_id
+ {isBound(userState.user?.wechat_id)
? t('修改绑定')
: status.wechat_login
? t('绑定')
@@ -221,8 +226,7 @@ const AccountManagement = ({
onGitHubOAuthClicked(status.github_client_id)
}
disabled={
- (userState.user && userState.user.github_id !== '') ||
- !status.github_oauth
+ isBound(userState.user?.github_id) || !status.github_oauth
}
>
{status.github_oauth ? t('绑定') : t('未启用')}
@@ -265,8 +269,7 @@ const AccountManagement = ({
)
}
disabled={
- (userState.user && userState.user.oidc_id !== '') ||
- !status.oidc_enabled
+ isBound(userState.user?.oidc_id) || !status.oidc_enabled
}
>
{status.oidc_enabled ? t('绑定') : t('未启用')}
@@ -299,26 +302,56 @@ const AccountManagement = ({
{cacheTokens > 0
? i18next.t(
- '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
- {
- nonCacheInput: inputTokens - cacheTokens,
- cacheInput: cacheTokens,
- cachePrice: inputRatioPrice * cacheRatio,
- price: inputRatioPrice,
- completion: completionTokens,
- compPrice: completionRatioPrice,
- total: textPrice.toFixed(6),
- },
- )
+ '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+ {
+ nonCacheInput: inputTokens - cacheTokens,
+ cacheInput: cacheTokens,
+ cachePrice: inputRatioPrice * cacheRatio,
+ price: inputRatioPrice,
+ completion: completionTokens,
+ compPrice: completionRatioPrice,
+ total: textPrice.toFixed(6),
+ },
+ )
: i18next.t(
- '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
- {
- input: inputTokens,
- price: inputRatioPrice,
- completion: completionTokens,
- compPrice: completionRatioPrice,
- total: textPrice.toFixed(6),
- },
- )}
+ '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+ {
+ input: inputTokens,
+ price: inputRatioPrice,
+ completion: completionTokens,
+ compPrice: completionRatioPrice,
+ total: textPrice.toFixed(6),
+ },
+ )}
{i18next.t(
@@ -1617,35 +1617,35 @@ export function renderClaudeModelPrice(
{cacheTokens > 0 || cacheCreationTokens > 0
? 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: nonCachedTokens,
- cacheInput: cacheTokens,
- cacheRatio: cacheRatio,
- cacheCreationInput: cacheCreationTokens,
- cacheCreationRatio: cacheCreationRatio,
- cachePrice: cacheRatioPrice,
- cacheCreationPrice: cacheCreationRatioPrice,
- price: inputRatioPrice,
- completion: completionTokens,
- compPrice: completionRatioPrice,
- ratio: groupRatio,
- ratioType: ratioLabel,
- total: price.toFixed(6),
- },
- )
+ '提示 {{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,
+ cacheInput: cacheTokens,
+ cacheRatio: cacheRatio,
+ cacheCreationInput: cacheCreationTokens,
+ cacheCreationRatio: cacheCreationRatio,
+ cachePrice: cacheRatioPrice,
+ cacheCreationPrice: cacheCreationRatioPrice,
+ price: inputRatioPrice,
+ completion: completionTokens,
+ compPrice: completionRatioPrice,
+ ratio: groupRatio,
+ ratioType: ratioLabel,
+ total: price.toFixed(6),
+ },
+ )
: i18next.t(
- '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
- {
- input: inputTokens,
- price: inputRatioPrice,
- completion: completionTokens,
- compPrice: completionRatioPrice,
- ratio: groupRatio,
- ratioType: ratioLabel,
- total: price.toFixed(6),
- },
- )}
+ '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
+ {
+ input: inputTokens,
+ price: inputRatioPrice,
+ completion: completionTokens,
+ compPrice: completionRatioPrice,
+ ratio: groupRatio,
+ ratioType: ratioLabel,
+ total: price.toFixed(6),
+ },
+ )}
Bonjour, vous êtes en train de vérifier votre adresse e-mail %s.
",
+ "Le code de vérification est valide pendant %d minutes. Si vous n'êtes pas à l'origine de cette demande, veuillez l'ignorer.
",
+ "无效的参数": "Paramètre non valide",
+ "该邮箱地址未注册": "Cette adresse e-mail n'est pas enregistrée",
+ "%s密码重置": "Réinitialisation du mot de passe de %s",
+ "Bonjour, vous êtes en train de réinitialiser votre mot de passe %s.
",
+ "Le lien de réinitialisation est valide pendant %d minutes. Si vous n'êtes pas à l'origine de cette demande, veuillez l'ignorer.
",
+ "重置链接非法或已过期": "Le lien de réinitialisation est non valide ou a expiré",
+ "无法启用 GitHub OAuth,请先填入 GitHub Client ID 以及 GitHub Client Secret!": "Impossible d'activer GitHub OAuth. Veuillez d'abord saisir l'ID client et le secret client GitHub !",
+ "无法启用微信登录,请先填入微信登录相关配置信息!": "Impossible d'activer la connexion WeChat. Veuillez d'abord saisir les informations de configuration de la connexion WeChat !",
+ "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!": "Impossible d'activer la vérification Turnstile. Veuillez d'abord saisir les informations de configuration de la vérification Turnstile !",
+ "兑换码名称长度必须在1-20之间": "Le nom du code d'échange doit comporter entre 1 et 20 caractères",
+ "兑换码个数必须大于0": "Le nombre de codes d'échange doit être supérieur à 0",
+ "一次兑换码批量生成的个数不能大于 100": "Impossible de générer plus de 100 codes d'échange à la fois",
+ "当前分组上游负载已饱和,请稍后再试": "La charge en amont du groupe actuel est saturée. Veuillez réessayer plus tard",
+ "令牌名称过长": "Le nom du jeton est trop long",
+ "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期": "Le jeton a expiré et ne peut pas être activé. Veuillez modifier la date d'expiration du jeton ou le définir pour qu'il n'expire jamais",
+ "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度": "Le quota du jeton est épuisé et ne peut pas être activé. Veuillez modifier le quota restant ou le définir sur illimité",
+ "管理员关闭了密码登录": "L'administrateur a désactivé la connexion par mot de passe",
+ "无法保存会话信息,请重试": "Impossible d'enregistrer les informations de session. Veuillez réessayer",
+ "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册": "L'administrateur a désactivé l'inscription par mot de passe. Veuillez vous inscrire en utilisant la vérification de compte tiers",
+ "输入不合法 ": "Entrée non valide ",
+ "管理员开启了邮箱验证,请输入邮箱地址和验证码": "L'administrateur a activé la vérification par e-mail. Veuillez saisir votre adresse e-mail et votre code de vérification",
+ "验证码错误或已过期": "Le code de vérification est incorrect ou a expiré",
+ "无权获取同级或更高等级用户的信息": "Aucune autorisation d'accéder aux informations des utilisateurs de même niveau ou de niveau supérieur",
+ "请重试,系统生成的 UUID 竟然重复了!": "Veuillez réessayer, l'UUID généré par le système est dupliqué !",
+ "输入不合法": "Entrée non valide",
+ "无权更新同权限等级或更高权限等级的用户信息": "Aucune autorisation de mettre à jour les informations des utilisateurs de même niveau de permission ou supérieur",
+ "管理员将用户额度从 %s修改为 %s": "L'administrateur a modifié le quota de l'utilisateur de %s à %s",
+ "无权删除同权限等级或更高权限等级的用户": "Aucune autorisation de supprimer les utilisateurs de même niveau de permission ou supérieur",
+ "无法创建权限大于等于自己的用户": "Impossible de créer des utilisateurs avec des autorisations supérieures ou égales aux vôtres",
+ "用户不存在": "L'utilisateur n'existe pas",
+ "无法禁用超级管理员用户": "Impossible de désactiver l'utilisateur super administrateur",
+ "无法删除超级管理员用户": "Impossible de supprimer l'utilisateur super administrateur",
+ "普通管理员用户无法提升其他用户为管理员": "Un administrateur ordinaire ne peut pas promouvoir d'autres utilisateurs au rang d'administrateur",
+ "该用户已经是管理员": "Cet utilisateur est déjà administrateur",
+ "无法降级超级管理员用户": "Impossible de rétrograder l'utilisateur super administrateur",
+ "该用户已经是普通用户": "Cet utilisateur est déjà un utilisateur ordinaire",
+ "管理员未开启通过微信登录以及注册": "L'administrateur n'a pas activé la connexion et l'inscription via WeChat",
+ "该微信账号已被绑定": "Ce compte WeChat est déjà lié",
+ "无权进行此操作,未登录且未提供 access token": "Aucune autorisation pour cette opération : non connecté et aucun jeton d'accès fourni",
+ "无权进行此操作,access token 无效": "Aucune autorisation pour cette opération : jeton d'accès non valide",
+ "无权进行此操作,权限不足": "Aucune autorisation pour cette opération : autorisations insuffisantes",
+ "普通用户不支持指定渠道": "Les utilisateurs ordinaires ne peuvent pas spécifier de canaux",
+ "无效的渠道 ID": "ID de canal non valide",
+ "该渠道已被禁用": "Ce canal a été désactivé",
+ "无效的请求": "Requête non valide",
+ "无可用渠道": "Aucun canal disponible",
+ "Turnstile token 为空": "Le jeton Turnstile est vide",
+ "Turnstile 校验失败,请刷新重试!": "La vérification Turnstile a échoué. Veuillez actualiser et réessayer !",
+ "id 为空!": "L'ID est vide !",
+ "未提供兑换码": "Aucun code d'échange fourni",
+ "无效的 user id": "ID utilisateur non valide",
+ "无效的兑换码": "Code d'échange non valide",
+ "该兑换码已被使用": "Ce code d'échange a déjà été utilisé",
+ "通过兑换码充值 %s": "Recharger %s via le code d'échange",
+ "未提供令牌": "Aucun jeton fourni",
+ "该令牌状态不可用": "Le statut de ce jeton n'est pas disponible",
+ "该令牌已过期": "Ce jeton a expiré",
+ "该令牌额度已用尽": "Le quota de ce jeton est épuisé",
+ "无效的令牌": "Jeton non valide",
+ "id 或 userId 为空!": "L'ID ou l'userID est vide !",
+ "quota 不能为负数!": "Le quota ne peut pas être négatif !",
+ "令牌额度不足": "Quota de jeton insuffisant",
+ "用户额度不足": "Quota utilisateur insuffisant",
+ "您的额度即将用尽": "Votre quota est presque épuisé",
+ "您的额度已用尽": "Votre quota est épuisé",
+ "%s,当前剩余额度为 %d,为了不影响您的使用,请及时充值。