diff --git a/admin/server/model/gaia/model_provider_constants_extend.go b/admin/server/model/gaia/model_provider_constants_extend.go index e4ffb6276..d104744e6 100644 --- a/admin/server/model/gaia/model_provider_constants_extend.go +++ b/admin/server/model/gaia/model_provider_constants_extend.go @@ -9,6 +9,7 @@ const ( ProviderAzure = "azure" ProviderZhipuai = "zhipuai" ProviderMinimax = "minimax" + ProviderAWS = "aws" // AWS Bedrock 渠道(用于转发 Claude 等 Anthropic 模型) ) // DifyProviderTypeCustom Dify providers 表 provider_type 枚举 @@ -16,15 +17,15 @@ 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" ) // SupportedProviders 列表展示的提供商顺序 -var SupportedProviders = []string{ProviderOpenai, ProviderTongyi, ProviderGoogle, ProviderAnthropic, ProviderAzure, ProviderZhipuai, ProviderMinimax} +var SupportedProviders = []string{ProviderOpenai, ProviderTongyi, ProviderGoogle, ProviderAnthropic, ProviderAWS, ProviderAzure, ProviderZhipuai, ProviderMinimax} // DefaultChatCompletionsEndpoints 各提供商聊天接口默认完整 URL(兼容旧 ProxyChat) var DefaultChatCompletionsEndpoints = map[string]string{ @@ -42,6 +43,7 @@ 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", // Azure 的 base URL 来自 openai_api_base 配置,不设置默认值 @@ -73,27 +75,51 @@ 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"}, } diff --git a/admin/server/model/gaia/response/model_provider.go b/admin/server/model/gaia/response/model_provider.go index 959179db9..60b2a9660 100644 --- a/admin/server/model/gaia/response/model_provider.go +++ b/admin/server/model/gaia/response/model_provider.go @@ -5,6 +5,12 @@ 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"` } // ModelInfo 模型信息 @@ -40,10 +46,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 +61,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 用于请求 diff --git a/admin/server/service/gaia/bedrock_extend.go b/admin/server/service/gaia/bedrock_extend.go new file mode 100644 index 000000000..0d7fccea8 --- /dev/null +++ b/admin/server/service/gaia/bedrock_extend.go @@ -0,0 +1,273 @@ +package gaia + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "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":""}, +// 解出后是 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") + // 注入 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 + 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, modelID, op) + + 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) 发起请求 + startTime := time.Now() + client := &http.Client{Timeout: 5 * time.Minute} + 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: \ndata: \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":""} + 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 +} + +// 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)) + } +} diff --git a/admin/server/service/gaia/model_provider.go b/admin/server/service/gaia/model_provider.go index 1bf390006..0993e89de 100644 --- a/admin/server/service/gaia/model_provider.go +++ b/admin/server/service/gaia/model_provider.go @@ -443,6 +443,9 @@ func (s *ModelProviderService) GetAvailableModelsFromDify(providerName string) ( return s.fetchGeminiModels(client, base, creds.APIKey) case gaia.ProviderAnthropic: return nil, nil + case gaia.ProviderAWS: + // AWS Bedrock 没有统一的 OpenAI 兼容 /v1/models 接口,模型由前端 allow-create 手输 + return nil, nil default: if creds.Endpoint != "" { return s.fetchOpenAICompatibleModels(client, creds.Endpoint, creds.APIKey) @@ -708,8 +711,8 @@ 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小时) @@ -987,7 +990,12 @@ func (s *ModelProviderService) getProviderCandidatesByModel(modelName string) [] return []string{gaia.ProviderGoogle} } if strings.Contains(modelLower, "claude") || strings.Contains(modelLower, "anthropic") { - return []string{gaia.ProviderAnthropic} + // 顺序即优先级:anthropic 直连优先,未开启则回落到 AWS Bedrock;都开则走 anthropic + return []string{gaia.ProviderAnthropic, gaia.ProviderAWS} + } + // 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") { @@ -1029,8 +1037,13 @@ func (s *ModelProviderService) getProviderByModel(modelName string) (string, err return gaia.ProviderGoogle, 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 } diff --git a/admin/web/src/view/systemIntegrated/modelManagement/index.vue b/admin/web/src/view/systemIntegrated/modelManagement/index.vue index 2b18e7281..2fd43a717 100644 --- a/admin/web/src/view/systemIntegrated/modelManagement/index.vue +++ b/admin/web/src/view/systemIntegrated/modelManagement/index.vue @@ -146,7 +146,12 @@ 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' } const getProviderDisplayName = (providerName) => { @@ -395,7 +400,7 @@ onMounted(() => { :deep(.el-select-dropdown) { .el-select-dropdown__item { padding: 8px 16px; - + &.is-selected { font-weight: 600; color: #409eff;