new-api / codex-luna-compat.patch
Codex
Preserve exact Gemini high model
e15b1ad
Raw
History Blame Contribute Delete
28.8 kB
diff --git a/dto/openai_response.go b/dto/openai_response.go
index 7959f07..a494a7f 100644
--- a/dto/openai_response.go
+++ b/dto/openai_response.go
@@ -358,8 +358,10 @@ type ResponsesOutputContent struct {
}
type ResponsesReasoningSummaryPart struct {
- Type string `json:"type"`
- Text string `json:"text"`
+ Type string `json:"type"`
+ Text string `json:"text"`
+ Annotations []interface{} `json:"annotations,omitempty"`
+ Logprobs []interface{} `json:"logprobs,omitempty"`
}
const (
@@ -387,6 +389,8 @@ type ResponsesStreamResponse struct {
Type string `json:"type"`
Response *OpenAIResponsesResponse `json:"response,omitempty"`
Delta string `json:"delta,omitempty"`
+ Text string `json:"text,omitempty"`
+ Logprobs []interface{} `json:"logprobs,omitempty"`
Item *ResponsesOutput `json:"item,omitempty"`
// - response.function_call_arguments.delta
// - response.function_call_arguments.done
diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go
index e0ab48e..1846cde 100644
--- a/relay/channel/gemini/adaptor.go
+++ b/relay/channel/gemini/adaptor.go
@@ -133,6 +133,7 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
+ info.OriginModelName != "gpt-5.6-luna" &&
!model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.UpstreamModelName, "-thinking-") {
diff --git a/relay/channel/gemini/codex_luna_model_test.go b/relay/channel/gemini/codex_luna_model_test.go
new file mode 100644
index 0000000..cd731ad
--- /dev/null
+++ b/relay/channel/gemini/codex_luna_model_test.go
@@ -0,0 +1,44 @@
+package gemini
+
+import (
+ "testing"
+
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/QuantumNous/new-api/setting/model_setting"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGetRequestURLPreservesCodexLunaHighModel(t *testing.T) {
+ settings := model_setting.GetGeminiSettings()
+ originalThinkingAdapterEnabled := settings.ThinkingAdapterEnabled
+ settings.ThinkingAdapterEnabled = true
+ t.Cleanup(func() {
+ settings.ThinkingAdapterEnabled = originalThinkingAdapterEnabled
+ })
+
+ adaptor := &Adaptor{}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gpt-5.6-luna",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "gemini-3.7-flash-high",
+ ChannelBaseUrl: "https://example.test",
+ },
+ }
+
+ url, err := adaptor.GetRequestURL(info)
+ require.NoError(t, err)
+ require.Equal(t, "https://example.test/v1beta/models/gemini-3.7-flash-high:generateContent", url)
+ require.Equal(t, "gemini-3.7-flash-high", info.UpstreamModelName)
+
+ ordinaryInfo := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-3.7-flash-high",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "gemini-3.7-flash-high",
+ ChannelBaseUrl: "https://example.test",
+ },
+ }
+ ordinaryURL, err := adaptor.GetRequestURL(ordinaryInfo)
+ require.NoError(t, err)
+ require.Equal(t, "https://example.test/v1beta/models/gemini-3.7-flash:generateContent", ordinaryURL)
+ require.Equal(t, "gemini-3.7-flash", ordinaryInfo.UpstreamModelName)
+}
diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go
index 3fd2466..bd52825 100644
--- a/relay/channel/openai/adaptor.go
+++ b/relay/channel/openai/adaptor.go
@@ -602,6 +602,16 @@ func detectImageMimeType(filename string) string {
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
+ if request.Model == codexLunaUpstreamModel ||
+ (info != nil && info.OriginModelName == codexLunaOriginModel) {
+ request.Model = codexLunaUpstreamModel
+ request.Reasoning = nil
+ if info != nil {
+ info.ReasoningEffort = ""
+ }
+ return request, nil
+ }
+
// 转换模型推理力度后缀
effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(request.Model)
if effort != "" {
diff --git a/relay/channel/openai/adaptor_responses_luna_test.go b/relay/channel/openai/adaptor_responses_luna_test.go
new file mode 100644
index 0000000..2d79925
--- /dev/null
+++ b/relay/channel/openai/adaptor_responses_luna_test.go
@@ -0,0 +1,44 @@
+package openai
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/dto"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConvertOpenAIResponsesRequestPreservesExactGeminiHighModel(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gemini-3.7-flash-high",
+ ReasoningEffort: "low",
+ }
+ request := dto.OpenAIResponsesRequest{
+ Model: codexLunaUpstreamModel,
+ Reasoning: &dto.Reasoning{
+ Effort: "low",
+ Summary: "detailed",
+ },
+ }
+
+ converted, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, request)
+ require.NoError(t, err)
+ actual, ok := converted.(dto.OpenAIResponsesRequest)
+ require.True(t, ok)
+ require.Equal(t, codexLunaUpstreamModel, actual.Model)
+ require.Nil(t, actual.Reasoning)
+ require.Empty(t, info.ReasoningEffort)
+}
+
+func TestConvertOpenAIResponsesRequestStillParsesOtherEffortSuffixes(t *testing.T) {
+ info := &relaycommon.RelayInfo{OriginModelName: "gemini-3.7-flash-low"}
+ request := dto.OpenAIResponsesRequest{Model: "gemini-3.7-flash-low"}
+
+ converted, err := (&Adaptor{}).ConvertOpenAIResponsesRequest(nil, info, request)
+ require.NoError(t, err)
+ actual, ok := converted.(dto.OpenAIResponsesRequest)
+ require.True(t, ok)
+ require.Equal(t, "gemini-3.7-flash", actual.Model)
+ require.NotNil(t, actual.Reasoning)
+ require.Equal(t, "low", actual.Reasoning.Effort)
+}
diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go
index 697cc96..299247f 100644
--- a/relay/channel/openai/relay_responses.go
+++ b/relay/channel/openai/relay_responses.go
@@ -84,6 +84,10 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
var responseTextBuilder strings.Builder
imageCounter := &relaycommon.ImageGenerationCallCounter{}
imageCommitted := false
+ var codexCompat *codexResponsesStreamState
+ if info.OriginModelName == codexLunaOriginModel {
+ codexCompat = newCodexResponsesStreamState()
+ }
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
@@ -94,7 +98,34 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
sr.Error(err)
return
}
- sendResponsesStreamData(c, streamResponse, data)
+ if codexCompat != nil {
+ before, normalized, after, err := codexCompat.normalize(&streamResponse, data)
+ if err != nil {
+ logger.LogError(c, "failed to normalize Codex responses stream: "+err.Error())
+ sr.Error(err)
+ return
+ }
+ for _, injected := range before {
+ injectedData, err := marshalCodexStreamEvent(injected)
+ if err != nil {
+ sr.Error(err)
+ return
+ }
+ sendResponsesStreamData(c, injected, injectedData)
+ }
+ data = normalized
+ sendResponsesStreamData(c, streamResponse, data)
+ for _, injected := range after {
+ injectedData, err := marshalCodexStreamEvent(injected)
+ if err != nil {
+ sr.Error(err)
+ return
+ }
+ sendResponsesStreamData(c, injected, injectedData)
+ }
+ } else {
+ sendResponsesStreamData(c, streamResponse, data)
+ }
switch streamResponse.Type {
case "response.completed", "response.done":
if streamResponse.Response != nil {
diff --git a/relay/channel/openai/responses_codex_compat.go b/relay/channel/openai/responses_codex_compat.go
new file mode 100644
index 0000000..c7df595
--- /dev/null
+++ b/relay/channel/openai/responses_codex_compat.go
@@ -0,0 +1,254 @@
+package openai
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+const (
+ codexLunaOriginModel = "gpt-5.6-luna"
+ codexLunaUpstreamModel = "gemini-3.7-flash-high"
+)
+
+type codexResponsesStreamState struct {
+ nextOutputIndex int
+ outputIndexByID map[string]int
+ contentStartedBy map[string]bool
+ contentDoneBy map[string]bool
+ textByID map[string]*strings.Builder
+ lastMessageID string
+}
+
+func newCodexResponsesStreamState() *codexResponsesStreamState {
+ return &codexResponsesStreamState{
+ outputIndexByID: make(map[string]int),
+ contentStartedBy: make(map[string]bool),
+ contentDoneBy: make(map[string]bool),
+ textByID: make(map[string]*strings.Builder),
+ }
+}
+
+func (s *codexResponsesStreamState) normalize(
+ event *dto.ResponsesStreamResponse,
+ data string,
+) (before []dto.ResponsesStreamResponse, normalized string, after []dto.ResponsesStreamResponse, err error) {
+ if s == nil || event == nil || data == "" {
+ return nil, data, nil, nil
+ }
+ if event.Response != nil {
+ event.Response.Model = codexLunaUpstreamModel
+ data, err = sjson.Set(data, "response.model", codexLunaUpstreamModel)
+ if err != nil {
+ return nil, "", nil, fmt.Errorf("set responses model: %w", err)
+ }
+ }
+
+ itemID := event.ItemID
+ if itemID == "" && event.Item != nil {
+ itemID = event.Item.ID
+ }
+
+ outputIndex := event.OutputIndex
+ if event.Type == dto.ResponsesOutputTypeItemAdded && itemID != "" {
+ if outputIndex == nil {
+ idx := s.nextOutputIndex
+ s.nextOutputIndex++
+ outputIndex = codexIntPtr(idx)
+ } else if *outputIndex >= s.nextOutputIndex {
+ s.nextOutputIndex = *outputIndex + 1
+ }
+ s.outputIndexByID[itemID] = *outputIndex
+ if event.Item != nil && event.Item.Type == "message" {
+ s.lastMessageID = itemID
+ }
+ }
+ if outputIndex == nil && itemID != "" {
+ if idx, ok := s.outputIndexByID[itemID]; ok {
+ outputIndex = codexIntPtr(idx)
+ }
+ }
+ if outputIndex != nil && event.OutputIndex == nil {
+ event.OutputIndex = outputIndex
+ data, err = sjson.Set(data, "output_index", *outputIndex)
+ if err != nil {
+ return nil, "", nil, fmt.Errorf("set responses output_index: %w", err)
+ }
+ }
+
+ if event.Type == dto.ResponsesOutputTypeItemAdded && event.Item != nil && event.Item.Type == "message" {
+ if !gjson.Get(data, "item.content").Exists() {
+ data, err = sjson.SetRaw(data, "item.content", "[]")
+ if err != nil {
+ return nil, "", nil, fmt.Errorf("set responses message content: %w", err)
+ }
+ }
+ }
+
+ switch event.Type {
+ case "response.content_part.added":
+ s.contentStartedBy[itemID] = true
+ case "response.content_part.done":
+ s.contentDoneBy[itemID] = true
+ case "response.output_text.delta":
+ data, err = ensureCodexContentIndex(event, data)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ if !s.contentStartedBy[itemID] {
+ before = append(before, codexContentPartEvent("response.content_part.added", itemID, outputIndex, ""))
+ s.contentStartedBy[itemID] = true
+ }
+ builder := s.textByID[itemID]
+ if builder == nil {
+ builder = &strings.Builder{}
+ s.textByID[itemID] = builder
+ }
+ builder.WriteString(event.Delta)
+ case "response.output_text.done":
+ data, err = ensureCodexContentIndex(event, data)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ if !s.contentStartedBy[itemID] {
+ before = append(before, codexContentPartEvent("response.content_part.added", itemID, outputIndex, ""))
+ s.contentStartedBy[itemID] = true
+ }
+ text := gjson.Get(data, "text").String()
+ if builder := s.textByID[itemID]; builder != nil && text == "" {
+ text = builder.String()
+ data, err = sjson.Set(data, "text", text)
+ if err != nil {
+ return nil, "", nil, fmt.Errorf("set responses output text: %w", err)
+ }
+ }
+ if !s.contentDoneBy[itemID] {
+ after = append(after, codexContentPartEvent("response.content_part.done", itemID, outputIndex, text))
+ s.contentDoneBy[itemID] = true
+ }
+ case dto.ResponsesOutputTypeItemDone:
+ if s.contentStartedBy[itemID] && !s.contentDoneBy[itemID] {
+ text := ""
+ if builder := s.textByID[itemID]; builder != nil {
+ text = builder.String()
+ }
+ before = append(before, codexContentPartEvent("response.content_part.done", itemID, outputIndex, text))
+ s.contentDoneBy[itemID] = true
+ }
+ if event.Item != nil && event.Item.Type == "message" {
+ text := ""
+ if builder := s.textByID[itemID]; builder != nil {
+ text = builder.String()
+ }
+ data, err = ensureCodexMessageOutputItem(data, text)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ }
+ case "response.completed", "response.done":
+ if event.Response != nil && len(event.Response.Output) == 0 {
+ if itemID == "" {
+ itemID = s.lastMessageID
+ }
+ if itemID == "" {
+ break
+ }
+ text := ""
+ if builder := s.textByID[itemID]; builder != nil {
+ text = builder.String()
+ }
+ data, err = ensureCodexCompletedOutput(data, itemID, text)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ }
+ }
+
+ return before, data, after, nil
+}
+
+func ensureCodexContentIndex(event *dto.ResponsesStreamResponse, data string) (string, error) {
+ if event.ContentIndex != nil {
+ return data, nil
+ }
+ event.ContentIndex = codexIntPtr(0)
+ updated, err := sjson.Set(data, "content_index", 0)
+ if err != nil {
+ return "", fmt.Errorf("set responses content_index: %w", err)
+ }
+ return updated, nil
+}
+
+func ensureCodexMessageOutputItem(data, text string) (string, error) {
+ if gjson.Get(data, "item.content").Exists() && gjson.Get(data, "item.content").IsArray() && len(gjson.Get(data, "item.content").Array()) > 0 {
+ return data, nil
+ }
+ data, err := sjson.Set(data, "item.role", "assistant")
+ if err != nil {
+ return "", fmt.Errorf("set responses output item role: %w", err)
+ }
+ content := []dto.ResponsesOutputContent{{Type: "output_text", Text: text, Annotations: []interface{}{}}}
+ contentData, err := common.Marshal(content)
+ if err != nil {
+ return "", fmt.Errorf("marshal responses output item content: %w", err)
+ }
+ data, err = sjson.SetRaw(data, "item.content", string(contentData))
+ if err != nil {
+ return "", fmt.Errorf("set responses output item content: %w", err)
+ }
+ return data, nil
+}
+
+func ensureCodexCompletedOutput(data, itemID, text string) (string, error) {
+ output := []dto.ResponsesOutput{{
+ Type: "message",
+ ID: itemID,
+ Status: "completed",
+ Role: "assistant",
+ Content: []dto.ResponsesOutputContent{{
+ Type: "output_text",
+ Text: text,
+ Annotations: []interface{}{},
+ }},
+ }}
+ outputData, err := common.Marshal(output)
+ if err != nil {
+ return "", fmt.Errorf("marshal responses output: %w", err)
+ }
+ updated, err := sjson.SetRaw(data, "response.output", string(outputData))
+ if err != nil {
+ return "", fmt.Errorf("set responses output: %w", err)
+ }
+ return updated, nil
+}
+
+func codexContentPartEvent(eventType, itemID string, outputIndex *int, text string) dto.ResponsesStreamResponse {
+ return dto.ResponsesStreamResponse{
+ Type: eventType,
+ OutputIndex: outputIndex,
+ ContentIndex: codexIntPtr(0),
+ ItemID: itemID,
+ Part: &dto.ResponsesReasoningSummaryPart{
+ Type: "output_text",
+ Text: text,
+ Annotations: []interface{}{},
+ Logprobs: []interface{}{},
+ },
+ }
+}
+
+func marshalCodexStreamEvent(event dto.ResponsesStreamResponse) (string, error) {
+ data, err := common.Marshal(event)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func codexIntPtr(value int) *int {
+ return &value
+}
diff --git a/relay/channel/openai/responses_codex_compat_test.go b/relay/channel/openai/responses_codex_compat_test.go
new file mode 100644
index 0000000..5944b97
--- /dev/null
+++ b/relay/channel/openai/responses_codex_compat_test.go
@@ -0,0 +1,90 @@
+package openai
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func TestCodexResponsesStreamStateAddsIndexesAndContentPartEvents(t *testing.T) {
+ state := newCodexResponsesStreamState()
+
+ added := dto.ResponsesStreamResponse{
+ Type: "response.output_item.added",
+ Item: &dto.ResponsesOutput{ID: "item_1", Type: "message", Role: "assistant", Status: "in_progress"},
+ }
+ before, addedData, after, err := state.normalize(&added, `{"type":"response.output_item.added","item":{"id":"item_1","type":"message","role":"assistant","status":"in_progress"},"sequence_number":1}`)
+ require.NoError(t, err)
+ assert.Empty(t, before)
+ assert.Empty(t, after)
+ assert.Equal(t, int64(0), gjson.Get(addedData, "output_index").Int())
+ assert.True(t, gjson.Get(addedData, "item.content").IsArray())
+ assert.Equal(t, int64(1), gjson.Get(addedData, "sequence_number").Int())
+
+ delta := dto.ResponsesStreamResponse{Type: "response.output_text.delta", ItemID: "item_1", Delta: "OK"}
+ before, deltaData, after, err := state.normalize(&delta, `{"type":"response.output_text.delta","item_id":"item_1","delta":"OK","sequence_number":2}`)
+ require.NoError(t, err)
+ require.Len(t, before, 1)
+ assert.Equal(t, "response.content_part.added", before[0].Type)
+ assert.Empty(t, after)
+ assert.Equal(t, int64(0), gjson.Get(deltaData, "output_index").Int())
+ assert.Equal(t, int64(0), gjson.Get(deltaData, "content_index").Int())
+
+ done := dto.ResponsesStreamResponse{Type: "response.output_text.done", ItemID: "item_1"}
+ before, doneData, after, err := state.normalize(&done, `{"type":"response.output_text.done","item_id":"item_1","sequence_number":3}`)
+ require.NoError(t, err)
+ assert.Empty(t, before)
+ require.Len(t, after, 1)
+ assert.Equal(t, "response.content_part.done", after[0].Type)
+ assert.Equal(t, "OK", after[0].Part.Text)
+ assert.Equal(t, "OK", gjson.Get(doneData, "text").String())
+}
+
+func TestMarshalCodexStreamEventIncludesContentPartPayload(t *testing.T) {
+ data, err := marshalCodexStreamEvent(codexContentPartEvent("response.content_part.added", "item_1", codexIntPtr(0), ""))
+ require.NoError(t, err)
+
+ var event dto.ResponsesStreamResponse
+ require.NoError(t, common.Unmarshal([]byte(data), &event))
+ assert.Equal(t, "response.content_part.added", event.Type)
+ assert.Equal(t, "output_text", event.Part.Type)
+ assert.Equal(t, 0, *event.OutputIndex)
+ assert.Equal(t, 0, *event.ContentIndex)
+}
+
+func TestCodexResponsesStreamStateCompletesMessageOutput(t *testing.T) {
+ state := newCodexResponsesStreamState()
+
+ added := dto.ResponsesStreamResponse{
+ Type: "response.output_item.added",
+ Item: &dto.ResponsesOutput{ID: "item_1", Type: "message", Role: "assistant"},
+ }
+ _, _, _, err := state.normalize(&added, `{"type":"response.output_item.added","item":{"id":"item_1","type":"message"}}`)
+ require.NoError(t, err)
+
+ delta := dto.ResponsesStreamResponse{Type: "response.output_text.delta", ItemID: "item_1", Delta: "OK"}
+ _, _, _, err = state.normalize(&delta, `{"type":"response.output_text.delta","item_id":"item_1","delta":"OK"}`)
+ require.NoError(t, err)
+
+ done := dto.ResponsesStreamResponse{
+ Type: dto.ResponsesOutputTypeItemDone,
+ ItemID: "item_1",
+ Item: &dto.ResponsesOutput{ID: "item_1", Type: "message", Status: "completed"},
+ }
+ _, doneData, _, err := state.normalize(&done, `{"type":"response.output_item.done","item_id":"item_1","item":{"id":"item_1","type":"message","status":"completed"}}`)
+ require.NoError(t, err)
+ assert.Equal(t, "OK", gjson.Get(doneData, "item.content.0.text").String())
+
+ completed := dto.ResponsesStreamResponse{
+ Type: "response.completed",
+ Response: &dto.OpenAIResponsesResponse{Model: "gemini-3.7-flash", Output: []dto.ResponsesOutput{}},
+ }
+ _, completedData, _, err := state.normalize(&completed, `{"type":"response.completed","response":{"model":"gemini-3.7-flash","output":[]}}`)
+ require.NoError(t, err)
+ assert.Equal(t, codexLunaUpstreamModel, gjson.Get(completedData, "response.model").String())
+ assert.Equal(t, "OK", gjson.Get(completedData, "response.output.0.content.0.text").String())
+}
diff --git a/relay/responses_handler.go b/relay/responses_handler.go
index 5fa23d0..9c74145 100644
--- a/relay/responses_handler.go
+++ b/relay/responses_handler.go
@@ -20,6 +20,25 @@ import (
"github.com/gin-gonic/gin"
)
+const (
+ codexLunaModel = "gpt-5.6-luna"
+ codexLunaUpstreamModel = "gemini-3.7-flash-high"
+)
+
+func applyCodexLunaCompatibility(info *relaycommon.RelayInfo, request *dto.OpenAIResponsesRequest) {
+ if info == nil || request == nil || info.OriginModelName != codexLunaModel {
+ return
+ }
+
+ // Luna is a stable Codex-facing alias for an upstream model whose name
+ // already selects the desired thinking level. Do not reinterpret Codex's
+ // reasoning fields or strip the upstream model's -high suffix.
+ info.UpstreamModelName = codexLunaUpstreamModel
+ info.ReasoningEffort = ""
+ request.Model = codexLunaUpstreamModel
+ request.Reasoning = nil
+}
+
func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
info.InitChannelMeta(c)
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
@@ -74,6 +93,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
+ applyCodexLunaCompatibility(info, request)
adaptor := GetAdaptor(info.ApiType)
if adaptor == nil {
diff --git a/relay/responses_luna_compat_test.go b/relay/responses_luna_compat_test.go
new file mode 100644
index 0000000..a6f4185
--- /dev/null
+++ b/relay/responses_luna_compat_test.go
@@ -0,0 +1,52 @@
+package relay
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/dto"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestApplyCodexLunaCompatibilityPinsUpstreamAndIgnoresReasoning(t *testing.T) {
+ info := &relaycommon.RelayInfo{
+ OriginModelName: codexLunaModel,
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "gemini-3.7-flash-low",
+ },
+ ReasoningEffort: "max",
+ }
+ request := &dto.OpenAIResponsesRequest{
+ Model: "gemini-3.7-flash-low",
+ Reasoning: &dto.Reasoning{
+ Effort: "max",
+ Summary: "auto",
+ },
+ }
+
+ applyCodexLunaCompatibility(info, request)
+
+ assert.Equal(t, codexLunaUpstreamModel, info.UpstreamModelName)
+ assert.Empty(t, info.ReasoningEffort)
+ assert.Equal(t, codexLunaUpstreamModel, request.Model)
+ require.Nil(t, request.Reasoning)
+}
+
+func TestApplyCodexLunaCompatibilityDoesNotAffectOtherModels(t *testing.T) {
+ reasoning := &dto.Reasoning{Effort: "high", Summary: "auto"}
+ info := &relaycommon.RelayInfo{
+ OriginModelName: "gpt-5.6-sol",
+ ChannelMeta: &relaycommon.ChannelMeta{
+ UpstreamModelName: "gpt-5.6-sol",
+ },
+ ReasoningEffort: "high",
+ }
+ request := &dto.OpenAIResponsesRequest{Model: "gpt-5.6-sol", Reasoning: reasoning}
+
+ applyCodexLunaCompatibility(info, request)
+
+ assert.Equal(t, "gpt-5.6-sol", info.UpstreamModelName)
+ assert.Equal(t, "high", info.ReasoningEffort)
+ assert.Same(t, reasoning, request.Reasoning)
+}
diff --git a/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go b/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go
index b3a15ad..31002b3 100644
--- a/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go
+++ b/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go
@@ -17,6 +17,8 @@ const (
responsesEventCreated = "response.created"
responsesEventCompleted = "response.completed"
responsesEventIncomplete = "response.incomplete"
+ responsesEventContentPartAdded = "response.content_part.added"
+ responsesEventContentPartDone = "response.content_part.done"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventOutputItemDone = "response.output_item.done"
diff --git a/service/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go b/service/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go
index a5034a0..6872def 100644
--- a/service/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go
+++ b/service/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go
@@ -118,18 +118,23 @@ func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *tes
})...)
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
- require.Len(t, events, 10)
+ require.Len(t, events, 12)
assert.Equal(t, responsesEventCreated, events[0].Type)
- assert.Equal(t, responsesEventOutputTextDelta, events[2].Type)
- assert.Equal(t, "hello", events[2].Payload.Delta)
- assert.Equal(t, responsesEventFunctionArgsDelta, events[4].Type)
- assert.Equal(t, `{"q":"x"}`, events[4].Payload.Delta)
- assert.Equal(t, responsesEventCompleted, events[9].Type)
- require.NotNil(t, events[9].Payload.Response)
- assert.Equal(t, 6, events[9].Payload.Response.Usage.TotalTokens)
- require.Len(t, events[9].Payload.Response.Output, 2)
- assert.Equal(t, "hello", events[9].Payload.Response.Output[0].Content[0].Text)
- assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
+ assert.Equal(t, responsesEventOutputItemAdded, events[1].Type)
+ assert.Equal(t, responsesEventContentPartAdded, events[2].Type)
+ assert.Equal(t, responsesEventOutputTextDelta, events[3].Type)
+ assert.Equal(t, "hello", events[3].Payload.Delta)
+ assert.Equal(t, responsesEventFunctionArgsDelta, events[5].Type)
+ assert.Equal(t, `{"q":"x"}`, events[5].Payload.Delta)
+ assert.Equal(t, "response.output_text.done", events[6].Type)
+ assert.Equal(t, responsesEventContentPartDone, events[7].Type)
+ assert.Equal(t, "hello", events[7].Payload.Part.Text)
+ assert.Equal(t, responsesEventCompleted, events[11].Type)
+ require.NotNil(t, events[11].Payload.Response)
+ assert.Equal(t, 6, events[11].Payload.Response.Usage.TotalTokens)
+ require.Len(t, events[11].Payload.Response.Output, 2)
+ assert.Equal(t, "hello", events[11].Payload.Response.Output[0].Content[0].Text)
+ assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[11].Payload.Response.Output[1].Arguments))
}
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
diff --git a/service/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go b/service/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
index 75c602d..4c3ee45 100644
--- a/service/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
+++ b/service/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
@@ -152,6 +152,18 @@ func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToRespo
Content: []dto.ResponsesOutputContent{},
},
}))
+ events = append(events, responsesStreamEvent(responsesEventContentPartAdded, dto.ResponsesStreamResponse{
+ Type: responsesEventContentPartAdded,
+ OutputIndex: intPtr(s.textOutputIndex),
+ ContentIndex: intPtr(0),
+ ItemID: s.messageID(),
+ Part: &dto.ResponsesReasoningSummaryPart{
+ Type: "output_text",
+ Text: "",
+ Annotations: []interface{}{},
+ Logprobs: []interface{}{},
+ },
+ }))
}
s.text.WriteString(delta)
events = append(events, responsesStreamEvent(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
@@ -251,6 +263,20 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
ItemID: s.messageID(),
+ Text: s.text.String(),
+ Logprobs: []interface{}{},
+ }))
+ events = append(events, responsesStreamEvent(responsesEventContentPartDone, dto.ResponsesStreamResponse{
+ Type: responsesEventContentPartDone,
+ OutputIndex: intPtr(s.textOutputIndex),
+ ContentIndex: intPtr(0),
+ ItemID: s.messageID(),
+ Part: &dto.ResponsesReasoningSummaryPart{
+ Type: "output_text",
+ Text: s.text.String(),
+ Annotations: []interface{}{},
+ Logprobs: []interface{}{},
+ },
}))
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,