mirror of
https://github.com/YFGaia/dify-plus.git
synced 2026-06-12 18:11:42 +08:00
Compare commits
18 Commits
1.12.1.fix
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| cb0233f7b6 | |||
| 6c0c6ba1fe | |||
| 3240e6e4e5 | |||
| 8d823787b7 | |||
| 84fc7bcb43 | |||
| e08b1b079c | |||
| 3f6ef97148 | |||
| 33a34e4181 | |||
| 347e48cef6 | |||
| d2a7ade1b0 | |||
| efc25217dc | |||
| 618a355ec8 | |||
| d474f15673 | |||
| 3bad30bff1 | |||
| ea77171028 | |||
| bb1db4ca99 | |||
| 5618c89721 | |||
| 3a02769e4a |
@@ -116,6 +116,7 @@ func proxyWithAccountId(c *gin.Context, accountId string) {
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.Int("body_len", len(body)),
|
||||
zap.String("body_model", bodyModel),
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
)
|
||||
|
||||
// 余额前置检查:余额耗尽时直接拦截,不继续请求上游
|
||||
@@ -126,6 +127,9 @@ func proxyWithAccountId(c *gin.Context, accountId string) {
|
||||
|
||||
if err = modelProviderService.ProxyRequest(
|
||||
accountId, path, c.Request.Method, reqHeader, body, c.Writer); err != nil {
|
||||
global.GVA_LOG.Info("Gaia代理请求body",
|
||||
zap.String("body", string(body)),
|
||||
)
|
||||
global.GVA_LOG.Error("代理请求失败", zap.String("account_id", accountId), zap.String("path", path), zap.Error(err))
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": err.Error()}})
|
||||
@@ -190,18 +194,39 @@ func (m *ModelProviderApi) TestProviderCredentials(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 隐藏API Key的大部分内容
|
||||
maskedKey := ""
|
||||
if len(creds.APIKey) > 8 {
|
||||
maskedKey = creds.APIKey[:4] + "****" + creds.APIKey[len(creds.APIKey)-4:]
|
||||
} else {
|
||||
maskedKey = "****"
|
||||
}
|
||||
var result map[string]interface{}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"provider": providerName,
|
||||
"has_api_key": creds.APIKey != "",
|
||||
"api_key": maskedKey,
|
||||
// AWS Bedrock:展示 access key 信息
|
||||
if creds.AWSAccessKeyID != "" {
|
||||
maskedKey := "****"
|
||||
if len(creds.AWSAccessKeyID) > 8 {
|
||||
maskedKey = creds.AWSAccessKeyID[:4] + "****" + creds.AWSAccessKeyID[len(creds.AWSAccessKeyID)-4:]
|
||||
}
|
||||
region := creds.AWSRegion
|
||||
if region == "" {
|
||||
region = "us-east-1(默认)"
|
||||
}
|
||||
result = map[string]interface{}{
|
||||
"provider": providerName,
|
||||
"has_api_key": true,
|
||||
"api_key": maskedKey,
|
||||
"aws_access_key_id": maskedKey,
|
||||
"aws_region": region,
|
||||
"has_session_token": creds.AWSSessionToken != "",
|
||||
}
|
||||
} else {
|
||||
// 隐藏API Key的大部分内容
|
||||
maskedKey := ""
|
||||
if len(creds.APIKey) > 8 {
|
||||
maskedKey = creds.APIKey[:4] + "****" + creds.APIKey[len(creds.APIKey)-4:]
|
||||
} else {
|
||||
maskedKey = "****"
|
||||
}
|
||||
result = map[string]interface{}{
|
||||
"provider": providerName,
|
||||
"has_api_key": creds.APIKey != "",
|
||||
"api_key": maskedKey,
|
||||
}
|
||||
}
|
||||
|
||||
response.OkWithData(result, c)
|
||||
|
||||
@@ -5,5 +5,5 @@ type Gaia struct {
|
||||
LoginMaxErrorLimit int `mapstructure:"login_max_error_limit" json:"login_max_error_limit" yaml:"login_max_error_limit"`
|
||||
SuperAdminAccountId string `mapstructure:"SUPER_ADMIN_ACCOUNT_ID" json:"SUPER_ADMIN_ACCOUNT_ID" yaml:"SUPER_ADMIN_ACCOUNT_ID"` // 超级管理员账号
|
||||
SuperAdminTenantId string `mapstructure:"SUPER_ADMIN_TENANT_ID" json:"SUPER_ADMIN_TENANT_ID" yaml:"SUPER_ADMIN_TENANT_ID"` // 系统默认工作区
|
||||
StoragePath string `mapstructure:"storage-path" json:"storage-path" yaml:"storage-path"` // Dify storage 目录路径,用于读取私钥
|
||||
StoragePath string `mapstructure:"storage-path" json:"storage-path" yaml:"storage-path"` // Dify storage 目录路径,用于读取私钥
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ const (
|
||||
ProviderAzure = "azure"
|
||||
ProviderZhipuai = "zhipuai"
|
||||
ProviderMinimax = "minimax"
|
||||
ProviderAWS = "aws" // AWS Bedrock 渠道(用于转发 Claude 等 Anthropic 模型)
|
||||
ProviderDeepSeek = "deepseek" // DeepSeek 渠道
|
||||
)
|
||||
|
||||
// DifyProviderTypeCustom Dify providers 表 provider_type 枚举
|
||||
@@ -16,23 +18,31 @@ const DifyProviderTypeCustom = "custom"
|
||||
|
||||
// 凭证配置中的 key 名
|
||||
const (
|
||||
ConfigKeyOpenaiAPIKey = "openai_api_key"
|
||||
ConfigKeyOpenaiAPIBase = "openai_api_base"
|
||||
ConfigKeyOpenaiAPIVersion = "openai_api_version"
|
||||
ConfigKeyDashScopeAPIKey = "dashscope_api_key"
|
||||
ConfigKeyAPIKey = "api_key"
|
||||
ConfigKeyOpenaiAPIKey = "openai_api_key"
|
||||
ConfigKeyOpenaiAPIBase = "openai_api_base"
|
||||
ConfigKeyOpenaiAPIVersion = "openai_api_version"
|
||||
ConfigKeyDashScopeAPIKey = "dashscope_api_key"
|
||||
ConfigKeyAPIKey = "api_key"
|
||||
|
||||
// AWS Bedrock 凭证字段(Dify bedrock provider 的 encrypted_config 中使用)
|
||||
ConfigKeyAWSAccessKeyID = "aws_access_key_id"
|
||||
ConfigKeyAWSSecretAccessKey = "aws_secret_access_key"
|
||||
ConfigKeyAWSSessionToken = "aws_session_token"
|
||||
ConfigKeyAWSRegion = "aws_region"
|
||||
ConfigKeyBedrockProxyURL = "bedrock_proxy_url" // 可选:HTTP 代理地址,格式 host:port 或 http://host:port
|
||||
)
|
||||
|
||||
// SupportedProviders 列表展示的提供商顺序
|
||||
var SupportedProviders = []string{ProviderOpenai, ProviderTongyi, ProviderGoogle, ProviderAnthropic, ProviderAzure, ProviderZhipuai, ProviderMinimax}
|
||||
var SupportedProviders = []string{ProviderOpenai, ProviderTongyi, ProviderGoogle, ProviderAnthropic, ProviderAWS, ProviderAzure, ProviderZhipuai, ProviderMinimax, ProviderDeepSeek}
|
||||
|
||||
// DefaultChatCompletionsEndpoints 各提供商聊天接口默认完整 URL(兼容旧 ProxyChat)
|
||||
var DefaultChatCompletionsEndpoints = map[string]string{
|
||||
ProviderOpenai: "https://api.openai.com/v1/chat/completions",
|
||||
ProviderTongyi: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
ProviderGoogle: "https://generativelanguage.googleapis.com/v1beta/chat/completions",
|
||||
ProviderZhipuai: "https://open.bigmodel.cn/api/paas/v4/chat/completions",
|
||||
ProviderMinimax: "https://api.minimax.chat/v1/text/chatcompletion_v2",
|
||||
ProviderOpenai: "https://api.openai.com/v1/chat/completions",
|
||||
ProviderTongyi: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
ProviderGoogle: "https://generativelanguage.googleapis.com/v1beta/chat/completions",
|
||||
ProviderZhipuai: "https://open.bigmodel.cn/api/paas/v4/chat/completions",
|
||||
ProviderMinimax: "https://api.minimax.chat/v1/text/chatcompletion_v2",
|
||||
ProviderDeepSeek: "https://api.deepseek.com/v1/chat/completions",
|
||||
// Azure 需要动态构建 URL,不使用默认值
|
||||
}
|
||||
|
||||
@@ -42,8 +52,10 @@ var DefaultAPIBase = map[string]string{
|
||||
ProviderTongyi: "https://dashscope.aliyuncs.com/compatible-mode",
|
||||
ProviderGoogle: "https://generativelanguage.googleapis.com",
|
||||
ProviderAnthropic: "https://api.anthropic.com",
|
||||
ProviderAWS: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
ProviderZhipuai: "https://open.bigmodel.cn",
|
||||
ProviderMinimax: "https://api.minimax.chat",
|
||||
ProviderDeepSeek: "https://api.deepseek.com",
|
||||
// Azure 的 base URL 来自 openai_api_base 配置,不设置默认值
|
||||
}
|
||||
|
||||
@@ -73,27 +85,57 @@ const (
|
||||
// - 输入/输出价格均为「每百万 token」,换算为每千 token 时除以 1000。
|
||||
var BuiltinModelPricing = map[string]ModelPricing{
|
||||
// ──── 通义千问 Qwen3 系列(RMB / 百万 token,128K 档) ────
|
||||
"qwen3-235b-a22b": {Input: 0.4 / 1000, Output: 1.6 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-30b-a3b": {Input: 0.11 / 1000, Output: 0.44 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-32b": {Input: 0.8 / 1000, Output: 3.2 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-14b": {Input: 0.3 / 1000, Output: 1.2 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-8b": {Input: 0.1 / 1000, Output: 0.4 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-4b": {Input: 0.04 / 1000, Output: 0.16 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-1.7b": {Input: 0.02 / 1000, Output: 0.08 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-0.6b": {Input: 0.01 / 1000, Output: 0.04 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-235b-a22b": {Input: 0.4 / 1000, Output: 1.6 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-30b-a3b": {Input: 0.11 / 1000, Output: 0.44 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-32b": {Input: 0.8 / 1000, Output: 3.2 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-14b": {Input: 0.3 / 1000, Output: 1.2 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-8b": {Input: 0.1 / 1000, Output: 0.4 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-4b": {Input: 0.04 / 1000, Output: 0.16 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-1.7b": {Input: 0.02 / 1000, Output: 0.08 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3-0.6b": {Input: 0.01 / 1000, Output: 0.04 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
|
||||
// ──── 通义千问 Qwen3.5 系列(RMB / 百万 token,128K 档) ────
|
||||
"qwen3.5-plus": {Input: 0.8 / 1000, Output: 4.8 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3.5-turbo": {Input: 0.3 / 1000, Output: 1.2 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3.5-plus": {Input: 0.8 / 1000, Output: 4.8 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen3.5-turbo": {Input: 0.3 / 1000, Output: 1.2 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
|
||||
// ──── 通义千问 Qwen2.5 系列(RMB / 百万 token) ────
|
||||
"qwen2.5-72b-instruct": {Input: 4.0 / 1000, Output: 12.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen2.5-32b-instruct": {Input: 3.5 / 1000, Output: 7.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen2.5-14b-instruct": {Input: 2.0 / 1000, Output: 6.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen2.5-7b-instruct": {Input: 1.0 / 1000, Output: 2.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen2.5-3b-instruct": {Input: 0.3 / 1000, Output: 0.6 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen-plus": {Input: 0.8 / 1000, Output: 2.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen-turbo": {Input: 0.3 / 1000, Output: 0.6 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen-max": {Input: 40.0 / 1000, Output: 120.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen-long": {Input: 0.5 / 1000, Output: 2.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen2.5-72b-instruct": {Input: 4.0 / 1000, Output: 12.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen2.5-32b-instruct": {Input: 3.5 / 1000, Output: 7.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen2.5-14b-instruct": {Input: 2.0 / 1000, Output: 6.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen2.5-7b-instruct": {Input: 1.0 / 1000, Output: 2.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen2.5-3b-instruct": {Input: 0.3 / 1000, Output: 0.6 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen-plus": {Input: 0.8 / 1000, Output: 2.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen-turbo": {Input: 0.3 / 1000, Output: 0.6 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen-max": {Input: 40.0 / 1000, Output: 120.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"qwen-long": {Input: 0.5 / 1000, Output: 2.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
|
||||
// ──── 月之暗面 Kimi 系列(RMB / 百万 token) ────
|
||||
// Kimi 走 tongyi(百炼)渠道转发,命名沿用 kimi 前缀;前缀匹配会让 kimi2-k2.6-xxx 也命中 kimi2-k2.6
|
||||
"kimi2-k2.6": {Input: 4.0 / 1000, Output: 16.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"moonshot-v1-8k": {Input: 12.0 / 1000, Output: 12.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"moonshot-v1-32k": {Input: 24.0 / 1000, Output: 24.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
"moonshot-v1-128k": {Input: 60.0 / 1000, Output: 60.0 / 1000, Unit: 0.001, Currency: "RMB"},
|
||||
|
||||
// ──── Anthropic Claude 系列(USD / 百万 token) ────
|
||||
// Claude 4.6 / 4.7 系列(Sonnet 与 Opus);anthropic 直连与 AWS Bedrock 走同一份定价
|
||||
"claude-sonnet-4-6": {Input: 3.0 / 1000, Output: 15.0 / 1000, Unit: 0.001, Currency: "USD"},
|
||||
"claude-sonnet-4-7": {Input: 3.0 / 1000, Output: 15.0 / 1000, Unit: 0.001, Currency: "USD"},
|
||||
"claude-opus-4-6": {Input: 15.0 / 1000, Output: 75.0 / 1000, Unit: 0.001, Currency: "USD"},
|
||||
"claude-opus-4-7": {Input: 15.0 / 1000, Output: 75.0 / 1000, Unit: 0.001, Currency: "USD"},
|
||||
// AWS Bedrock 上常用的模型 ID 形式(带 anthropic. 前缀与 -v1:0 后缀),单独列出避免前缀匹配漂移
|
||||
"anthropic.claude-sonnet-4-6-v1:0": {Input: 3.0 / 1000, Output: 15.0 / 1000, Unit: 0.001, Currency: "USD"},
|
||||
"anthropic.claude-sonnet-4-7-v1:0": {Input: 3.0 / 1000, Output: 15.0 / 1000, Unit: 0.001, Currency: "USD"},
|
||||
"anthropic.claude-opus-4-6-v1:0": {Input: 15.0 / 1000, Output: 75.0 / 1000, Unit: 0.001, Currency: "USD"},
|
||||
"anthropic.claude-opus-4-7-v1:0": {Input: 15.0 / 1000, Output: 75.0 / 1000, Unit: 0.001, Currency: "USD"},
|
||||
|
||||
// ──── OpenAI 图片生成(按次计费,Input 字段表示「每次请求的 USD 单价」) ────
|
||||
// 命中分支见 service/gaia/model_provider.go 中的 isImageOrPerRequestPath 与 ProxyRequest 计费逻辑
|
||||
"gpt-image-1": {Input: 0.04, Currency: "USD"},
|
||||
"gpt-image-2": {Input: 0.05, Currency: "USD"},
|
||||
|
||||
// ──── DeepSeek 系列(USD / 百万 token) ────
|
||||
// deepseek-v4-pro:旗舰推理模型,定价参考官方 https://platform.deepseek.com/api-docs/pricing
|
||||
"deepseek-v4-pro": {Input: 2.19 / 1000, Output: 8.19 / 1000, Unit: 0.001, Currency: "USD"},
|
||||
// deepseek-v4-flash:高速轻量模型
|
||||
"deepseek-v4-flash": {Input: 0.27 / 1000, Output: 1.10 / 1000, Unit: 0.001, Currency: "USD"},
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ type ProviderCredentials struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
APIVersion string `json:"api_version,omitempty"` // Azure OpenAI API 版本
|
||||
|
||||
// AWS Bedrock 直连用:access key + secret + region(不走 APIKey/Endpoint)
|
||||
AWSAccessKeyID string `json:"aws_access_key_id,omitempty"`
|
||||
AWSSecretAccessKey string `json:"aws_secret_access_key,omitempty"`
|
||||
AWSSessionToken string `json:"aws_session_token,omitempty"`
|
||||
AWSRegion string `json:"aws_region,omitempty"`
|
||||
// Bedrock 可选代理地址(host:port 或 http://host:port),非空时请求经该代理转发到 AWS
|
||||
BedrockProxyURL string `json:"bedrock_proxy_url,omitempty"`
|
||||
}
|
||||
|
||||
// ModelInfo 模型信息
|
||||
@@ -40,10 +48,10 @@ type OpenAIModelsListResponse struct {
|
||||
type TongyiModelsListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Output struct {
|
||||
Total int `json:"total"`
|
||||
PageNo int `json:"page_no"`
|
||||
PageSize int `json:"page_size"`
|
||||
Models []TongyiModelItem `json:"models"`
|
||||
Total int `json:"total"`
|
||||
PageNo int `json:"page_no"`
|
||||
PageSize int `json:"page_size"`
|
||||
Models []TongyiModelItem `json:"models"`
|
||||
} `json:"output"`
|
||||
}
|
||||
|
||||
@@ -55,8 +63,8 @@ type TongyiModelItem struct {
|
||||
|
||||
// GeminiModelsListResponse Google Gemini GET /v1beta/models 返回:models[] + nextPageToken
|
||||
type GeminiModelsListResponse struct {
|
||||
Models []GeminiModelItem `json:"models"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
Models []GeminiModelItem `json:"models"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
|
||||
// GeminiModelItem Gemini 模型单项,name 为 "models/gemini-xxx",baseModelId 用于请求
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package gaia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
|
||||
estream "github.com/aws/aws-sdk-go/private/protocol/eventstream"
|
||||
|
||||
"github.com/flipped-aurora/gin-vue-admin/server/global"
|
||||
"github.com/flipped-aurora/gin-vue-admin/server/model/gaia"
|
||||
gaiaResponse "github.com/flipped-aurora/gin-vue-admin/server/model/gaia/response"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// proxyBedrockRequest 直连 AWS Bedrock 原生 API 转发 Anthropic Messages 请求。
|
||||
//
|
||||
// 路径转换:v1/messages → model/{modelId}/invoke 或 model/{modelId}/invoke-with-response-stream
|
||||
// 鉴权:SigV4(service=bedrock,region 来自 Dify 凭证 aws_region 字段)
|
||||
// 请求体改写:去掉 model 字段(Bedrock 走 URL 路径),注入 anthropic_version=bedrock-2023-05-31
|
||||
// 响应:
|
||||
// - 非流式:Bedrock 返回 Anthropic Messages JSON(含 usage.input_tokens/output_tokens),原样转发
|
||||
// - 流式:vnd.amazon.eventstream 二进制帧,每帧 payload 是 {"bytes":"<base64>"},
|
||||
// 解出后是 Anthropic SSE 事件 JSON,在此重组为标准 SSE 写回客户端
|
||||
//
|
||||
// 计费:成功后按 (input_tokens, output_tokens) 调 calcQuotaDelta 扣额。
|
||||
func (s *ModelProviderService) proxyBedrockRequest(
|
||||
userID, _ /* path */, method string, _ /* reqHeader */ http.Header, body []byte, writer io.Writer,
|
||||
creds *gaiaResponse.ProviderCredentials,
|
||||
) error {
|
||||
// 1) 校验 AWS 凭证
|
||||
if creds == nil || creds.AWSAccessKeyID == "" || creds.AWSSecretAccessKey == "" {
|
||||
return fmt.Errorf("AWS Bedrock 凭证缺失(需要 aws_access_key_id / aws_secret_access_key)")
|
||||
}
|
||||
region := creds.AWSRegion
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
// 2) 解析 body:拿到 modelId 与 stream 标记,并改写为 Bedrock 期望的格式
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("Bedrock 请求 body 不能为空")
|
||||
}
|
||||
var bodyObj map[string]interface{}
|
||||
if err := json.Unmarshal(body, &bodyObj); err != nil {
|
||||
return fmt.Errorf("解析 Bedrock 请求 body 失败:%w", err)
|
||||
}
|
||||
modelID, _ := bodyObj["model"].(string)
|
||||
if modelID == "" {
|
||||
return fmt.Errorf("Bedrock 请求 body 缺少 model 字段")
|
||||
}
|
||||
streaming := false
|
||||
if v, ok := bodyObj["stream"].(bool); ok {
|
||||
streaming = v
|
||||
}
|
||||
// 移除 model(Bedrock 不需要);删除 stream(流式由 URL 决定)
|
||||
delete(bodyObj, "model")
|
||||
delete(bodyObj, "stream")
|
||||
delete(bodyObj, "stream_options")
|
||||
// OpenAI 兼容字段转换:max_completion_tokens → max_tokens(Bedrock/Anthropic 使用 max_tokens)
|
||||
if _, hasMaxTokens := bodyObj["max_tokens"]; !hasMaxTokens {
|
||||
if v, ok := bodyObj["max_completion_tokens"]; ok {
|
||||
bodyObj["max_tokens"] = v
|
||||
delete(bodyObj, "max_completion_tokens")
|
||||
}
|
||||
} else {
|
||||
delete(bodyObj, "max_completion_tokens")
|
||||
}
|
||||
// 注入 Bedrock 必需的 anthropic_version
|
||||
if _, ok := bodyObj["anthropic_version"]; !ok {
|
||||
bodyObj["anthropic_version"] = "bedrock-2023-05-31"
|
||||
}
|
||||
rewritten, err := json.Marshal(bodyObj)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重写 Bedrock 请求 body 失败:%w", err)
|
||||
}
|
||||
|
||||
// 3) 构建 Bedrock URL
|
||||
// 新一代 Claude 模型(3.5v2、3.7、Sonnet-4、Opus-4 等)要求通过跨区域推理配置文件调用,
|
||||
// 模型 ID 需加地理前缀(us. / eu. / ap.),否则 Bedrock 返回 "on-demand throughput isn't supported" 错误。
|
||||
// 若调用方已传入带前缀的 ID(如 us.anthropic.xxx)则直接使用,不重复添加。
|
||||
invokeModelID := bedrockResolveModelID(modelID, region)
|
||||
if invokeModelID != modelID {
|
||||
global.GVA_LOG.Info("Bedrock 模型 ID 已映射为跨区域推理配置文件",
|
||||
zap.String("original", modelID),
|
||||
zap.String("resolved", invokeModelID),
|
||||
zap.String("region", region),
|
||||
)
|
||||
}
|
||||
host := fmt.Sprintf("bedrock-runtime.%s.amazonaws.com", region)
|
||||
op := "invoke"
|
||||
if streaming {
|
||||
op = "invoke-with-response-stream"
|
||||
}
|
||||
requestURL := fmt.Sprintf("https://%s/model/%s/%s", host, url.PathEscape(invokeModelID), op)
|
||||
|
||||
// 打印请求地址、参数和代理地址
|
||||
global.GVA_LOG.Info("Bedrock 请求详情",
|
||||
zap.String("request_url", requestURL),
|
||||
zap.String("method", method),
|
||||
zap.ByteString("body", rewritten),
|
||||
zap.String("proxy_url", creds.BedrockProxyURL),
|
||||
)
|
||||
|
||||
httpReq, err := http.NewRequest(method, requestURL, bytes.NewReader(rewritten))
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建 Bedrock 请求失败:%w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if streaming {
|
||||
httpReq.Header.Set("Accept", "application/vnd.amazon.eventstream")
|
||||
httpReq.Header.Set("X-Amzn-Bedrock-Accept", "application/json")
|
||||
} else {
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
// 4) SigV4 签名(service=bedrock)
|
||||
awsCreds := credentials.NewStaticCredentials(creds.AWSAccessKeyID, creds.AWSSecretAccessKey, creds.AWSSessionToken)
|
||||
signer := v4.NewSigner(awsCreds)
|
||||
if _, err = signer.Sign(httpReq, bytes.NewReader(rewritten), "bedrock", region, time.Now()); err != nil {
|
||||
return fmt.Errorf("Bedrock SigV4 签名失败:%w", err)
|
||||
}
|
||||
|
||||
// 5) 发起请求(若配置了 bedrock_proxy_url 则经 HTTP 代理转发)
|
||||
startTime := time.Now()
|
||||
transport := http.DefaultTransport
|
||||
if creds.BedrockProxyURL != "" {
|
||||
proxyAddr := creds.BedrockProxyURL
|
||||
if !strings.HasPrefix(proxyAddr, "http://") && !strings.HasPrefix(proxyAddr, "https://") {
|
||||
proxyAddr = "http://" + proxyAddr
|
||||
}
|
||||
if proxyURL, parseErr := url.Parse(proxyAddr); parseErr == nil {
|
||||
transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
}
|
||||
}
|
||||
client := &http.Client{Timeout: 5 * time.Minute, Transport: transport}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
s.logBedrock(userID, modelID, "error", err.Error(), startTime, 0, 0)
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 6) 写回响应头/状态码(流式改写 Content-Type 为 SSE)
|
||||
if w, ok := writer.(http.ResponseWriter); ok {
|
||||
for k, v := range resp.Header {
|
||||
lower := strings.ToLower(k)
|
||||
if streaming && (lower == "content-type" || lower == "content-length") {
|
||||
continue
|
||||
}
|
||||
for _, vv := range v {
|
||||
w.Header().Add(k, vv)
|
||||
}
|
||||
}
|
||||
if streaming {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
_, _ = writer.Write(raw)
|
||||
s.logBedrock(userID, modelID, "error",
|
||||
fmt.Sprintf("bedrock %d: %s", resp.StatusCode, string(raw)), startTime, 0, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 7) 处理响应体
|
||||
var inputTokens, outputTokens int
|
||||
if streaming {
|
||||
inputTokens, outputTokens, err = s.streamBedrockEventStream(resp.Body, writer)
|
||||
if err != nil {
|
||||
s.logBedrock(userID, modelID, "error", err.Error(), startTime, inputTokens, outputTokens)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
var buf bytes.Buffer
|
||||
tee := io.TeeReader(resp.Body, &buf)
|
||||
if _, err = io.Copy(writer, tee); err != nil {
|
||||
s.logBedrock(userID, modelID, "error", err.Error(), startTime, 0, 0)
|
||||
return err
|
||||
}
|
||||
inputTokens, outputTokens = parseAnthropicUsage(buf.Bytes())
|
||||
}
|
||||
|
||||
// 8) 记录日志 + 计费扣款
|
||||
s.logBedrock(userID, modelID, "success", "", startTime, inputTokens, outputTokens)
|
||||
if inputTokens > 0 || outputTokens > 0 {
|
||||
pricing, _ := s.fetchModelPricingFromDify(modelID)
|
||||
delta := calcQuotaDelta(pricing, modelID, inputTokens, outputTokens)
|
||||
deductAccountQuota(userID, delta)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamBedrockEventStream 解析 Bedrock 的 vnd.amazon.eventstream 二进制流,
|
||||
// 把每个事件还原为 Anthropic SSE(event: <type>\ndata: <json>\n\n)写给客户端。
|
||||
// 返回累计的 input/output token 数(用于计费)。
|
||||
func (s *ModelProviderService) streamBedrockEventStream(r io.Reader, w io.Writer) (int, int, error) {
|
||||
flusher, _ := w.(http.Flusher)
|
||||
dec := estream.NewDecoder(r)
|
||||
payloadBuf := make([]byte, 0, 32*1024)
|
||||
|
||||
var inputTokens, outputTokens int
|
||||
for {
|
||||
msg, err := dec.Decode(payloadBuf)
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return inputTokens, outputTokens, nil
|
||||
}
|
||||
return inputTokens, outputTokens, fmt.Errorf("eventstream decode 失败:%w", err)
|
||||
}
|
||||
|
||||
// Bedrock 的事件 payload 形如 {"bytes":"<base64-encoded inner JSON>"}
|
||||
var wrap struct {
|
||||
Bytes string `json:"bytes"`
|
||||
}
|
||||
var inner []byte
|
||||
if e := json.Unmarshal(msg.Payload, &wrap); e == nil && wrap.Bytes != "" {
|
||||
if decoded, e2 := base64.StdEncoding.DecodeString(wrap.Bytes); e2 == nil {
|
||||
inner = decoded
|
||||
}
|
||||
}
|
||||
if len(inner) == 0 {
|
||||
// 非包装格式(如错误/ping),直接用原 payload
|
||||
inner = msg.Payload
|
||||
}
|
||||
|
||||
// 解析事件类型和 usage(Anthropic 在 message_start.message.usage 给 input_tokens,
|
||||
// message_delta.usage 给 output_tokens)
|
||||
var ev struct {
|
||||
Type string `json:"type"`
|
||||
Message struct {
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
} `json:"message"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
_ = json.Unmarshal(inner, &ev)
|
||||
if ev.Message.Usage.InputTokens > 0 {
|
||||
inputTokens = ev.Message.Usage.InputTokens
|
||||
}
|
||||
if ev.Message.Usage.OutputTokens > 0 {
|
||||
outputTokens = ev.Message.Usage.OutputTokens
|
||||
}
|
||||
if ev.Usage.InputTokens > 0 {
|
||||
inputTokens = ev.Usage.InputTokens
|
||||
}
|
||||
if ev.Usage.OutputTokens > 0 {
|
||||
outputTokens = ev.Usage.OutputTokens
|
||||
}
|
||||
|
||||
// 重组为 Anthropic SSE 写回
|
||||
eventName := ev.Type
|
||||
if eventName == "" {
|
||||
eventName = "message"
|
||||
}
|
||||
sse := "event: " + eventName + "\ndata: " + string(inner) + "\n\n"
|
||||
if _, err = w.Write([]byte(sse)); err != nil {
|
||||
return inputTokens, outputTokens, err
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseAnthropicUsage 从非流式 Anthropic Messages 响应 JSON 中提取 usage 字段。
|
||||
func parseAnthropicUsage(data []byte) (input, output int) {
|
||||
var obj struct {
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if json.Unmarshal(data, &obj) == nil {
|
||||
return obj.Usage.InputTokens, obj.Usage.OutputTokens
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// bedrockCrossRegionPrefixes 是需要跨区域推理配置文件的模型 ID 前缀列表(anthropic. 开头)。
|
||||
// 来源:https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
|
||||
// 规则:凡模型不在旧版 on-demand 列表中,均需加地理前缀才能调用。
|
||||
var bedrockCrossRegionPrefixes = []string{
|
||||
// Claude 3.5 v2
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2",
|
||||
"anthropic.claude-3-5-haiku-20241022",
|
||||
// Claude 3.7
|
||||
"anthropic.claude-3-7-sonnet",
|
||||
// Claude Sonnet 4 / Opus 4 / Haiku 4(及后续新版本)
|
||||
"anthropic.claude-sonnet-4",
|
||||
"anthropic.claude-opus-4",
|
||||
"anthropic.claude-haiku-4",
|
||||
}
|
||||
|
||||
// bedrockResolveModelID 将用户输入的模型 ID 解析为正确的 Bedrock 调用 ID。
|
||||
//
|
||||
// 支持以下输入格式(会自动规范化):
|
||||
// - 完整带前缀:us.anthropic.claude-sonnet-4-6 → 直接使用
|
||||
// - 带厂商前缀:anthropic.claude-sonnet-4-6 → 按需加 us./eu./ap.
|
||||
// - 短横线名称:claude-sonnet-4-6 → 补 anthropic. 再按需加前缀
|
||||
// - 空格+点号: "claude sonnet 4.6" → 规范化后同上
|
||||
func bedrockResolveModelID(modelID, region string) string {
|
||||
// Step 1: 规范化输入
|
||||
// 小写、空格→横线、版本中的点→横线(如 4.6 → 4-6,但保留 : 用于版本后缀如 v2:0)
|
||||
normalized := strings.ToLower(strings.TrimSpace(modelID))
|
||||
normalized = strings.ReplaceAll(normalized, " ", "-")
|
||||
// 仅将数字之间的 `.` 替换为 `-`(处理 "4.6" → "4-6"),保留 anthropic. 这样的厂商点
|
||||
normalized = replaceVersionDots(normalized)
|
||||
|
||||
// Step 2: 补全 anthropic. 前缀(用户只填了 claude-xxx)
|
||||
if !strings.HasPrefix(normalized, "anthropic.") &&
|
||||
!strings.HasPrefix(normalized, "us.") &&
|
||||
!strings.HasPrefix(normalized, "eu.") &&
|
||||
!strings.HasPrefix(normalized, "ap.") {
|
||||
normalized = "anthropic." + normalized
|
||||
}
|
||||
|
||||
// Step 3: 已经带地理前缀 → 直接使用
|
||||
if strings.HasPrefix(normalized, "us.") || strings.HasPrefix(normalized, "eu.") || strings.HasPrefix(normalized, "ap.") {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Step 4: 判断是否属于需要跨区域推理配置文件的模型
|
||||
needsCrossRegion := false
|
||||
for _, prefix := range bedrockCrossRegionPrefixes {
|
||||
if strings.HasPrefix(normalized, prefix) {
|
||||
needsCrossRegion = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needsCrossRegion {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Step 5: 根据 region 推导地理前缀
|
||||
geoPrefix := "us" // 默认
|
||||
switch {
|
||||
case strings.HasPrefix(region, "us-"):
|
||||
geoPrefix = "us"
|
||||
case strings.HasPrefix(region, "eu-"):
|
||||
geoPrefix = "eu"
|
||||
case strings.HasPrefix(region, "ap-"):
|
||||
geoPrefix = "ap"
|
||||
}
|
||||
return geoPrefix + "." + normalized
|
||||
}
|
||||
|
||||
// replaceVersionDots 将版本号中数字之间的 `.` 替换为 `-`(如 4.6 → 4-6),
|
||||
// 保留厂商命名空间中的点(如 anthropic. 开头不受影响,因为点后紧跟字母)。
|
||||
func replaceVersionDots(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '.' && i > 0 && i < len(s)-1 {
|
||||
prev := s[i-1]
|
||||
next := s[i+1]
|
||||
// 仅当点号两侧都是数字时才替换为 -
|
||||
if prev >= '0' && prev <= '9' && next >= '0' && next <= '9' {
|
||||
b.WriteByte('-')
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// logBedrock 记录代理日志(与 ProxyRequest 中的 ModelProxyLog 行为一致)。
|
||||
func (s *ModelProviderService) logBedrock(userID, modelID, status, errMsg string, startTime time.Time, in, out int) {
|
||||
if err := global.GVA_DB.Create(&gaia.ModelProxyLog{
|
||||
UserId: userID,
|
||||
ProviderName: gaia.ProviderAWS,
|
||||
ModelName: modelID,
|
||||
RequestTokens: in,
|
||||
ResponseTokens: out,
|
||||
Status: status,
|
||||
ErrorMessage: errMsg,
|
||||
CreatedAt: startTime,
|
||||
}).Error; err != nil {
|
||||
global.GVA_LOG.Warn("logBedrock 写日志失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
@@ -418,6 +418,14 @@ func (s *ModelProviderService) GetAvailableModelsFromDify(providerName string) (
|
||||
if providerName == gaia.ProviderAzure {
|
||||
return s.getAvailableModelsFromProviderModelCredentials(providerName)
|
||||
}
|
||||
// AWS Bedrock 没有统一的 /v1/models 接口,模型由前端手输;直接返回空列表
|
||||
if providerName == gaia.ProviderAWS || providerName == gaia.ProviderAnthropic {
|
||||
return nil, nil
|
||||
}
|
||||
// DeepSeek 模型由前端手输(避免拉取全量列表),直接返回空列表
|
||||
if providerName == gaia.ProviderDeepSeek {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
creds, err := s.GetDifyProviderCredentials(providerName)
|
||||
if err != nil || creds.APIKey == "" {
|
||||
@@ -441,8 +449,6 @@ func (s *ModelProviderService) GetAvailableModelsFromDify(providerName string) (
|
||||
base = gaia.DefaultAPIBase[gaia.ProviderGoogle]
|
||||
}
|
||||
return s.fetchGeminiModels(client, base, creds.APIKey)
|
||||
case gaia.ProviderAnthropic:
|
||||
return nil, nil
|
||||
default:
|
||||
if creds.Endpoint != "" {
|
||||
return s.fetchOpenAICompatibleModels(client, creds.Endpoint, creds.APIKey)
|
||||
@@ -637,26 +643,33 @@ func (s *ModelProviderService) GetDifyProviderCredentials(providerName string) (
|
||||
cacheKey := gaia.RedisKeyModelProviderCredentialsPrefix + providerName
|
||||
if cached, err = global.GVA_Dify_REDIS.Get(context.Background(), cacheKey).Result(); err == nil {
|
||||
if err = json.Unmarshal([]byte(cached), &creds); err == nil {
|
||||
global.GVA_LOG.Info("GetDifyProviderCredentials 命中缓存",
|
||||
zap.String("provider", providerName),
|
||||
zap.String("aws_region", creds.AWSRegion),
|
||||
zap.String("bedrock_proxy_url", creds.BedrockProxyURL),
|
||||
)
|
||||
return creds, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试方式1: 从 providers + provider_credentials 表查询
|
||||
var row gaia.ProviderCredential
|
||||
err = global.GVA_DB.Table("providers").
|
||||
// 将短名转为 Dify 内部 provider_name 的 LIKE 模式(避免 aws 匹配不到 bedrock_claude)
|
||||
likePattern := s.difyProviderLikePattern(providerName)
|
||||
err = global.GVA_DB.Debug().Table("providers").
|
||||
Select("provider_credentials.encrypted_config, providers.tenant_id").
|
||||
Joins("LEFT JOIN provider_credentials ON providers.credential_id = provider_credentials.id").
|
||||
Where("providers.tenant_id = ? AND providers.provider_name LIKE ? AND providers.provider_type = ? AND providers.is_valid = ?",
|
||||
tenantID, fmt.Sprintf("%%%s%%", providerName), gaia.DifyProviderTypeCustom, true).
|
||||
tenantID, likePattern, gaia.DifyProviderTypeCustom, true).
|
||||
Order("provider_credentials.updated_at DESC").
|
||||
First(&row).Error
|
||||
|
||||
// 如果方式1 未找到记录,尝试方式2: 从 provider_model_credentials 表查询
|
||||
if err != nil || row.EncryptedConfig == "" {
|
||||
var pmcRow gaia.ProviderCredential
|
||||
if pmcErr := global.GVA_DB.Table("provider_model_credentials").
|
||||
if pmcErr := global.GVA_DB.Debug().Table("provider_model_credentials").
|
||||
Select("encrypted_config, tenant_id, provider_name, updated_at").
|
||||
Where("tenant_id = ? AND provider_name LIKE ?", tenantID, fmt.Sprintf("%%%s%%", providerName)).
|
||||
Where("tenant_id = ? AND provider_name LIKE ?", tenantID, likePattern).
|
||||
Order("updated_at DESC"). // 按 updated_at 倒序,取最新的凭证
|
||||
First(&pmcRow).Error; pmcErr == nil && pmcRow.EncryptedConfig != "" {
|
||||
row = pmcRow
|
||||
@@ -671,6 +684,7 @@ func (s *ModelProviderService) GetDifyProviderCredentials(providerName string) (
|
||||
// 兼容两种存储:1) 明文 JSON(如 {"openai_api_key":"...", "openai_api_base":"..."});2) Dify RSA+AES-EAX 加密后再 base64
|
||||
var base, apiVersion string
|
||||
var configMap map[string]interface{}
|
||||
fmt.Println("row.EncryptedConfig", row.EncryptedConfig)
|
||||
if err = json.Unmarshal([]byte(row.EncryptedConfig), &configMap); err == nil {
|
||||
// 解密函数用于处理加密的值
|
||||
if config, ok := configMap[gaia.ConfigKeyOpenaiAPIKey]; ok {
|
||||
@@ -686,6 +700,35 @@ func (s *ModelProviderService) GetDifyProviderCredentials(providerName string) (
|
||||
creds.APIKey, err = s.decryptConfig(config.(string), row.TenantID)
|
||||
} else if config, ok = configMap[gaia.ConfigKeyAPIKey]; ok {
|
||||
creds.APIKey, err = s.decryptConfig(config.(string), row.TenantID)
|
||||
} else if _, hasAWSKey := configMap[gaia.ConfigKeyAWSAccessKeyID]; hasAWSKey {
|
||||
// AWS Bedrock 凭证:解析 aws_access_key_id / aws_secret_access_key / aws_region
|
||||
if v, ok2 := configMap[gaia.ConfigKeyAWSAccessKeyID].(string); ok2 && v != "" {
|
||||
if creds.AWSAccessKeyID, err = s.decryptConfig(v, row.TenantID); err != nil {
|
||||
return nil, fmt.Errorf("解密 aws_access_key_id 失败: %w", err)
|
||||
}
|
||||
}
|
||||
if v, ok2 := configMap[gaia.ConfigKeyAWSSecretAccessKey].(string); ok2 && v != "" {
|
||||
if creds.AWSSecretAccessKey, err = s.decryptConfig(v, row.TenantID); err != nil {
|
||||
return nil, fmt.Errorf("解密 aws_secret_access_key 失败: %w", err)
|
||||
}
|
||||
}
|
||||
if v, ok2 := configMap[gaia.ConfigKeyAWSSessionToken].(string); ok2 && v != "" {
|
||||
if creds.AWSSessionToken, err = s.decryptConfig(v, row.TenantID); err != nil {
|
||||
return nil, fmt.Errorf("解密 aws_session_token 失败: %w", err)
|
||||
}
|
||||
}
|
||||
if v, ok2 := configMap[gaia.ConfigKeyAWSRegion].(string); ok2 && v != "" {
|
||||
creds.AWSRegion = strings.TrimSpace(v)
|
||||
}
|
||||
// 可选:HTTP 代理地址(用于从受限地区中转 Bedrock 请求)。
|
||||
// 支持 "host:port" 或 "http(s)://host:port";若值被加密则先解密,失败时按原文处理。
|
||||
if v, ok2 := configMap[gaia.ConfigKeyBedrockProxyURL].(string); ok2 && strings.TrimSpace(v) != "" {
|
||||
proxyVal := strings.TrimSpace(v)
|
||||
if decrypted, decErr := s.decryptConfig(proxyVal, row.TenantID); decErr == nil && decrypted != "" {
|
||||
proxyVal = strings.TrimSpace(decrypted)
|
||||
}
|
||||
creds.BedrockProxyURL = proxyVal
|
||||
}
|
||||
} else {
|
||||
// 尝试从备选字段中查找
|
||||
for _, key := range gaia.CredentialKeyFallback {
|
||||
@@ -708,13 +751,14 @@ func (s *ModelProviderService) GetDifyProviderCredentials(providerName string) (
|
||||
return nil, fmt.Errorf("解密凭证失败: %w", err)
|
||||
}
|
||||
}
|
||||
if creds.APIKey == "" {
|
||||
return nil, fmt.Errorf("未能从配置中提取API Key")
|
||||
if creds.APIKey == "" && creds.AWSAccessKeyID == "" {
|
||||
return nil, fmt.Errorf("未能从配置中提取API Key(也未找到 AWS 凭证)")
|
||||
}
|
||||
|
||||
// 缓存凭证(1小时)
|
||||
var cacheJSON []byte
|
||||
if cacheJSON, err = json.Marshal(creds); err == nil {
|
||||
fmt.Println("row.EncryptedConfig", string(cacheJSON))
|
||||
global.GVA_Dify_REDIS.Set(context.Background(), cacheKey, cacheJSON, time.Hour)
|
||||
}
|
||||
|
||||
@@ -986,8 +1030,18 @@ func (s *ModelProviderService) getProviderCandidatesByModel(modelName string) []
|
||||
if strings.HasPrefix(modelLower, "gemini") || strings.Contains(modelLower, "google") {
|
||||
return []string{gaia.ProviderGoogle}
|
||||
}
|
||||
if strings.HasPrefix(modelLower, "anthropic.") {
|
||||
// anthropic.* 前缀是 AWS Bedrock 专用模型 ID 格式(如 anthropic.claude-sonnet-4-6),
|
||||
// 仅走 Bedrock 渠道,不回落到 Anthropic 直连(直连不支持此格式且可能因地区受限)。
|
||||
return []string{gaia.ProviderAWS}
|
||||
}
|
||||
if strings.Contains(modelLower, "claude") || strings.Contains(modelLower, "anthropic") {
|
||||
return []string{gaia.ProviderAnthropic}
|
||||
// 顺序即优先级:AWS Bedrock 优先(配置成本更高、可覆盖受限地区),未开启再回落到 anthropic 直连
|
||||
return []string{gaia.ProviderAWS, gaia.ProviderAnthropic}
|
||||
}
|
||||
// Kimi / Moonshot 系列经由 tongyi(百炼)渠道转发
|
||||
if strings.HasPrefix(modelLower, "kimi") || strings.Contains(modelLower, "moonshot") {
|
||||
return []string{gaia.ProviderTongyi}
|
||||
}
|
||||
// GLM/智谱 可能配置在 tongyi(统一入口)或 zhipuai 下,先试 tongyi
|
||||
if strings.HasPrefix(modelLower, "glm") || strings.Contains(modelLower, "zhipu") || strings.Contains(modelLower, "chatglm") {
|
||||
@@ -997,6 +1051,10 @@ func (s *ModelProviderService) getProviderCandidatesByModel(modelName string) []
|
||||
if strings.HasPrefix(modelLower, "minimax") || strings.Contains(modelLower, "abab") {
|
||||
return []string{gaia.ProviderTongyi, gaia.ProviderMinimax}
|
||||
}
|
||||
// DeepSeek 系列模型走 deepseek 渠道
|
||||
if strings.HasPrefix(modelLower, "deepseek") {
|
||||
return []string{gaia.ProviderDeepSeek}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1028,9 +1086,18 @@ func (s *ModelProviderService) getProviderByModel(modelName string) (string, err
|
||||
if strings.HasPrefix(modelLower, "gemini") || strings.Contains(modelLower, "google") {
|
||||
return gaia.ProviderGoogle, nil
|
||||
}
|
||||
if strings.HasPrefix(modelLower, "anthropic.") {
|
||||
// anthropic.* 前缀是 AWS Bedrock 专用格式,直接归 aws 渠道
|
||||
return gaia.ProviderAWS, nil
|
||||
}
|
||||
if strings.Contains(modelLower, "claude") || strings.Contains(modelLower, "anthropic") {
|
||||
// 仅按名字推断时默认 anthropic;实际渠道(含 AWS Bedrock)由 resolveProviderByModel 决定
|
||||
return gaia.ProviderAnthropic, nil
|
||||
}
|
||||
// Kimi / Moonshot 默认走 tongyi(百炼)渠道
|
||||
if strings.HasPrefix(modelLower, "kimi") || strings.Contains(modelLower, "moonshot") {
|
||||
return gaia.ProviderTongyi, nil
|
||||
}
|
||||
if strings.Contains(modelLower, "azure") {
|
||||
return gaia.ProviderAzure, nil
|
||||
}
|
||||
@@ -1042,6 +1109,10 @@ func (s *ModelProviderService) getProviderByModel(modelName string) (string, err
|
||||
if strings.HasPrefix(modelLower, "minimax") || strings.Contains(modelLower, "abab") {
|
||||
return gaia.ProviderTongyi, nil
|
||||
}
|
||||
// DeepSeek 系列走 deepseek 渠道
|
||||
if strings.HasPrefix(modelLower, "deepseek") {
|
||||
return gaia.ProviderDeepSeek, nil
|
||||
}
|
||||
return "", fmt.Errorf("无法识别模型 %s 的提供商", modelName)
|
||||
}
|
||||
|
||||
@@ -1154,6 +1225,11 @@ func (s *ModelProviderService) ProxyRequest(
|
||||
return err
|
||||
}
|
||||
|
||||
// AWS Bedrock 直连:不走通用 HTTP 转发,改用 SigV4 签名的 Bedrock 原生 API
|
||||
if providerName == gaia.ProviderAWS {
|
||||
return s.proxyBedrockRequest(userID, path, method, reqHeader, body, writer, creds)
|
||||
}
|
||||
|
||||
if base = s.getUpstreamBase(providerName, creds); base == "" {
|
||||
return fmt.Errorf("提供商 %s 无可用上游地址", providerName)
|
||||
}
|
||||
@@ -1313,7 +1389,7 @@ func (s *ModelProviderService) ProxyRequest(
|
||||
}
|
||||
}
|
||||
|
||||
// 流式响应:按行扫描,顺带从最后一条含 usage 的 data 行中提取 token 数
|
||||
// 流式响应:按行扫描,顺带从最后一条含 usage 的 data 行返回
|
||||
if strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") {
|
||||
if flusher, ok := writer.(http.Flusher); ok {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
@@ -1399,3 +1475,21 @@ func isImageOrPerRequestPath(path string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// difyProviderLikePattern 将 Gaia 短名(openai/aws/...)转换为 Dify providers 表
|
||||
// provider_name 字段的 LIKE 搜索模式。
|
||||
// Dify 内部以 "langgenius/<plugin>/<plugin>" 格式存储提供商名,例如:
|
||||
// - aws → langgenius/bedrock_claude/bedrock_claude 或 langgenius/bedrock/bedrock
|
||||
// - openai → langgenius/openai/openai
|
||||
// - anthropic → langgenius/anthropic/anthropic
|
||||
//
|
||||
// 对于 AWS,Dify 有两种插件包:bedrock_claude 和 bedrock;使用 bedrock 作为公共关键字可同时命中。
|
||||
func (s *ModelProviderService) difyProviderLikePattern(providerName string) string {
|
||||
switch providerName {
|
||||
case gaia.ProviderAWS:
|
||||
// langgenius/bedrock_claude/bedrock_claude 和 langgenius/bedrock/bedrock 都含 "bedrock"
|
||||
return "%bedrock%"
|
||||
default:
|
||||
return fmt.Sprintf("%%%s%%", providerName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,13 @@ const providerList = ref([])
|
||||
const providerDisplayNames = {
|
||||
openai: 'OpenAI',
|
||||
tongyi: '千问(通义)',
|
||||
google: 'Google Gemini'
|
||||
google: 'Google Gemini',
|
||||
anthropic: 'Anthropic',
|
||||
aws: 'AWS Bedrock',
|
||||
azure: 'Azure OpenAI',
|
||||
zhipuai: '智谱 AI',
|
||||
minimax: 'MiniMax',
|
||||
deepseek: 'DeepSeek'
|
||||
}
|
||||
|
||||
const getProviderDisplayName = (providerName) => {
|
||||
@@ -395,7 +401,7 @@ onMounted(() => {
|
||||
:deep(.el-select-dropdown) {
|
||||
.el-select-dropdown__item {
|
||||
padding: 8px 16px;
|
||||
|
||||
|
||||
&.is-selected {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
|
||||
@@ -1283,8 +1283,11 @@ NGINX_SSL_PROTOCOLS=TLSv1.2 TLSv1.3
|
||||
NGINX_WORKER_PROCESSES=auto
|
||||
NGINX_CLIENT_MAX_BODY_SIZE=100M
|
||||
NGINX_KEEPALIVE_TIMEOUT=65
|
||||
NGINX_CLIENT_HEADER_TIMEOUT=1800s
|
||||
NGINX_CLIENT_BODY_TIMEOUT=1800s
|
||||
|
||||
# Proxy settings
|
||||
NGINX_PROXY_CONNECT_TIMEOUT=3600s
|
||||
NGINX_PROXY_READ_TIMEOUT=3600s
|
||||
NGINX_PROXY_SEND_TIMEOUT=3600s
|
||||
|
||||
|
||||
@@ -419,6 +419,9 @@ services:
|
||||
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
|
||||
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
|
||||
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
|
||||
NGINX_CLIENT_HEADER_TIMEOUT: ${NGINX_CLIENT_HEADER_TIMEOUT:-1800s}
|
||||
NGINX_CLIENT_BODY_TIMEOUT: ${NGINX_CLIENT_BODY_TIMEOUT:-1800s}
|
||||
NGINX_PROXY_CONNECT_TIMEOUT: ${NGINX_PROXY_CONNECT_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s}
|
||||
NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false}
|
||||
|
||||
@@ -557,6 +557,9 @@ x-shared-env: &shared-api-worker-env
|
||||
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
|
||||
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
|
||||
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
|
||||
NGINX_CLIENT_HEADER_TIMEOUT: ${NGINX_CLIENT_HEADER_TIMEOUT:-1800s}
|
||||
NGINX_CLIENT_BODY_TIMEOUT: ${NGINX_CLIENT_BODY_TIMEOUT:-1800s}
|
||||
NGINX_PROXY_CONNECT_TIMEOUT: ${NGINX_PROXY_CONNECT_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s}
|
||||
NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false}
|
||||
@@ -1170,6 +1173,9 @@ services:
|
||||
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
|
||||
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
|
||||
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
|
||||
NGINX_CLIENT_HEADER_TIMEOUT: ${NGINX_CLIENT_HEADER_TIMEOUT:-1800s}
|
||||
NGINX_CLIENT_BODY_TIMEOUT: ${NGINX_CLIENT_BODY_TIMEOUT:-1800s}
|
||||
NGINX_PROXY_CONNECT_TIMEOUT: ${NGINX_PROXY_CONNECT_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s}
|
||||
NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false}
|
||||
|
||||
@@ -554,6 +554,9 @@ x-shared-env: &shared-api-worker-env
|
||||
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
|
||||
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
|
||||
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
|
||||
NGINX_CLIENT_HEADER_TIMEOUT: ${NGINX_CLIENT_HEADER_TIMEOUT:-1800s}
|
||||
NGINX_CLIENT_BODY_TIMEOUT: ${NGINX_CLIENT_BODY_TIMEOUT:-1800s}
|
||||
NGINX_PROXY_CONNECT_TIMEOUT: ${NGINX_PROXY_CONNECT_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s}
|
||||
NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false}
|
||||
@@ -1105,6 +1108,9 @@ services:
|
||||
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
|
||||
NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
|
||||
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
|
||||
NGINX_CLIENT_HEADER_TIMEOUT: ${NGINX_CLIENT_HEADER_TIMEOUT:-1800s}
|
||||
NGINX_CLIENT_BODY_TIMEOUT: ${NGINX_CLIENT_BODY_TIMEOUT:-1800s}
|
||||
NGINX_PROXY_CONNECT_TIMEOUT: ${NGINX_PROXY_CONNECT_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s}
|
||||
NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s}
|
||||
NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
server {
|
||||
listen ${NGINX_PORT};
|
||||
server_name ${NGINX_SERVER_NAME};
|
||||
keepalive_timeout 1800s;
|
||||
client_header_timeout ${NGINX_CLIENT_HEADER_TIMEOUT};
|
||||
client_body_timeout ${NGINX_CLIENT_BODY_TIMEOUT};
|
||||
# 管理中心反向代理配置
|
||||
location = /admin {
|
||||
return 301 /admin/;
|
||||
|
||||
@@ -7,5 +7,6 @@ proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_connect_timeout ${NGINX_PROXY_CONNECT_TIMEOUT};
|
||||
proxy_read_timeout ${NGINX_PROXY_READ_TIMEOUT};
|
||||
proxy_send_timeout ${NGINX_PROXY_SEND_TIMEOUT};
|
||||
|
||||
Reference in New Issue
Block a user