Download codex-luna-compat.patch from agagsder/new-api: direct link, hf CLI and curl.
- Browser
- Download file 28.8 kB
-
https://huggingface.co/spaces/agagsder/new-api/resolve/main/codex-luna-compat.patch
- Command line
-
hf download hf://spaces/agagsder/new-api/codex-luna-compat.patch
-
curl -L -o codex-luna-compat.patch https://huggingface.co/spaces/agagsder/new-api/resolve/main/codex-luna-compat.patch
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 | |
| 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 ( | |
| 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 | |
| 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 | |
| +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 | |
| 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 | |
| +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 | |
| 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) { | |
| 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 | |
| +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 | |
| +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 | |
| 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 { | |
| 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 | |
| +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 | |
| 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 | |
| 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 | |
| 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{ | |
| 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, | |