Files
new-api/relay/channel/openai/adaptor.go
T
Calcium-Ion 86ac0f7745 refactor: extract protocol conversion layer into standalone relaykit module (#6369)
* test(relayconvert): add golden snapshot matrix and relaykit boundary guard

Phase 0 of the relaykit extraction plan: pin byte-level output of every
registered (from,to) request/response/stream conversion route, and
forbid kit-bound packages from growing host-only imports.

* wip(relayconvert): drop gin.Context from converter signatures; add convmeta draft

Phase 1 in progress: relayconvert now takes context.Context; host media
resolver adapts gin.Context back at the service boundary.

* refactor(relayconvert): decouple converters from RelayInfo, gin, and settings

Phase 1 of the relaykit extraction plan:
- converters now depend on convmeta.Meta (implemented by RelayInfo) instead
  of *relaycommon.RelayInfo; ClaudeConvertInfo and the format guesser move
  to convmeta with aliases left behind
- host settings reach converters via a convmeta.Options snapshot built in
  RelayInfo.ConvOptions; no more model_setting/reasoning global reads inside
  the conversion layer
- effort-suffix helpers move to service/relayconvert/reasoning (old package
  forwards); chat-to-responses upgrade policy moves to service (host routing
  logic, not conversion)
- golden conversion matrix unchanged

* test(relayconvert): tighten boundary — kit packages now free of gin/setting imports

* refactor(dto): drop gin and logger dependencies

Phase 2 (part 1): dto.Request.IsStream now takes *http.Request instead of
*gin.Context (Gemini's impl reads query/path off the std request); dto's
three logger calls become common.SysError. Boundary test allowlist is now
empty — kit-bound packages import no gin/setting/logger/model.

* refactor(kit): extract dependency-free kitutil; dto/types/relayconvert stop importing common

Phase 2 of the relaykit extraction plan:
- new service/relayconvert/kitutil holds the pure helpers the kit needs
  (JSON wrappers, pointer/string/uuid/timestamp utils, MaskSensitiveInfo,
  pluggable LogInfo/LogError hooks, Debug flag)
- dto, types, and all relayconvert packages now use kitutil; their only
  remaining internal deps are dto/types/constant
- common keeps every original symbol (MaskSensitiveInfo delegates to
  kitutil) so host code is untouched; main.go routes kit logging into
  common.SysLog/SysError and mirrors DebugEnabled
- golden conversion matrix unchanged

* refactor(kit): move EndpointType/FinishReason to types; OpenRouter dialect via Options

Kit packages (dto/types/relayconvert/reasonmap) no longer import constant:
- EndpointType and finish-reason values live in types; constant re-exports
- the OpenRouter special-case in claude->openai request conversion reads
  Options.OpenRouterDialect, set by the host from the channel type;
  InitChannelMeta invalidates the cached snapshot on channel switch

* refactor: extract relaykit submodule (dto/types/relayconvert/reasonmap)

Phase 3 of the relaykit extraction plan:
- new go module github.com/QuantumNous/new-api/relaykit containing dto
  (minus task family), types, relayconvert (with convmeta/kitutil/reasoning),
  and reasonmap; host consumes it via require + replace, go.work for dev
- task-family dto (task/suno/midjourney/video) stays in the host dto
  package; dual-consumer host files alias it as taskdto
- relaykit builds and tests standalone (GOWORK=off): no host imports,
  no gin, no DB, no settings
- golden conversion matrix unchanged

* build(docker): copy relaykit/go.mod before go mod download

The local-replace submodule's go.mod must exist inside the build context
for the main module graph to resolve.

* fix: address relaykit extraction regressions

* fix: address relaykit review regressions

* docs: document Meta nil receiver contract

* fix(relaykit): fail OpenAI→Claude conversion without max_tokens; reject negative default_max_tokens

The Claude Messages API requires max_tokens (omitting it is a 400
"Field required"), but with a nil Options.Claude.DefaultMaxTokens hook
the converters silently emitted a request the upstream is guaranteed to
reject. Both OpenAI Chat and Responses → Claude conversions now return
sharedclaude.ErrMissingMaxTokens when no path (client value, default
hook, thinking-adapter floor) supplied one. Unreachable in the host,
which always configures the hook.

Host side, claude.default_max_tokens now rejects negative values at the
option API before persisting — they would wrap into huge unsigned values
during conversion. Zero stays allowed: the current API treats
max_tokens: 0 as cache pre-warming.

* fix: make Gemini safety settings read path race-free
2026-07-27 15:56:21 +08:00

704 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package openai
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"path/filepath"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/ai360"
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
"github.com/QuantumNous/new-api/relaykit/dto"
//"github.com/QuantumNous/new-api/relay/channel/minimax"
"github.com/QuantumNous/new-api/relay/channel/openrouter"
"github.com/QuantumNous/new-api/relay/channel/xinference"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/common_handler"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/samber/lo"
"github.com/gin-gonic/gin"
)
type Adaptor struct {
ChannelType int
ResponseFormat string
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
if err != nil {
return nil, err
}
openaiRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
if !ok {
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
}
return a.ConvertOpenAIRequest(c, info, openaiRequest)
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
//if !strings.Contains(request.Model, "claude") {
// return nil, fmt.Errorf("you are using openai channel type with path /v1/messages, only claude model supported convert, but got %s", request.Model)
//}
//if common.DebugEnabled {
// bodyBytes := []byte(common.GetJsonString(request))
// err := os.WriteFile(fmt.Sprintf("claude_request_%s.txt", c.GetString(common.RequestIdKey)), bodyBytes, 0644)
// if err != nil {
// println(fmt.Sprintf("failed to save request body to file: %v", err))
// }
//}
result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
if err != nil {
return nil, err
}
aiRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
if !ok {
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
}
//if common.DebugEnabled {
// println(fmt.Sprintf("convert claude to openai request result: %s", common.GetJsonString(aiRequest)))
// // Save request body to file for debugging
// bodyBytes := []byte(common.GetJsonString(aiRequest))
// err = os.WriteFile(fmt.Sprintf("claude_to_openai_request_%s.txt", c.GetString(common.RequestIdKey)), bodyBytes, 0644)
// if err != nil {
// println(fmt.Sprintf("failed to save request body to file: %v", err))
// }
//}
if info.SupportStreamOptions && info.IsStream {
aiRequest.StreamOptions = &dto.StreamOptions{
IncludeUsage: true,
}
}
return a.ConvertOpenAIRequest(c, info, aiRequest)
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
a.ChannelType = info.ChannelType
// initialize ThinkingContentInfo when thinking_to_content is enabled
if info.ChannelSetting.ThinkingToContent {
info.ThinkingContentInfo = relaycommon.ThinkingContentInfo{
IsFirstThinkingContent: true,
SendLastThinkingContent: false,
HasSentThinkingContent: false,
}
}
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayMode == relayconstant.RelayModeRealtime {
if strings.HasPrefix(info.ChannelBaseUrl, "https://") {
baseUrl := strings.TrimPrefix(info.ChannelBaseUrl, "https://")
baseUrl = "wss://" + baseUrl
info.ChannelBaseUrl = baseUrl
} else if strings.HasPrefix(info.ChannelBaseUrl, "http://") {
baseUrl := strings.TrimPrefix(info.ChannelBaseUrl, "http://")
baseUrl = "ws://" + baseUrl
info.ChannelBaseUrl = baseUrl
}
}
switch info.ChannelType {
case constant.ChannelTypeAzure:
apiVersion := info.ApiVersion
if apiVersion == "" {
apiVersion = constant.AzureDefaultAPIVersion
}
// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api
requestURL := strings.Split(info.RequestURLPath, "?")[0]
requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion)
task := strings.TrimPrefix(requestURL, "/v1/")
if info.RelayFormat == types.RelayFormatClaude {
task = strings.TrimPrefix(task, "messages")
task = "chat/completions" + task
}
// 特殊处理 responses API(包含 compact
if info.RelayMode == relayconstant.RelayModeResponses || info.RelayMode == relayconstant.RelayModeResponsesCompact {
responsesApiVersion := "preview"
subUrl := "/openai/v1/responses"
if strings.Contains(info.ChannelBaseUrl, "cognitiveservices.azure.com") {
subUrl = "/openai/responses"
responsesApiVersion = apiVersion
}
if info.ChannelOtherSettings.AzureResponsesVersion != "" {
responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion
}
// compact 模式追加 /compact
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
subUrl = subUrl + "/compact"
}
requestURL = fmt.Sprintf("%s?api-version=%s", subUrl, responsesApiVersion)
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestURL, info.ChannelType), nil
}
model_ := info.UpstreamModelName
// 2025年5月10日后创建的渠道不移除.
if info.ChannelCreateTime < constant.AzureNoRemoveDotTime {
model_ = strings.Replace(model_, ".", "", -1)
}
// https://github.com/songquanpeng/one-api/issues/67
requestURL = fmt.Sprintf("/openai/deployments/%s/%s", model_, task)
if info.RelayMode == relayconstant.RelayModeRealtime {
requestURL = fmt.Sprintf("/openai/realtime?deployment=%s&api-version=%s", model_, apiVersion)
}
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestURL, info.ChannelType), nil
//case constant.ChannelTypeMiniMax:
// return minimax.GetRequestURL(info)
case constant.ChannelTypeCustom:
url := info.ChannelBaseUrl
url = strings.Replace(url, "{model}", info.UpstreamModelName, -1)
return url, nil
default:
if (info.RelayFormat == types.RelayFormatClaude || info.RelayFormat == types.RelayFormatGemini) &&
info.RelayMode != relayconstant.RelayModeResponses &&
info.RelayMode != relayconstant.RelayModeResponsesCompact {
return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil
}
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
}
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, header)
if info.ChannelType == constant.ChannelTypeAzure {
header.Set("api-key", info.ApiKey)
return nil
}
if info.ChannelType == constant.ChannelTypeOpenAI && "" != info.Organization {
header.Set("OpenAI-Organization", info.Organization)
}
// 检查 Header Override 是否已设置 Authorization,如果已设置则跳过默认设置
// 这样可以避免在 Header Override 应用时被覆盖(虽然 Header Override 会在之后应用,但这里作为额外保护)
hasAuthOverride := false
if len(info.HeadersOverride) > 0 {
for k := range info.HeadersOverride {
if strings.EqualFold(k, "Authorization") {
hasAuthOverride = true
break
}
}
}
if info.RelayMode == relayconstant.RelayModeRealtime {
// OpenAI 已下线 Realtime Beta API,GA 模型收到 beta 标识会以 beta_api_shape_disabled 拒绝;
// 仅对遗留 preview 模型保留 beta 标识
legacyRealtimeBeta := strings.Contains(info.UpstreamModelName, "-realtime-preview")
swp := c.Request.Header.Get("Sec-WebSocket-Protocol")
if swp != "" {
items := []string{
"realtime",
"openai-insecure-api-key." + info.ApiKey,
}
if legacyRealtimeBeta {
items = append(items, "openai-beta.realtime-v1")
}
header.Set("Sec-WebSocket-Protocol", strings.Join(items, ","))
//req.Header.Set("Sec-WebSocket-Key", c.Request.Header.Get("Sec-WebSocket-Key"))
//req.Header.Set("Sec-Websocket-Extensions", c.Request.Header.Get("Sec-Websocket-Extensions"))
//req.Header.Set("Sec-Websocket-Version", c.Request.Header.Get("Sec-Websocket-Version"))
} else {
if legacyRealtimeBeta {
header.Set("openai-beta", "realtime=v1")
}
if !hasAuthOverride {
header.Set("Authorization", "Bearer "+info.ApiKey)
}
}
} else {
if !hasAuthOverride {
header.Set("Authorization", "Bearer "+info.ApiKey)
}
}
if info.ChannelType == constant.ChannelTypeOpenRouter {
if header.Get("HTTP-Referer") == "" {
header.Set("HTTP-Referer", "https://www.newapi.ai")
}
if header.Get("X-OpenRouter-Title") == "" {
header.Set("X-OpenRouter-Title", "New API")
}
}
return nil
}
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
if info.ChannelType != constant.ChannelTypeOpenAI && info.ChannelType != constant.ChannelTypeAzure {
request.StreamOptions = nil
}
if info.ChannelType == constant.ChannelTypeOpenRouter {
if len(request.Usage) == 0 {
request.Usage = json.RawMessage(`{"include":true}`)
}
// 适配 OpenRouter 的 thinking 后缀
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) &&
strings.HasSuffix(info.UpstreamModelName, "-thinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
request.Model = info.UpstreamModelName
if len(request.Reasoning) == 0 {
reasoning := map[string]any{
"enabled": true,
}
if request.ReasoningEffort != "" && request.ReasoningEffort != "none" {
reasoning["effort"] = request.ReasoningEffort
}
marshal, err := common.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
// 清空多余的ReasoningEffort
request.ReasoningEffort = ""
} else {
if len(request.Reasoning) == 0 {
// 适配 OpenAI 的 ReasoningEffort 格式
if request.ReasoningEffort != "" {
reasoning := map[string]any{
"enabled": true,
}
if request.ReasoningEffort != "none" {
reasoning["effort"] = request.ReasoningEffort
marshal, err := common.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
}
}
request.ReasoningEffort = ""
}
// https://docs.anthropic.com/en/api/openai-sdk#extended-thinking-support
// 没有做排除3.5Haiku等,要出问题再加吧,最佳兼容性(不是
if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") {
var thinking dto.Thinking // Claude标准Thinking格式
if err := json.Unmarshal(request.THINKING, &thinking); err != nil {
return nil, fmt.Errorf("error Unmarshal thinking: %w", err)
}
// 只有当 thinking.Type 是 "enabled" 时才处理
if thinking.Type == "enabled" {
// 检查 BudgetTokens 是否为 nil
if thinking.BudgetTokens == nil {
return nil, fmt.Errorf("BudgetTokens is nil when thinking is enabled")
}
reasoning := openrouter.RequestReasoning{
Enabled: true,
MaxTokens: *thinking.BudgetTokens,
}
marshal, err := common.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
// 清空 THINKING
request.THINKING = nil
}
}
isOModel := dto.IsOpenAIReasoningOModel(info.UpstreamModelName)
isGPT5Model := dto.IsOpenAIGPT5Model(info.UpstreamModelName)
if isOModel || isGPT5Model {
if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 {
request.MaxCompletionTokens = request.MaxTokens
request.MaxTokens = nil
}
if isOModel {
request.Temperature = nil
}
// gpt-5系列模型适配 归零不再支持的参数
if isGPT5Model {
request.Temperature = nil
request.TopP = nil
request.LogProbs = nil
}
// 转换模型推理力度后缀
effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
if effort != "" {
request.ReasoningEffort = effort
info.UpstreamModelName = originModel
request.Model = originModel
}
info.ReasoningEffort = request.ReasoningEffort
// o系列模型developer适配(o1-mini除外)
if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") {
//修改第一个Message的内容,将system改为developer
if len(request.Messages) > 0 && request.Messages[0].Role == "system" {
request.Messages[0].Role = "developer"
}
}
}
return request, nil
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return request, nil
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
return request, nil
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
a.ResponseFormat = request.ResponseFormat
if info.RelayMode == relayconstant.RelayModeAudioSpeech {
jsonData, err := common.Marshal(request)
if err != nil {
return nil, fmt.Errorf("error marshalling object: %w", err)
}
return bytes.NewReader(jsonData), nil
} else {
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
writer.WriteField("model", request.Model)
formData, err2 := common.ParseMultipartFormReusable(c)
if err2 != nil {
return nil, fmt.Errorf("error parsing multipart form: %w", err2)
}
// 打印类似 curl 命令格式的信息
logger.LogDebug(c.Request.Context(), "--form 'model=\"%s\"'", request.Model)
// 遍历表单字段并打印输出
for key, values := range formData.Value {
if key == "model" {
continue
}
for _, value := range values {
writer.WriteField(key, value)
logger.LogDebug(c.Request.Context(), "--form '%s=\"%s\"'", key, value)
}
}
// 从 formData 中获取文件
fileHeaders := formData.File["file"]
if len(fileHeaders) == 0 {
return nil, errors.New("file is required")
}
// 使用 formData 中的第一个文件
fileHeader := fileHeaders[0]
logger.LogDebug(c.Request.Context(), "--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)",
fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type"))
file, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("error opening audio file: %v", err)
}
defer file.Close()
part, err := writer.CreateFormFile("file", fileHeader.Filename)
if err != nil {
return nil, errors.New("create form file failed")
}
if _, err := io.Copy(part, file); err != nil {
return nil, errors.New("copy file failed")
}
// 关闭 multipart 编写器以设置分界线
writer.Close()
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
logger.LogDebug(c.Request.Context(), "--header 'Content-Type: %s'", writer.FormDataContentType())
return &requestBody, nil
}
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
switch info.RelayMode {
case relayconstant.RelayModeImagesEdits:
if isJSONRequest(c) {
return request, nil
}
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
writer.WriteField("model", request.Model)
// 使用已解析的 multipart 表单,避免重复解析
mf := c.Request.MultipartForm
if mf == nil {
form, err := common.ParseMultipartFormReusable(c)
if err != nil {
return nil, fmt.Errorf("failed to parse multipart form: %w", err)
}
c.Request.MultipartForm = form
c.Request.PostForm = url.Values(form.Value)
mf = form
}
// 写入所有非文件字段
if mf != nil {
for key, values := range mf.Value {
if key == "model" {
continue
}
for _, value := range values {
writer.WriteField(key, value)
}
}
}
if mf != nil && mf.File != nil {
// Check if "image" field exists in any form, including array notation
var imageFiles []*multipart.FileHeader
var exists bool
// First check for standard "image" field
if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 {
// If not found, check for "image[]" field
if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 {
// If still not found, iterate through all fields to find any that start with "image["
foundArrayImages := false
for fieldName, files := range mf.File {
if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
foundArrayImages = true
imageFiles = append(imageFiles, files...)
}
}
// If no image fields found at all
if !foundArrayImages && (len(imageFiles) == 0) {
return nil, errors.New("image is required")
}
}
}
// Process all image files
for i, fileHeader := range imageFiles {
file, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("failed to open image file %d: %w", i, err)
}
// If multiple images, use image[] as the field name
fieldName := "image"
if len(imageFiles) > 1 {
fieldName = "image[]"
}
// Determine MIME type based on file extension
mimeType := detectImageMimeType(fileHeader.Filename)
// Create a form file with the appropriate content type
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename))
h.Set("Content-Type", mimeType)
part, err := writer.CreatePart(h)
if err != nil {
return nil, fmt.Errorf("create form part failed for image %d: %w", i, err)
}
if _, err := io.Copy(part, file); err != nil {
return nil, fmt.Errorf("copy file failed for image %d: %w", i, err)
}
// 复制完立即关闭,避免在循环内使用 defer 占用资源
_ = file.Close()
}
// Handle mask file if present
if maskFiles, exists := mf.File["mask"]; exists && len(maskFiles) > 0 {
maskFile, err := maskFiles[0].Open()
if err != nil {
return nil, errors.New("failed to open mask file")
}
// 复制完立即关闭,避免在循环内使用 defer 占用资源
// Determine MIME type for mask file
mimeType := detectImageMimeType(maskFiles[0].Filename)
// Create a form file with the appropriate content type
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename))
h.Set("Content-Type", mimeType)
maskPart, err := writer.CreatePart(h)
if err != nil {
return nil, errors.New("create form file failed for mask")
}
if _, err := io.Copy(maskPart, maskFile); err != nil {
return nil, errors.New("copy mask file failed")
}
_ = maskFile.Close()
}
} else {
return nil, errors.New("no multipart form data found")
}
// 关闭 multipart 编写器以设置分界线
writer.Close()
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
return &requestBody, nil
default:
return request, nil
}
}
func isJSONRequest(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
}
return strings.HasPrefix(c.Request.Header.Get("Content-Type"), "application/json")
}
// detectImageMimeType determines the MIME type based on the file extension
func detectImageMimeType(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".webp":
return "image/webp"
default:
// Try to detect from extension if possible
if strings.HasPrefix(ext, ".jp") {
return "image/jpeg"
}
// Default to png as a fallback
return "image/png"
}
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// 转换模型推理力度后缀
effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(request.Model)
if effort != "" {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{
Effort: effort,
}
} else {
request.Reasoning.Effort = effort
}
request.Model = originModel
}
if info != nil && request.Reasoning != nil && request.Reasoning.Effort != "" {
info.ReasoningEffort = request.Reasoning.Effort
}
return request, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
if info.RelayMode == relayconstant.RelayModeAudioTranscription ||
info.RelayMode == relayconstant.RelayModeAudioTranslation ||
(info.RelayMode == relayconstant.RelayModeImagesEdits && !isJSONRequest(c)) {
return channel.DoFormRequest(a, c, info, requestBody)
} else if info.RelayMode == relayconstant.RelayModeRealtime {
return channel.DoWssRequest(a, c, info, requestBody)
} else {
return channel.DoApiRequest(a, c, info, requestBody)
}
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
switch info.RelayMode {
case relayconstant.RelayModeRealtime:
err, usage = OpenaiRealtimeHandler(c, info)
case relayconstant.RelayModeAudioSpeech:
usage = OpenaiTTSHandler(c, resp, info)
case relayconstant.RelayModeAudioTranslation:
fallthrough
case relayconstant.RelayModeAudioTranscription:
err, usage = OpenaiSTTHandler(c, resp, info, a.ResponseFormat)
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
if info.IsStream {
usage, err = OpenaiImageStreamHandler(c, info, resp)
} else {
usage, err = OpenaiImageHandler(c, info, resp)
}
case relayconstant.RelayModeRerank:
usage, err = common_handler.RerankHandler(c, info, resp)
case relayconstant.RelayModeResponses:
if info.IsStream {
usage, err = OaiResponsesStreamHandler(c, info, resp)
} else {
usage, err = OaiResponsesHandler(c, info, resp)
}
case relayconstant.RelayModeResponsesCompact:
usage, err = OaiResponsesCompactionHandler(c, resp)
default:
if info.IsStream {
usage, err = OaiStreamHandler(c, info, resp)
} else {
usage, err = OpenaiHandler(c, info, resp)
}
}
return
}
func (a *Adaptor) GetModelList() []string {
switch a.ChannelType {
case constant.ChannelType360:
return ai360.ModelList
case constant.ChannelTypeLingYiWanWu:
return lingyiwanwu.ModelList
//case constant.ChannelTypeMiniMax:
// return minimax.ModelList
case constant.ChannelTypeXinference:
return xinference.ModelList
case constant.ChannelTypeOpenRouter:
return openrouter.ModelList
default:
return ModelList
}
}
func (a *Adaptor) GetChannelName() string {
switch a.ChannelType {
case constant.ChannelType360:
return ai360.ChannelName
case constant.ChannelTypeLingYiWanWu:
return lingyiwanwu.ChannelName
//case constant.ChannelTypeMiniMax:
// return minimax.ChannelName
case constant.ChannelTypeXinference:
return xinference.ChannelName
case constant.ChannelTypeOpenRouter:
return openrouter.ChannelName
default:
return ChannelName
}
}