692 lines
24 KiB
Go
692 lines
24 KiB
Go
package app
|
||
|
||
// Provider 出站 HTTP、multipart 与媒体字节读取。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"mime"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/textproto"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"yingce/backend/internal/model"
|
||
"yingce/backend/internal/platform"
|
||
)
|
||
|
||
func postGeminiJSON(ctx context.Context, config providerConfig, path string, body interface{}, target interface{}) error {
|
||
data, err := json.Marshal(body)
|
||
if err != nil {
|
||
return fmt.Errorf("序列化上游请求失败:%w", err)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, geminiVeoURL(config.BaseURL, path), bytes.NewReader(data))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("x-goog-api-key", config.APIKey)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
return doJSON(req, target)
|
||
}
|
||
|
||
func deleteGeminiCachedContent(ctx context.Context, config providerConfig, resourceName string) error {
|
||
if err := validateGeminiCachedContentName(resourceName); err != nil {
|
||
return err
|
||
}
|
||
suffix := strings.TrimPrefix(strings.TrimSpace(resourceName), "cachedContents/")
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, geminiVeoURL(config.BaseURL, "/cachedContents/"+suffix), nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("x-goog-api-key", config.APIKey)
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
_, _, err = doBinary(req)
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
var httpErr providerHTTPError
|
||
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
|
||
func getGeminiJSON(ctx context.Context, config providerConfig, path string, target interface{}) error {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, geminiVeoURL(config.BaseURL, path), nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("x-goog-api-key", config.APIKey)
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
return doJSON(req, target)
|
||
}
|
||
|
||
func getGeminiBinary(ctx context.Context, config providerConfig, rawURL string) ([]byte, string, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
req.Header.Set("x-goog-api-key", config.APIKey)
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
return doBinary(req)
|
||
}
|
||
|
||
func geminiVeoURL(baseURL string, path string) string {
|
||
return apiURLWithDefaultPrefix(baseURL, path, "/v1beta")
|
||
}
|
||
|
||
func postStreamingBinary(ctx context.Context, config providerConfig, path string, body interface{}, onChunk func(string, []byte)) ([]byte, string, error) {
|
||
data, err := json.Marshal(body)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("序列化上游请求失败:%w", err)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), bytes.NewReader(data))
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
applyProviderAuth(req, config)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Accept", "text/event-stream")
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
return doBinaryWithConsumer(req, onChunk)
|
||
}
|
||
|
||
func postJSON(ctx context.Context, config providerConfig, path string, body interface{}, target interface{}) error {
|
||
data, err := json.Marshal(body)
|
||
if err != nil {
|
||
return fmt.Errorf("序列化上游请求失败:%w", err)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), bytes.NewReader(data))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
applyProviderAuth(req, config)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
return doJSON(req, target)
|
||
}
|
||
|
||
func applyProviderAuth(req *http.Request, config providerConfig) {
|
||
if config.APIFormat == "claude" {
|
||
req.Header.Set("x-api-key", config.APIKey)
|
||
req.Header.Set("anthropic-version", "2023-06-01")
|
||
return
|
||
}
|
||
if config.APIFormat == "gemini" {
|
||
req.Header.Set("x-goog-api-key", config.APIKey)
|
||
return
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||
}
|
||
|
||
func postForm(ctx context.Context, config providerConfig, path string, contentType string, body io.Reader, target interface{}) error {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||
req.Header.Set("Content-Type", contentType)
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
return doJSON(req, target)
|
||
}
|
||
|
||
func getJSON(ctx context.Context, config providerConfig, path string, target interface{}) error {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL(config.BaseURL, path), nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
return doJSON(req, target)
|
||
}
|
||
|
||
func postBinary(ctx context.Context, config providerConfig, path string, body interface{}) ([]byte, string, error) {
|
||
data, err := json.Marshal(body)
|
||
if err != nil {
|
||
return nil, "", fmt.Errorf("序列化上游请求失败:%w", err)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL(config.BaseURL, path), bytes.NewReader(data))
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
return doBinary(req)
|
||
}
|
||
|
||
func getBinary(ctx context.Context, config providerConfig, path string) ([]byte, string, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL(config.BaseURL, path), nil)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+config.APIKey)
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
return doBinary(req)
|
||
}
|
||
|
||
func getExternalBinary(ctx context.Context, rawURL string) ([]byte, string, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return doBinary(req)
|
||
}
|
||
|
||
func getProviderExternalBinary(ctx context.Context, config providerConfig, rawURL string) ([]byte, string, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if sameProviderOrigin(config.BaseURL, rawURL) {
|
||
applyProviderAuth(req, config)
|
||
ApplyOutboundHeaders(req, config.Headers)
|
||
}
|
||
return doBinary(req)
|
||
}
|
||
|
||
func sameProviderOrigin(baseURL string, rawURL string) bool {
|
||
base, baseErr := url.Parse(strings.TrimSpace(baseURL))
|
||
target, targetErr := url.Parse(strings.TrimSpace(rawURL))
|
||
if baseErr != nil || targetErr != nil || base.Scheme == "" || base.Host == "" || target.Scheme == "" || target.Host == "" {
|
||
return false
|
||
}
|
||
return strings.EqualFold(base.Scheme, target.Scheme) && strings.EqualFold(base.Host, target.Host)
|
||
}
|
||
|
||
func doJSON(req *http.Request, target interface{}) error {
|
||
data, mimeType, err := doBinary(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !strings.Contains(mimeType, "json") && !json.Valid(data) {
|
||
return providerResponseDecodeError{Err: fmt.Errorf("接口返回非 JSON 内容:%s", mimeType)}
|
||
}
|
||
if err := json.Unmarshal(data, target); err != nil {
|
||
return providerResponseDecodeError{Err: err}
|
||
}
|
||
if payload, ok := target.(*imageResponse); ok {
|
||
if payload.Error != nil && payload.Error.Message != "" {
|
||
return errors.New(providerPayloadErrorMessage(payload.Error.Message))
|
||
}
|
||
if payload.Code != nil && *payload.Code != 0 {
|
||
return errors.New(providerPayloadErrorMessage(payload.Msg))
|
||
}
|
||
}
|
||
if payload, ok := target.(*map[string]interface{}); ok {
|
||
if _, rawMessage, failed := providerPayloadBusinessFailure(*payload); failed {
|
||
return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)}
|
||
}
|
||
if errValue, ok := (*payload)["error"].(map[string]interface{}); ok && stringField(errValue, "message") != "" {
|
||
rawMessage := stringField(errValue, "message")
|
||
return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func doBinary(req *http.Request) ([]byte, string, error) {
|
||
return doBinaryWithConsumer(req, nil)
|
||
}
|
||
|
||
// doBinaryWithConsumer 是 Provider 出站响应的统一安全边界。JSON、SSE 和媒体下载最终都在这里执行
|
||
// 渠道并发/熔断、SSRF、超时、响应大小、HTTP 状态和审计检查;onChunk 仅观察已读取的流片段,
|
||
// 不会绕过完整响应的大小上限或错误判定。
|
||
func doBinaryWithConsumer(req *http.Request, onChunk func(string, []byte)) ([]byte, string, error) {
|
||
startedAt := time.Now()
|
||
requestTimeout := providerHTTPTimeout
|
||
if deadline, ok := req.Context().Deadline(); ok {
|
||
if remaining := time.Until(deadline); remaining > 0 {
|
||
requestTimeout = remaining
|
||
}
|
||
}
|
||
var release func()
|
||
var coordinator *platform.Coordinator
|
||
var runtimeService *Service
|
||
requestKind := providerRequestKindFromContext(req.Context())
|
||
cacheManagementRequest := requestKind == "cache-create" || requestKind == "cache-delete"
|
||
responseLimit := maxProviderResponseBytes
|
||
channelID := ""
|
||
if metadata, ok := req.Context().Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok && metadata.Service != nil {
|
||
runtimeService = metadata.Service
|
||
coordinator = metadata.Service.coordinator
|
||
channelID = metadata.ChannelID
|
||
policy, err := metadata.Service.RuntimePolicy()
|
||
if err != nil {
|
||
err = fmt.Errorf("读取生成资源限制失败:%w", err)
|
||
recordProviderRequest(req, startedAt, 0, nil, err)
|
||
return nil, "", err
|
||
}
|
||
responseLimit = megabytes(policy.Resource.GeneratedFileMB)
|
||
if !cacheManagementRequest {
|
||
open, err := coordinator.CircuitOpen(req.Context(), channelID)
|
||
if err != nil {
|
||
err = fmt.Errorf("读取渠道熔断状态失败:%w", err)
|
||
recordProviderRequest(req, startedAt, 0, nil, err)
|
||
return nil, "", err
|
||
}
|
||
if open {
|
||
err = providerCircuitOpenError{}
|
||
recordProviderRequest(req, startedAt, 0, nil, err)
|
||
return nil, "", err
|
||
}
|
||
slotID := channelID
|
||
if slotID == "" {
|
||
slotID = "custom:" + strings.ToLower(req.URL.Host)
|
||
}
|
||
var concurrencyLimit int
|
||
release, concurrencyLimit, err = metadata.Service.AcquireChannelSlot(req.Context(), channelID, slotID, requestTimeout+time.Minute)
|
||
metadata.ConcurrencyLimit = concurrencyLimit
|
||
req = req.WithContext(context.WithValue(req.Context(), providerAnalyticsKey{}, metadata))
|
||
if err != nil {
|
||
recordProviderRequest(req, startedAt, 0, nil, err)
|
||
return nil, "", err
|
||
}
|
||
}
|
||
if release != nil {
|
||
defer release()
|
||
}
|
||
}
|
||
if _, err := ValidateOutboundURL(req.URL.String()); err != nil {
|
||
recordProviderRequest(req, startedAt, 0, nil, err)
|
||
return nil, "", err
|
||
}
|
||
ApplyDefaultOutboundHeaders(req)
|
||
client := OutboundHTTPClient(requestTimeout)
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
err = providerConnectionError(err)
|
||
if runtimeService != nil && !cacheManagementRequest {
|
||
_ = runtimeService.RecordChannelResult(req.Context(), channelID, !errors.Is(err, context.Canceled))
|
||
}
|
||
recordProviderRequest(req, startedAt, 0, nil, err)
|
||
return nil, "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.ContentLength > responseLimit {
|
||
err = fmt.Errorf("上游响应超过 %s 限制", formatStorageLimit(responseLimit))
|
||
recordProviderRequest(req, startedAt, resp.StatusCode, nil, err)
|
||
return nil, "", err
|
||
}
|
||
mimeType := resp.Header.Get("Content-Type")
|
||
var buffered bytes.Buffer
|
||
reader := io.LimitReader(resp.Body, responseLimit+1)
|
||
chunk := make([]byte, 32<<10)
|
||
for {
|
||
readCount, readErr := reader.Read(chunk)
|
||
if readCount > 0 {
|
||
if int64(buffered.Len()+readCount) > responseLimit {
|
||
err = fmt.Errorf("上游响应超过 %s 限制", formatStorageLimit(responseLimit))
|
||
recordProviderRequest(req, startedAt, resp.StatusCode, buffered.Bytes(), err)
|
||
return nil, "", err
|
||
}
|
||
_, _ = buffered.Write(chunk[:readCount])
|
||
if onChunk != nil {
|
||
onChunk(mimeType, chunk[:readCount])
|
||
}
|
||
}
|
||
if errors.Is(readErr, io.EOF) {
|
||
break
|
||
}
|
||
if readErr != nil {
|
||
readErr = providerConnectionError(readErr)
|
||
recordProviderRequest(req, startedAt, resp.StatusCode, buffered.Bytes(), readErr)
|
||
return nil, "", readErr
|
||
}
|
||
}
|
||
data := buffered.Bytes()
|
||
if int64(len(data)) > responseLimit {
|
||
err = fmt.Errorf("上游响应超过 %s 限制", formatStorageLimit(responseLimit))
|
||
recordProviderRequest(req, startedAt, resp.StatusCode, nil, err)
|
||
return nil, "", err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
if runtimeService != nil && !cacheManagementRequest {
|
||
_ = runtimeService.RecordChannelResult(req.Context(), channelID, resp.StatusCode >= 500)
|
||
}
|
||
httpErr := providerHTTPError{StatusCode: resp.StatusCode, Status: resp.Status, Body: string(data), RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())}
|
||
recordProviderRequest(req, startedAt, resp.StatusCode, data, httpErr)
|
||
return nil, "", httpErr
|
||
}
|
||
recordProviderRequest(req, startedAt, resp.StatusCode, data, nil)
|
||
if runtimeService != nil && !cacheManagementRequest {
|
||
_ = runtimeService.RecordChannelResult(req.Context(), channelID, false)
|
||
}
|
||
return data, mimeType, nil
|
||
}
|
||
|
||
func providerRequestKindFromContext(ctx context.Context) string {
|
||
metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
|
||
return strings.TrimSpace(metadata.RequestKind)
|
||
}
|
||
|
||
func providerConnectionError(err error) error {
|
||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||
return fmt.Errorf("模型服务连接提前关闭,未收到完整结果;请先核对中转站任务和扣费记录,再决定是否重试:%w", err)
|
||
}
|
||
return err
|
||
}
|
||
|
||
func parseRetryAfter(value string, now time.Time) time.Duration {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return 0
|
||
}
|
||
if seconds, err := strconv.Atoi(value); err == nil && seconds > 0 {
|
||
return time.Duration(seconds) * time.Second
|
||
}
|
||
if at, err := http.ParseTime(value); err == nil && at.After(now) {
|
||
return at.Sub(now)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func providerPollingDeadline(ctx context.Context) time.Time {
|
||
if deadline, ok := ctx.Deadline(); ok {
|
||
return deadline
|
||
}
|
||
return time.Now().Add(videoPollTimeout)
|
||
}
|
||
|
||
func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode int, responseBody []byte, requestErr error) {
|
||
metadata, ok := req.Context().Value(providerAnalyticsKey{}).(providerAnalyticsContext)
|
||
if !ok || metadata.Service == nil {
|
||
return
|
||
}
|
||
status := model.ApiCallStatusSucceeded
|
||
errorCode := ""
|
||
errorText := ""
|
||
if requestErr != nil || statusCode < 200 || statusCode >= 300 {
|
||
status = model.ApiCallStatusFailed
|
||
errorCode, errorText = providerRequestErrorDetails(requestErr)
|
||
} else if businessCode, businessMessage, failed := providerResponseBusinessFailure(responseBody); failed {
|
||
status = model.ApiCallStatusFailed
|
||
errorCode = businessCode
|
||
errorText = businessMessage
|
||
}
|
||
requestKind := providerRequestKind(req.Method, req.URL.Path)
|
||
if metadata.RequestKind != "" {
|
||
requestKind = metadata.RequestKind
|
||
}
|
||
if status == model.ApiCallStatusSucceeded && (requestKind == "create" || requestKind == "poll") && (metadata.Capability == "image" || metadata.Capability == "video") {
|
||
metadata.Service.syncProviderTaskProgress(metadata.TaskID, responseBody)
|
||
}
|
||
apiFormat := "openai"
|
||
if req.Header.Get("x-goog-api-key") != "" {
|
||
apiFormat = "gemini"
|
||
}
|
||
callLog := model.ApiCallLog{
|
||
UserID: metadata.UserID, TraceID: metadata.TraceID, RequestID: metadata.RequestID, ChannelID: metadata.ChannelID, TaskID: metadata.TaskID, BillingOrderID: metadata.BillingOrderID,
|
||
Source: "backend-task", Capability: metadata.Capability, Operation: metadata.Operation,
|
||
RequestKind: requestKind, Billable: providerRequestIsBillable(req.Method, requestKind),
|
||
APIFormat: apiFormat, Method: req.Method, Path: req.URL.Path, Model: metadata.Model,
|
||
Status: status, StatusCode: statusCode, DurationMs: time.Since(startedAt).Milliseconds(),
|
||
ErrorCode: errorCode, Error: errorText, ConcurrencyLimit: metadata.ConcurrencyLimit, UpstreamURL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path,
|
||
ProviderRequestID: metadata.ProviderRequestID, RequestContentType: req.Header.Get("Content-Type"), RequestBody: requestPayloadForLog(req), ResponseBody: SanitizeAPICallPayload(responseBody, ""),
|
||
}
|
||
channelSlotFailure := false
|
||
if code, message := ChannelSlotFailureDetails(requestErr); code != "" {
|
||
channelSlotFailure = true
|
||
callLog.ErrorCode = code
|
||
callLog.Error = message
|
||
}
|
||
if requestKind == "create" && metadata.Capability == "video" {
|
||
callLog.VideoSeconds = metadata.VideoSeconds
|
||
if callLog.VideoSeconds <= 0 {
|
||
if strings.Contains(strings.ToLower(metadata.Model), "seedance") || strings.Contains(req.URL.Path, "/contents/generations/tasks") {
|
||
callLog.VideoSeconds = 5
|
||
} else {
|
||
callLog.VideoSeconds = 6
|
||
}
|
||
}
|
||
}
|
||
metadata.Service.EnrichAPICallLog(&callLog, responseBody)
|
||
callLog.Error = withUserVisibleLogError(requestErr, callLog.Error)
|
||
if err := metadata.Service.LogAPICall(callLog); err != nil {
|
||
if !channelSlotFailure && metadata.Billing != nil {
|
||
if uncertainErr := metadata.Billing.MarkBillingUncertain(metadata.BillingOrderID, "上游调用日志写入失败,费用状态待核对"); uncertainErr != nil {
|
||
// 这里无法把日志落库错误返回给已完成的 HTTP 请求,只能把计费边界失败写入进程日志,交给待核对审计继续处理。
|
||
log.Printf("provider billing uncertainty update failed: task_id=%s billing_order_id=%s error=%v", metadata.TaskID, metadata.BillingOrderID, uncertainErr)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func providerRequestIsBillable(method, requestKind string) bool {
|
||
if method != http.MethodPost {
|
||
return false
|
||
}
|
||
switch requestKind {
|
||
case "cancel", "cache-create", "cache-delete":
|
||
return false
|
||
default:
|
||
return true
|
||
}
|
||
}
|
||
|
||
func providerRequestErrorDetails(err error) (string, string) {
|
||
if err == nil {
|
||
return "", ""
|
||
}
|
||
if errors.Is(err, context.Canceled) {
|
||
return "request_cancelled", "任务取消,中断上游请求"
|
||
}
|
||
if errors.Is(err, context.DeadlineExceeded) {
|
||
return "upstream_timeout", "等待上游响应超时"
|
||
}
|
||
return "", safeProviderLogError(err)
|
||
}
|
||
|
||
// withUserVisibleLogError 把画布上实际展示的失败原因写进请求日志,同时保留原始诊断。
|
||
// 语音合成权限/音色不匹配和网络异常都会被前端改写成固定中文;只存上游原文时,后台按用户看到的文案检索不到。
|
||
func withUserVisibleLogError(requestErr error, stored string) string {
|
||
stored = strings.TrimSpace(stored)
|
||
visible := userVisibleFailureLabel(requestErr, stored)
|
||
if visible == "" || strings.Contains(stored, visible) {
|
||
return truncateRunes(stored, 2_000)
|
||
}
|
||
if stored == "" {
|
||
return visible
|
||
}
|
||
return truncateRunes(visible+" "+stored, 2_000)
|
||
}
|
||
|
||
func userVisibleFailureLabel(requestErr error, stored string) string {
|
||
texts := make([]string, 0, 3)
|
||
if requestErr != nil {
|
||
texts = append(texts, taskFailureMessage(requestErr), requestErr.Error())
|
||
}
|
||
if stored != "" {
|
||
texts = append(texts, stored)
|
||
}
|
||
network := false
|
||
for _, text := range texts {
|
||
if speechResourceDeniedUserMessage(text) != "" {
|
||
return volcengineSpeechResourceDeniedMessage
|
||
}
|
||
if userFacingTaskError(text) == taskErrorNetworkMessage {
|
||
network = true
|
||
}
|
||
}
|
||
if network {
|
||
return taskErrorNetworkMessage
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func safeProviderLogError(err error) string {
|
||
var httpErr providerHTTPError
|
||
if errors.As(err, &httpErr) {
|
||
return fmt.Sprintf("上游 HTTP %d", httpErr.StatusCode)
|
||
}
|
||
return truncateRunes(err.Error(), 500)
|
||
}
|
||
|
||
func providerRequestKind(method string, path string) string {
|
||
if method == http.MethodGet {
|
||
if strings.HasSuffix(strings.TrimRight(path, "/"), "/content") || strings.Contains(path, "/download") {
|
||
return "download"
|
||
}
|
||
return "poll"
|
||
}
|
||
if strings.Contains(path, "repair") {
|
||
return "repair"
|
||
}
|
||
return "create"
|
||
}
|
||
|
||
func apiURL(baseURL string, path string) string {
|
||
return apiURLWithDefaultPrefix(baseURL, path, "/v1")
|
||
}
|
||
|
||
// ChannelAPIURL 是 Provider 请求拼接 URL 的唯一公共入口。
|
||
// 渠道地址可配置为 host、host/、host/v1 或 host/v1/;调用方应传协议路径,避免重复硬编码版本前缀。
|
||
func ChannelAPIURL(baseURL string, path string) string {
|
||
return apiURL(baseURL, path)
|
||
}
|
||
|
||
// ChannelAPIURLForProtocol 把协议默认版本收敛在传输边界:Gemini 默认 v1beta,
|
||
// OpenAI 兼容协议默认 v1;baseURL 或 path 中显式出现的版本始终优先。
|
||
func ChannelAPIURLForProtocol(baseURL string, path string, interfaceType model.ChannelInterfaceType) string {
|
||
if interfaceType == model.ChannelInterfaceAgnesVideo && strings.HasPrefix(strings.TrimSpace(path), "/agnesapi") {
|
||
base, err := url.Parse(strings.TrimSpace(baseURL))
|
||
requestPath, pathErr := url.Parse(strings.TrimSpace(path))
|
||
if err == nil && pathErr == nil && base.Scheme != "" && base.Host != "" && strings.HasPrefix(requestPath.Path, "/") {
|
||
base.Path = requestPath.Path
|
||
base.RawPath = requestPath.RawPath
|
||
base.RawQuery = requestPath.RawQuery
|
||
base.Fragment = ""
|
||
return base.String()
|
||
}
|
||
}
|
||
defaultPrefix := "/v1"
|
||
if interfaceType == model.ChannelInterfaceGeminiVeo || interfaceType == model.ChannelInterfaceGeminiImage {
|
||
defaultPrefix = "/v1beta"
|
||
}
|
||
return apiURLWithDefaultPrefix(baseURL, path, defaultPrefix)
|
||
}
|
||
|
||
var channelAPIPrefixes = []string{"/api/plan/v3", "/api/v3", "/api/v1", "/v1beta", "/v1", "/v2", "/v3"}
|
||
|
||
func apiURLWithDefaultPrefix(baseURL string, path string, defaultPrefix string) string {
|
||
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||
requestPath := strings.TrimSpace(path)
|
||
if requestPath == "" {
|
||
return base
|
||
}
|
||
if !strings.HasPrefix(requestPath, "/") {
|
||
requestPath = "/" + requestPath
|
||
}
|
||
|
||
requestPrefix := requestAPIPathPrefix(requestPath)
|
||
basePrefix := baseAPIPathPrefix(base)
|
||
if requestPrefix != "" {
|
||
if basePrefix == requestPrefix {
|
||
return base + strings.TrimPrefix(requestPath, requestPrefix)
|
||
}
|
||
// 请求路径显式版本优先于 baseURL 残留版本,例如 base=/v1、path=/v2/... 时必须切到 /v2。
|
||
return strings.TrimSuffix(base, basePrefix) + requestPath
|
||
}
|
||
if basePrefix != "" {
|
||
return base + requestPath
|
||
}
|
||
return base + defaultPrefix + requestPath
|
||
}
|
||
|
||
func requestAPIPathPrefix(value string) string {
|
||
lower := strings.ToLower(value)
|
||
for _, prefix := range channelAPIPrefixes {
|
||
if lower == prefix || strings.HasPrefix(lower, prefix+"/") || strings.HasPrefix(lower, prefix+"?") || strings.HasPrefix(lower, prefix+"#") {
|
||
return prefix
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func baseAPIPathPrefix(value string) string {
|
||
lower := strings.ToLower(strings.TrimRight(value, "/"))
|
||
for _, prefix := range channelAPIPrefixes {
|
||
if lower == prefix || strings.HasSuffix(lower, prefix) {
|
||
return prefix
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func writeField(writer *multipart.Writer, key string, value string) {
|
||
_ = writer.WriteField(key, value)
|
||
}
|
||
|
||
func writeMediaPart(writer *multipart.Writer, field string, media providerMedia) error {
|
||
raw, mimeType, err := mediaBytes(media)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
filename := providerMediaFilename(media, mimeType)
|
||
header := make(textproto.MIMEHeader)
|
||
header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": field, "filename": filename}))
|
||
header.Set("Content-Type", mimeType)
|
||
part, err := writer.CreatePart(header)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = part.Write(raw)
|
||
return err
|
||
}
|
||
|
||
func providerMediaFilename(media providerMedia, mimeType string) string {
|
||
base := strings.TrimSpace(media.ID)
|
||
if base == "" {
|
||
base = "reference"
|
||
}
|
||
var builder strings.Builder
|
||
for _, char := range base {
|
||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' {
|
||
builder.WriteRune(char)
|
||
if builder.Len() >= 64 {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
base = builder.String()
|
||
if base == "" {
|
||
base = "reference"
|
||
}
|
||
extensions, _ := mime.ExtensionsByType(strings.TrimSpace(strings.Split(mimeType, ";")[0]))
|
||
extension := ".bin"
|
||
if len(extensions) > 0 {
|
||
extension = extensions[0]
|
||
}
|
||
return "reference-" + base + extension
|
||
}
|
||
|
||
func mediaBytes(media providerMedia) ([]byte, string, error) {
|
||
value := media.DataURL
|
||
if value == "" {
|
||
value = media.URL
|
||
}
|
||
if !strings.HasPrefix(value, "data:") {
|
||
return nil, "", errors.New("后端任务队列需要 data URL 形式的本地参考素材")
|
||
}
|
||
header, encoded, ok := strings.Cut(value, ",")
|
||
if !ok {
|
||
return nil, "", errors.New("data URL 格式错误")
|
||
}
|
||
mimeType := strings.TrimPrefix(strings.Split(strings.TrimPrefix(header, "data:"), ";")[0], " ")
|
||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return raw, normalizedMediaMimeType(defaultString(mimeType, media.Type), raw), nil
|
||
}
|