Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions common/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ var (
"dall-e-3",
"dall-e-2",
"gpt-image-1",
"gpt-image-2",
"prefix:imagen-",
"flux-",
"flux.1-",
Expand Down
22 changes: 22 additions & 0 deletions controller/channel-test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ type testResult struct {
newAPIError *types.NewAPIError
}

func isCodexImageGenerationTestModel(channel *model.Channel, modelName string) bool {
return channel != nil &&
channel.Type == constant.ChannelTypeCodex &&
strings.EqualFold(strings.TrimSpace(modelName), "gpt-image-2")
}

func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointType string) string {
normalized := strings.TrimSpace(endpointType)
if normalized != "" {
Expand All @@ -51,6 +57,9 @@ func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointTyp
if strings.HasSuffix(modelName, ratio_setting.CompactModelSuffix) {
return string(constant.EndpointTypeOpenAIResponseCompact)
}
if isCodexImageGenerationTestModel(channel, modelName) {
return string(constant.EndpointTypeImageGeneration)
}
if channel != nil && channel.Type == constant.ChannelTypeCodex {
return string(constant.EndpointTypeOpenAIResponse)
}
Expand Down Expand Up @@ -122,6 +131,10 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
requestPath = "/v1/images/generations"
}

if isCodexImageGenerationTestModel(channel, testModel) {
requestPath = "/v1/images/generations"
}

// responses-only models
if strings.Contains(strings.ToLower(testModel), "codex") {
requestPath = "/v1/responses"
Expand Down Expand Up @@ -773,6 +786,15 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel,
}
}

if isCodexImageGenerationTestModel(channel, model) {
return &dto.ImageRequest{
Model: model,
Prompt: "a cute cat",
N: lo.ToPtr(uint(1)),
Size: "1024x1024",
}
}

// Responses-only models (e.g. codex series)
if strings.Contains(strings.ToLower(model), "codex") {
return &dto.OpenAIResponsesRequest{
Expand Down
11 changes: 8 additions & 3 deletions dto/openai_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,14 @@ func (i *ImageRequest) SetModelName(modelName string) {
}

type ImageResponse struct {
Data []ImageData `json:"data"`
Created int64 `json:"created"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Data []ImageData `json:"data"`
Created int64 `json:"created"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Background string `json:"background,omitempty"`
OutputFormat string `json:"output_format,omitempty"`
Quality string `json:"quality,omitempty"`
Size string `json:"size,omitempty"`
Usage *Usage `json:"usage,omitempty"`
}
type ImageData struct {
Url string `json:"url"`
Expand Down
24 changes: 14 additions & 10 deletions dto/openai_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,16 +338,20 @@ type IncompleteDetails struct {
}

type ResponsesOutput struct {
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status"`
Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"`
Quality string `json:"quality"`
Size string `json:"size"`
CallId string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status"`
Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"`
Quality string `json:"quality"`
Size string `json:"size"`
Result string `json:"result,omitempty"`
RevisedPrompt string `json:"revised_prompt,omitempty"`
OutputFormat string `json:"output_format,omitempty"`
Background string `json:"background,omitempty"`
CallId string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
}

// ArgumentsString returns function call arguments in the string form expected by Chat Completions.
Expand Down
195 changes: 188 additions & 7 deletions relay/channel/codex/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (
"github.com/QuantumNous/new-api/types"

"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)

type Adaptor struct {
Expand All @@ -34,7 +36,16 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
}

func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return nil, errors.New("codex channel: endpoint not supported")
relayMode := relayconstant.RelayModeUnknown
if info != nil {
relayMode = info.RelayMode
}
switch relayMode {
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
return buildCodexImageResponsesRequest(c, info, request)
default:
return nil, errors.New("codex channel: only /v1/images/generations and /v1/images/edits are supported for image requests")
}
}

func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
Expand All @@ -52,10 +63,170 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
return nil, errors.New("codex channel: /v1/embeddings endpoint not supported")
}

func normalizeCodexResponsesInput(raw json.RawMessage) (json.RawMessage, error) {
if common.GetJsonType(raw) != "string" {
return raw, nil
}
var input string
if err := common.Unmarshal(raw, &input); err != nil {
return raw, err
}
return common.Marshal([]map[string]string{{
"role": "user",
"content": input,
}})
}

func normalizeCodexResponsesTools(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 || common.GetJsonType(raw) != "array" {
return raw, nil
}
var tools []map[string]any
if err := common.Unmarshal(raw, &tools); err != nil {
return raw, err
}
changed := false
for i := range tools {
if common.Interface2String(tools[i]["type"]) != imageGenerationTool {
continue
}
if strings.TrimSpace(common.Interface2String(tools[i]["model"])) == "" {
tools[i]["model"] = CodexImageModel
changed = true
}
}
if !changed {
return raw, nil
}
return common.Marshal(tools)
}

func buildCodexRawResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest, isCompact bool) (json.RawMessage, bool, error) {
if c == nil || c.Request == nil || c.Request.Body == nil {
return nil, false, nil
}
storage, err := common.GetBodyStorage(c)
if err != nil {
return nil, false, err
}
raw, err := storage.Bytes()
if err != nil {
return nil, false, err
}
if len(raw) == 0 || common.GetJsonType(raw) != "object" {
return nil, false, nil
}

out := string(raw)
if request.Model != "" && gjson.Get(out, "model").String() != request.Model {
out, err = sjson.Set(out, "model", request.Model)
if err != nil {
return nil, false, err
}
}

input := gjson.Get(out, "input")
if input.Exists() && input.Type == gjson.String {
wrapped := []map[string]string{{
"role": "user",
"content": input.String(),
}}
out, err = sjson.Set(out, "input", wrapped)
if err != nil {
return nil, false, err
}
}

if tools := gjson.Get(out, "tools"); tools.Exists() && tools.IsArray() {
normalizedTools, err := normalizeCodexResponsesTools(json.RawMessage(tools.Raw))
if err != nil {
return nil, false, err
}
if string(normalizedTools) != tools.Raw {
out, err = sjson.SetRaw(out, "tools", string(normalizedTools))
if err != nil {
return nil, false, err
}
}
}

if info != nil && info.ChannelMeta != nil && info.ChannelSetting.SystemPrompt != "" {
systemPrompt := info.ChannelSetting.SystemPrompt
instructions := gjson.Get(out, "instructions")
if !instructions.Exists() {
out, err = sjson.Set(out, "instructions", systemPrompt)
if err != nil {
return nil, false, err
}
} else if info.ChannelSetting.SystemPromptOverride {
if instructions.Type == gjson.String {
existing := strings.TrimSpace(instructions.String())
if existing != "" {
systemPrompt += "\n" + existing
}
}
out, err = sjson.Set(out, "instructions", systemPrompt)
if err != nil {
return nil, false, err
}
}
} else if !gjson.Get(out, "instructions").Exists() {
out, err = sjson.Set(out, "instructions", "")
if err != nil {
return nil, false, err
}
}

if !isCompact {
out, err = sjson.Set(out, "stream", true)
if err != nil {
return nil, false, err
}
out, err = sjson.Set(out, "store", false)
if err != nil {
return nil, false, err
}
out, err = sjson.Delete(out, "max_output_tokens")
if err != nil {
return nil, false, err
}
out, err = sjson.Delete(out, "temperature")
if err != nil {
return nil, false, err
}
}

return json.RawMessage(out), true, nil
}

func IsRawResponsesRequest(request any) bool {
_, ok := request.(json.RawMessage)
return ok
}

func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
isCompact := info != nil && info.RelayMode == relayconstant.RelayModeResponsesCompact

if info != nil && info.ChannelSetting.SystemPrompt != "" {
if rawRequest, ok, err := buildCodexRawResponsesRequest(c, info, request, isCompact); ok || err != nil {
return rawRequest, err
}

if len(request.Input) > 0 {
normalizedInput, err := normalizeCodexResponsesInput(request.Input)
if err != nil {
return nil, err
}
request.Input = normalizedInput
}
if len(request.Tools) > 0 {
normalizedTools, err := normalizeCodexResponsesTools(request.Tools)
if err != nil {
return nil, err
}
request.Tools = normalizedTools
}

if info != nil && info.ChannelMeta != nil && info.ChannelSetting.SystemPrompt != "" {
systemPrompt := info.ChannelSetting.SystemPrompt

if len(request.Instructions) == 0 {
Expand Down Expand Up @@ -99,6 +270,7 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
if isCompact {
return request, nil
}
request.Stream = common.GetPointer(true)
// codex: store must be false
request.Store = json.RawMessage("false")
// rm max_output_tokens
Expand All @@ -112,6 +284,13 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}

func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if isCodexImageRelayMode(info) {
return handleImageResponse(c, resp, info)
}

if info == nil {
return nil, types.NewError(errors.New("codex channel: relay info is nil"), types.ErrorCodeInvalidRequest)
}
if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact {
return nil, types.NewError(errors.New("codex channel: endpoint not supported"), types.ErrorCodeInvalidRequest)
}
Expand All @@ -121,9 +300,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
}

if info.IsStream {
return openai.OaiResponsesStreamHandler(c, info, resp)
return handleResponsesStream(c, resp, info)
}
return openai.OaiResponsesHandler(c, info, resp)
return handleResponsesNonStream(c, resp, info)
}

func (a *Adaptor) GetModelList() []string {
Expand All @@ -135,8 +314,10 @@ func (a *Adaptor) GetChannelName() string {
}

func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact {
return "", errors.New("codex channel: only /v1/responses and /v1/responses/compact are supported")
if info.RelayMode != relayconstant.RelayModeResponses &&
info.RelayMode != relayconstant.RelayModeResponsesCompact &&
!isCodexImageRelayMode(info) {
return "", errors.New("codex channel: only /v1/responses, /v1/responses/compact, /v1/images/generations and /v1/images/edits are supported")
}
path := "/backend-api/codex/responses"
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
Expand Down Expand Up @@ -182,7 +363,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
// Clients may omit it or include parameters like `application/json; charset=utf-8`,
// which can be rejected by the upstream. Force the exact media type.
req.Set("Content-Type", "application/json")
if info.IsStream {
if info.IsStream || info.RelayMode == relayconstant.RelayModeResponses || isCodexImageRelayMode(info) {
req.Set("Accept", "text/event-stream")
} else if req.Get("Accept") == "" {
req.Set("Accept", "application/json")
Expand Down
Loading