Codex commited on
Commit
61ed9bf
·
1 Parent(s): 7cd5cb8

Fix Codex Luna compatibility

Browse files
Files changed (4) hide show
  1. Dockerfile +11 -5
  2. README.md +6 -0
  3. codex-image-b64.patch +19 -7
  4. codex-luna-compat.patch +508 -0
Dockerfile CHANGED
@@ -4,8 +4,11 @@ RUN apk add --no-cache git
4
  RUN git clone --depth 1 --branch v1.0.0-rc.22 https://github.com/QuantumNous/new-api.git /src
5
  COPY image_url_compat.go image_url_compat_test.go image_task_compat.go image_task_compat_test.go /src/relay/channel/openai/
6
  COPY codex-image-b64.patch /tmp/codex-image-b64.patch
7
- RUN git -C /src apply --unidiff-zero --check /tmp/codex-image-b64.patch \
8
- && git -C /src apply --unidiff-zero /tmp/codex-image-b64.patch
 
 
 
9
 
10
  FROM oven/bun:1 AS web-builder
11
 
@@ -14,7 +17,7 @@ COPY --from=source /src/web/package.json /src/web/bun.lock ./
14
  RUN bun install --frozen-lockfile
15
  COPY --from=source /src/web ./
16
  COPY --from=source /src/VERSION /build/VERSION
17
- RUN DISABLE_ESLINT_PLUGIN=true VITE_REACT_APP_VERSION=v1.0.0-rc.22 bun run build
18
 
19
  FROM golang:1.26.1-alpine AS go-builder
20
 
@@ -25,7 +28,10 @@ RUN go mod download
25
  COPY --from=source /src ./
26
  COPY --from=web-builder /build/web/dist ./web/dist
27
  RUN go test ./relay/channel/openai \
28
- && go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=v1.0.0-rc.22-codex-image-compat'" -o new-api
 
 
 
29
 
30
  FROM debian:bookworm-slim
31
 
@@ -39,7 +45,7 @@ RUN apt-get update \
39
  COPY --from=go-builder /build/new-api /new-api
40
  COPY --from=source /src/LICENSE /src/NOTICE /src/THIRD-PARTY-LICENSES.md /licenses/
41
  ENV PORT=7860 \
42
- VERSION=v1.0.0-rc.22-codex-image-compat \
43
  CODEX_IMAGE_URL_TO_B64=true \
44
  CODEX_IMAGE_TASK_POLL=true \
45
  CODEX_IMAGE_TASK_POLL_TIMEOUT_SECONDS=120
 
4
  RUN git clone --depth 1 --branch v1.0.0-rc.22 https://github.com/QuantumNous/new-api.git /src
5
  COPY image_url_compat.go image_url_compat_test.go image_task_compat.go image_task_compat_test.go /src/relay/channel/openai/
6
  COPY codex-image-b64.patch /tmp/codex-image-b64.patch
7
+ COPY codex-luna-compat.patch /tmp/codex-luna-compat.patch
8
+ RUN git -C /src apply --check /tmp/codex-image-b64.patch \
9
+ && git -C /src apply /tmp/codex-image-b64.patch \
10
+ && git -C /src apply --check /tmp/codex-luna-compat.patch \
11
+ && git -C /src apply /tmp/codex-luna-compat.patch
12
 
13
  FROM oven/bun:1 AS web-builder
14
 
 
17
  RUN bun install --frozen-lockfile
18
  COPY --from=source /src/web ./
19
  COPY --from=source /src/VERSION /build/VERSION
20
+ RUN DISABLE_ESLINT_PLUGIN=true VITE_REACT_APP_VERSION=v1.0.0-rc.22-codex-luna-compat bun run build
21
 
22
  FROM golang:1.26.1-alpine AS go-builder
23
 
 
28
  COPY --from=source /src ./
29
  COPY --from=web-builder /build/web/dist ./web/dist
30
  RUN go test ./relay/channel/openai \
31
+ ./relay \
32
+ ./relay/channel/gemini \
33
+ ./service/relayconvert/internal/oai_chat \
34
+ && go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=v1.0.0-rc.22-codex-luna-compat'" -o new-api
35
 
36
  FROM debian:bookworm-slim
37
 
 
45
  COPY --from=go-builder /build/new-api /new-api
46
  COPY --from=source /src/LICENSE /src/NOTICE /src/THIRD-PARTY-LICENSES.md /licenses/
47
  ENV PORT=7860 \
48
+ VERSION=v1.0.0-rc.22-codex-luna-compat \
49
  CODEX_IMAGE_URL_TO_B64=true \
50
  CODEX_IMAGE_TASK_POLL=true \
51
  CODEX_IMAGE_TASK_POLL_TIMEOUT_SECONDS=120
README.md CHANGED
@@ -15,3 +15,9 @@ This Space includes Codex image compatibility by default:
15
  - asynchronous `202 image.task` responses are polled for up to 120 seconds;
16
  - final image URLs are converted to `b64_json`;
17
  - transient image download errors and upstream 5xx responses are retried up to three times without resubmitting the generation task.
 
 
 
 
 
 
 
15
  - asynchronous `202 image.task` responses are polled for up to 120 seconds;
16
  - final image URLs are converted to `b64_json`;
17
  - transient image download errors and upstream 5xx responses are retried up to three times without resubmitting the generation task.
18
+
19
+ It also includes a Codex Luna compatibility layer:
20
+
21
+ - `gpt-5.6-luna` is always routed to `gemini-3.7-flash-high`;
22
+ - Codex reasoning effort and summary fields are ignored for the Luna alias;
23
+ - incomplete Responses SSE streams are normalized with output/content indexes and content-part lifecycle events required by Codex.
codex-image-b64.patch CHANGED
@@ -1,13 +1,16 @@
1
  diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go
2
- index 4f1c4286..c05ef093 100644
3
  --- a/relay/channel/openai/adaptor.go
4
  +++ b/relay/channel/openai/adaptor.go
5
- @@ -624,3 +624 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
 
 
 
6
  - if info.RelayMode == relayconstant.RelayModeAudioTranscription ||
7
  - info.RelayMode == relayconstant.RelayModeAudioTranslation ||
8
  - (info.RelayMode == relayconstant.RelayModeImagesEdits && !isJSONRequest(c)) {
9
  + if info.RelayMode == relayconstant.RelayModeAudioTranscription || info.RelayMode == relayconstant.RelayModeAudioTranslation {
10
- @@ -628 +626,9 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
11
  - } else if info.RelayMode == relayconstant.RelayModeRealtime {
12
  + }
13
  + if info.RelayMode == relayconstant.RelayModeImagesEdits && !isJSONRequest(c) {
@@ -18,10 +21,10 @@ index 4f1c4286..c05ef093 100644
18
  + return resolveOpenAIImageTask(c, info, response)
19
  + }
20
  + if info.RelayMode == relayconstant.RelayModeRealtime {
21
- @@ -630,2 +635,0 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
22
  - } else {
23
  - return channel.DoApiRequest(a, c, info, requestBody)
24
- @@ -632,0 +637,9 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
25
  +
26
  + response, err := channel.DoApiRequest(a, c, info, requestBody)
27
  + if err != nil {
@@ -31,11 +34,17 @@ index 4f1c4286..c05ef093 100644
31
  + return resolveOpenAIImageTask(c, info, response)
32
  + }
33
  + return response, nil
 
 
 
34
  diff --git a/relay/channel/openai/relay_image.go b/relay/channel/openai/relay_image.go
35
- index 1e6be0dd..240e6eca 100644
36
  --- a/relay/channel/openai/relay_image.go
37
  +++ b/relay/channel/openai/relay_image.go
38
- @@ -41,0 +42,10 @@ func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
 
 
 
39
  + responseBody, compatStats, err := convertOpenAIImageURLsToBase64(responseBody, downloadOpenAIImageURLCompat)
40
  + if err != nil {
41
  + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusBadGateway)
@@ -46,3 +55,6 @@ index 1e6be0dd..240e6eca 100644
46
  + logger.LogInfo(c, compatStats.LogMessage())
47
  + }
48
  +
 
 
 
 
1
  diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go
2
+ index 3fd2466..9400aa0 100644
3
  --- a/relay/channel/openai/adaptor.go
4
  +++ b/relay/channel/openai/adaptor.go
5
+ @@ -621,15 +621,28 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
6
+ }
7
+
8
+ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
9
  - if info.RelayMode == relayconstant.RelayModeAudioTranscription ||
10
  - info.RelayMode == relayconstant.RelayModeAudioTranslation ||
11
  - (info.RelayMode == relayconstant.RelayModeImagesEdits && !isJSONRequest(c)) {
12
  + if info.RelayMode == relayconstant.RelayModeAudioTranscription || info.RelayMode == relayconstant.RelayModeAudioTranslation {
13
+ return channel.DoFormRequest(a, c, info, requestBody)
14
  - } else if info.RelayMode == relayconstant.RelayModeRealtime {
15
  + }
16
  + if info.RelayMode == relayconstant.RelayModeImagesEdits && !isJSONRequest(c) {
 
21
  + return resolveOpenAIImageTask(c, info, response)
22
  + }
23
  + if info.RelayMode == relayconstant.RelayModeRealtime {
24
+ return channel.DoWssRequest(a, c, info, requestBody)
25
  - } else {
26
  - return channel.DoApiRequest(a, c, info, requestBody)
27
+ }
28
  +
29
  + response, err := channel.DoApiRequest(a, c, info, requestBody)
30
  + if err != nil {
 
34
  + return resolveOpenAIImageTask(c, info, response)
35
  + }
36
  + return response, nil
37
+ }
38
+
39
+ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
40
  diff --git a/relay/channel/openai/relay_image.go b/relay/channel/openai/relay_image.go
41
+ index e0f09aa..f9372d7 100644
42
  --- a/relay/channel/openai/relay_image.go
43
  +++ b/relay/channel/openai/relay_image.go
44
+ @@ -39,6 +39,16 @@ func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
45
+ return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
46
+ }
47
+
48
  + responseBody, compatStats, err := convertOpenAIImageURLsToBase64(responseBody, downloadOpenAIImageURLCompat)
49
  + if err != nil {
50
  + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusBadGateway)
 
55
  + logger.LogInfo(c, compatStats.LogMessage())
56
  + }
57
  +
58
+ var usageResp dto.SimpleResponse
59
+ err = common.Unmarshal(responseBody, &usageResp)
60
+ if err != nil {
codex-luna-compat.patch ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diff --git a/dto/openai_response.go b/dto/openai_response.go
2
+ index 7959f07..a494a7f 100644
3
+ --- a/dto/openai_response.go
4
+ +++ b/dto/openai_response.go
5
+ @@ -358,8 +358,10 @@ type ResponsesOutputContent struct {
6
+ }
7
+
8
+ type ResponsesReasoningSummaryPart struct {
9
+ - Type string `json:"type"`
10
+ - Text string `json:"text"`
11
+ + Type string `json:"type"`
12
+ + Text string `json:"text"`
13
+ + Annotations []interface{} `json:"annotations,omitempty"`
14
+ + Logprobs []interface{} `json:"logprobs,omitempty"`
15
+ }
16
+
17
+ const (
18
+ @@ -387,6 +389,8 @@ type ResponsesStreamResponse struct {
19
+ Type string `json:"type"`
20
+ Response *OpenAIResponsesResponse `json:"response,omitempty"`
21
+ Delta string `json:"delta,omitempty"`
22
+ + Text string `json:"text,omitempty"`
23
+ + Logprobs []interface{} `json:"logprobs,omitempty"`
24
+ Item *ResponsesOutput `json:"item,omitempty"`
25
+ // - response.function_call_arguments.delta
26
+ // - response.function_call_arguments.done
27
+ diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go
28
+ index 697cc96..299247f 100644
29
+ --- a/relay/channel/openai/relay_responses.go
30
+ +++ b/relay/channel/openai/relay_responses.go
31
+ @@ -84,6 +84,10 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
32
+ var responseTextBuilder strings.Builder
33
+ imageCounter := &relaycommon.ImageGenerationCallCounter{}
34
+ imageCommitted := false
35
+ + var codexCompat *codexResponsesStreamState
36
+ + if info.OriginModelName == codexLunaOriginModel {
37
+ + codexCompat = newCodexResponsesStreamState()
38
+ + }
39
+
40
+ helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
41
+
42
+ @@ -94,7 +98,34 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
43
+ sr.Error(err)
44
+ return
45
+ }
46
+ - sendResponsesStreamData(c, streamResponse, data)
47
+ + if codexCompat != nil {
48
+ + before, normalized, after, err := codexCompat.normalize(&streamResponse, data)
49
+ + if err != nil {
50
+ + logger.LogError(c, "failed to normalize Codex responses stream: "+err.Error())
51
+ + sr.Error(err)
52
+ + return
53
+ + }
54
+ + for _, injected := range before {
55
+ + injectedData, err := marshalCodexStreamEvent(injected)
56
+ + if err != nil {
57
+ + sr.Error(err)
58
+ + return
59
+ + }
60
+ + sendResponsesStreamData(c, injected, injectedData)
61
+ + }
62
+ + data = normalized
63
+ + sendResponsesStreamData(c, streamResponse, data)
64
+ + for _, injected := range after {
65
+ + injectedData, err := marshalCodexStreamEvent(injected)
66
+ + if err != nil {
67
+ + sr.Error(err)
68
+ + return
69
+ + }
70
+ + sendResponsesStreamData(c, injected, injectedData)
71
+ + }
72
+ + } else {
73
+ + sendResponsesStreamData(c, streamResponse, data)
74
+ + }
75
+ switch streamResponse.Type {
76
+ case "response.completed", "response.done":
77
+ if streamResponse.Response != nil {
78
+ diff --git a/relay/channel/openai/responses_codex_compat.go b/relay/channel/openai/responses_codex_compat.go
79
+ new file mode 100644
80
+ index 0000000..cb18b81
81
+ --- /dev/null
82
+ +++ b/relay/channel/openai/responses_codex_compat.go
83
+ @@ -0,0 +1,170 @@
84
+ +package openai
85
+ +
86
+ +import (
87
+ + "fmt"
88
+ + "strings"
89
+ +
90
+ + "github.com/QuantumNous/new-api/common"
91
+ + "github.com/QuantumNous/new-api/dto"
92
+ + "github.com/tidwall/gjson"
93
+ + "github.com/tidwall/sjson"
94
+ +)
95
+ +
96
+ +const codexLunaOriginModel = "gpt-5.6-luna"
97
+ +
98
+ +type codexResponsesStreamState struct {
99
+ + nextOutputIndex int
100
+ + outputIndexByID map[string]int
101
+ + contentStartedBy map[string]bool
102
+ + contentDoneBy map[string]bool
103
+ + textByID map[string]*strings.Builder
104
+ +}
105
+ +
106
+ +func newCodexResponsesStreamState() *codexResponsesStreamState {
107
+ + return &codexResponsesStreamState{
108
+ + outputIndexByID: make(map[string]int),
109
+ + contentStartedBy: make(map[string]bool),
110
+ + contentDoneBy: make(map[string]bool),
111
+ + textByID: make(map[string]*strings.Builder),
112
+ + }
113
+ +}
114
+ +
115
+ +func (s *codexResponsesStreamState) normalize(
116
+ + event *dto.ResponsesStreamResponse,
117
+ + data string,
118
+ +) (before []dto.ResponsesStreamResponse, normalized string, after []dto.ResponsesStreamResponse, err error) {
119
+ + if s == nil || event == nil || data == "" {
120
+ + return nil, data, nil, nil
121
+ + }
122
+ +
123
+ + itemID := event.ItemID
124
+ + if itemID == "" && event.Item != nil {
125
+ + itemID = event.Item.ID
126
+ + }
127
+ +
128
+ + outputIndex := event.OutputIndex
129
+ + if event.Type == dto.ResponsesOutputTypeItemAdded && itemID != "" {
130
+ + if outputIndex == nil {
131
+ + idx := s.nextOutputIndex
132
+ + s.nextOutputIndex++
133
+ + outputIndex = codexIntPtr(idx)
134
+ + } else if *outputIndex >= s.nextOutputIndex {
135
+ + s.nextOutputIndex = *outputIndex + 1
136
+ + }
137
+ + s.outputIndexByID[itemID] = *outputIndex
138
+ + }
139
+ + if outputIndex == nil && itemID != "" {
140
+ + if idx, ok := s.outputIndexByID[itemID]; ok {
141
+ + outputIndex = codexIntPtr(idx)
142
+ + }
143
+ + }
144
+ + if outputIndex != nil && event.OutputIndex == nil {
145
+ + event.OutputIndex = outputIndex
146
+ + data, err = sjson.Set(data, "output_index", *outputIndex)
147
+ + if err != nil {
148
+ + return nil, "", nil, fmt.Errorf("set responses output_index: %w", err)
149
+ + }
150
+ + }
151
+ +
152
+ + if event.Type == dto.ResponsesOutputTypeItemAdded && event.Item != nil && event.Item.Type == "message" {
153
+ + if !gjson.Get(data, "item.content").Exists() {
154
+ + data, err = sjson.SetRaw(data, "item.content", "[]")
155
+ + if err != nil {
156
+ + return nil, "", nil, fmt.Errorf("set responses message content: %w", err)
157
+ + }
158
+ + }
159
+ + }
160
+ +
161
+ + switch event.Type {
162
+ + case "response.content_part.added":
163
+ + s.contentStartedBy[itemID] = true
164
+ + case "response.content_part.done":
165
+ + s.contentDoneBy[itemID] = true
166
+ + case "response.output_text.delta":
167
+ + data, err = ensureCodexContentIndex(event, data)
168
+ + if err != nil {
169
+ + return nil, "", nil, err
170
+ + }
171
+ + if !s.contentStartedBy[itemID] {
172
+ + before = append(before, codexContentPartEvent("response.content_part.added", itemID, outputIndex, ""))
173
+ + s.contentStartedBy[itemID] = true
174
+ + }
175
+ + builder := s.textByID[itemID]
176
+ + if builder == nil {
177
+ + builder = &strings.Builder{}
178
+ + s.textByID[itemID] = builder
179
+ + }
180
+ + builder.WriteString(event.Delta)
181
+ + case "response.output_text.done":
182
+ + data, err = ensureCodexContentIndex(event, data)
183
+ + if err != nil {
184
+ + return nil, "", nil, err
185
+ + }
186
+ + if !s.contentStartedBy[itemID] {
187
+ + before = append(before, codexContentPartEvent("response.content_part.added", itemID, outputIndex, ""))
188
+ + s.contentStartedBy[itemID] = true
189
+ + }
190
+ + text := gjson.Get(data, "text").String()
191
+ + if builder := s.textByID[itemID]; builder != nil && text == "" {
192
+ + text = builder.String()
193
+ + data, err = sjson.Set(data, "text", text)
194
+ + if err != nil {
195
+ + return nil, "", nil, fmt.Errorf("set responses output text: %w", err)
196
+ + }
197
+ + }
198
+ + if !s.contentDoneBy[itemID] {
199
+ + after = append(after, codexContentPartEvent("response.content_part.done", itemID, outputIndex, text))
200
+ + s.contentDoneBy[itemID] = true
201
+ + }
202
+ + case dto.ResponsesOutputTypeItemDone:
203
+ + if s.contentStartedBy[itemID] && !s.contentDoneBy[itemID] {
204
+ + text := ""
205
+ + if builder := s.textByID[itemID]; builder != nil {
206
+ + text = builder.String()
207
+ + }
208
+ + before = append(before, codexContentPartEvent("response.content_part.done", itemID, outputIndex, text))
209
+ + s.contentDoneBy[itemID] = true
210
+ + }
211
+ + }
212
+ +
213
+ + return before, data, after, nil
214
+ +}
215
+ +
216
+ +func ensureCodexContentIndex(event *dto.ResponsesStreamResponse, data string) (string, error) {
217
+ + if event.ContentIndex != nil {
218
+ + return data, nil
219
+ + }
220
+ + event.ContentIndex = codexIntPtr(0)
221
+ + updated, err := sjson.Set(data, "content_index", 0)
222
+ + if err != nil {
223
+ + return "", fmt.Errorf("set responses content_index: %w", err)
224
+ + }
225
+ + return updated, nil
226
+ +}
227
+ +
228
+ +func codexContentPartEvent(eventType, itemID string, outputIndex *int, text string) dto.ResponsesStreamResponse {
229
+ + return dto.ResponsesStreamResponse{
230
+ + Type: eventType,
231
+ + OutputIndex: outputIndex,
232
+ + ContentIndex: codexIntPtr(0),
233
+ + ItemID: itemID,
234
+ + Part: &dto.ResponsesReasoningSummaryPart{
235
+ + Type: "output_text",
236
+ + Text: text,
237
+ + Annotations: []interface{}{},
238
+ + Logprobs: []interface{}{},
239
+ + },
240
+ + }
241
+ +}
242
+ +
243
+ +func marshalCodexStreamEvent(event dto.ResponsesStreamResponse) (string, error) {
244
+ + data, err := common.Marshal(event)
245
+ + if err != nil {
246
+ + return "", err
247
+ + }
248
+ + return string(data), nil
249
+ +}
250
+ +
251
+ +func codexIntPtr(value int) *int {
252
+ + return &value
253
+ +}
254
+ diff --git a/relay/channel/openai/responses_codex_compat_test.go b/relay/channel/openai/responses_codex_compat_test.go
255
+ new file mode 100644
256
+ index 0000000..3cb1082
257
+ --- /dev/null
258
+ +++ b/relay/channel/openai/responses_codex_compat_test.go
259
+ @@ -0,0 +1,57 @@
260
+ +package openai
261
+ +
262
+ +import (
263
+ + "testing"
264
+ +
265
+ + "github.com/QuantumNous/new-api/common"
266
+ + "github.com/QuantumNous/new-api/dto"
267
+ + "github.com/stretchr/testify/assert"
268
+ + "github.com/stretchr/testify/require"
269
+ + "github.com/tidwall/gjson"
270
+ +)
271
+ +
272
+ +func TestCodexResponsesStreamStateAddsIndexesAndContentPartEvents(t *testing.T) {
273
+ + state := newCodexResponsesStreamState()
274
+ +
275
+ + added := dto.ResponsesStreamResponse{
276
+ + Type: "response.output_item.added",
277
+ + Item: &dto.ResponsesOutput{ID: "item_1", Type: "message", Role: "assistant", Status: "in_progress"},
278
+ + }
279
+ + 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}`)
280
+ + require.NoError(t, err)
281
+ + assert.Empty(t, before)
282
+ + assert.Empty(t, after)
283
+ + assert.Equal(t, int64(0), gjson.Get(addedData, "output_index").Int())
284
+ + assert.True(t, gjson.Get(addedData, "item.content").IsArray())
285
+ + assert.Equal(t, int64(1), gjson.Get(addedData, "sequence_number").Int())
286
+ +
287
+ + delta := dto.ResponsesStreamResponse{Type: "response.output_text.delta", ItemID: "item_1", Delta: "OK"}
288
+ + before, deltaData, after, err := state.normalize(&delta, `{"type":"response.output_text.delta","item_id":"item_1","delta":"OK","sequence_number":2}`)
289
+ + require.NoError(t, err)
290
+ + require.Len(t, before, 1)
291
+ + assert.Equal(t, "response.content_part.added", before[0].Type)
292
+ + assert.Empty(t, after)
293
+ + assert.Equal(t, int64(0), gjson.Get(deltaData, "output_index").Int())
294
+ + assert.Equal(t, int64(0), gjson.Get(deltaData, "content_index").Int())
295
+ +
296
+ + done := dto.ResponsesStreamResponse{Type: "response.output_text.done", ItemID: "item_1"}
297
+ + before, doneData, after, err := state.normalize(&done, `{"type":"response.output_text.done","item_id":"item_1","sequence_number":3}`)
298
+ + require.NoError(t, err)
299
+ + assert.Empty(t, before)
300
+ + require.Len(t, after, 1)
301
+ + assert.Equal(t, "response.content_part.done", after[0].Type)
302
+ + assert.Equal(t, "OK", after[0].Part.Text)
303
+ + assert.Equal(t, "OK", gjson.Get(doneData, "text").String())
304
+ +}
305
+ +
306
+ +func TestMarshalCodexStreamEventIncludesContentPartPayload(t *testing.T) {
307
+ + data, err := marshalCodexStreamEvent(codexContentPartEvent("response.content_part.added", "item_1", codexIntPtr(0), ""))
308
+ + require.NoError(t, err)
309
+ +
310
+ + var event dto.ResponsesStreamResponse
311
+ + require.NoError(t, common.Unmarshal([]byte(data), &event))
312
+ + assert.Equal(t, "response.content_part.added", event.Type)
313
+ + assert.Equal(t, "output_text", event.Part.Type)
314
+ + assert.Equal(t, 0, *event.OutputIndex)
315
+ + assert.Equal(t, 0, *event.ContentIndex)
316
+ +}
317
+ diff --git a/relay/responses_handler.go b/relay/responses_handler.go
318
+ index 5fa23d0..9c74145 100644
319
+ --- a/relay/responses_handler.go
320
+ +++ b/relay/responses_handler.go
321
+ @@ -20,6 +20,25 @@ import (
322
+ "github.com/gin-gonic/gin"
323
+ )
324
+
325
+ +const (
326
+ + codexLunaModel = "gpt-5.6-luna"
327
+ + codexLunaUpstreamModel = "gemini-3.7-flash-high"
328
+ +)
329
+ +
330
+ +func applyCodexLunaCompatibility(info *relaycommon.RelayInfo, request *dto.OpenAIResponsesRequest) {
331
+ + if info == nil || request == nil || info.OriginModelName != codexLunaModel {
332
+ + return
333
+ + }
334
+ +
335
+ + // Luna is a stable Codex-facing alias for an upstream model whose name
336
+ + // already selects the desired thinking level. Do not reinterpret Codex's
337
+ + // reasoning fields or strip the upstream model's -high suffix.
338
+ + info.UpstreamModelName = codexLunaUpstreamModel
339
+ + info.ReasoningEffort = ""
340
+ + request.Model = codexLunaUpstreamModel
341
+ + request.Reasoning = nil
342
+ +}
343
+ +
344
+ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
345
+ info.InitChannelMeta(c)
346
+ if info.RelayMode == relayconstant.RelayModeResponsesCompact {
347
+ @@ -74,6 +93,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
348
+ if err != nil {
349
+ return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
350
+ }
351
+ + applyCodexLunaCompatibility(info, request)
352
+
353
+ adaptor := GetAdaptor(info.ApiType)
354
+ if adaptor == nil {
355
+ diff --git a/relay/responses_luna_compat_test.go b/relay/responses_luna_compat_test.go
356
+ new file mode 100644
357
+ index 0000000..a6f4185
358
+ --- /dev/null
359
+ +++ b/relay/responses_luna_compat_test.go
360
+ @@ -0,0 +1,52 @@
361
+ +package relay
362
+ +
363
+ +import (
364
+ + "testing"
365
+ +
366
+ + "github.com/QuantumNous/new-api/dto"
367
+ + relaycommon "github.com/QuantumNous/new-api/relay/common"
368
+ + "github.com/stretchr/testify/assert"
369
+ + "github.com/stretchr/testify/require"
370
+ +)
371
+ +
372
+ +func TestApplyCodexLunaCompatibilityPinsUpstreamAndIgnoresReasoning(t *testing.T) {
373
+ + info := &relaycommon.RelayInfo{
374
+ + OriginModelName: codexLunaModel,
375
+ + ChannelMeta: &relaycommon.ChannelMeta{
376
+ + UpstreamModelName: "gemini-3.7-flash-low",
377
+ + },
378
+ + ReasoningEffort: "max",
379
+ + }
380
+ + request := &dto.OpenAIResponsesRequest{
381
+ + Model: "gemini-3.7-flash-low",
382
+ + Reasoning: &dto.Reasoning{
383
+ + Effort: "max",
384
+ + Summary: "auto",
385
+ + },
386
+ + }
387
+ +
388
+ + applyCodexLunaCompatibility(info, request)
389
+ +
390
+ + assert.Equal(t, codexLunaUpstreamModel, info.UpstreamModelName)
391
+ + assert.Empty(t, info.ReasoningEffort)
392
+ + assert.Equal(t, codexLunaUpstreamModel, request.Model)
393
+ + require.Nil(t, request.Reasoning)
394
+ +}
395
+ +
396
+ +func TestApplyCodexLunaCompatibilityDoesNotAffectOtherModels(t *testing.T) {
397
+ + reasoning := &dto.Reasoning{Effort: "high", Summary: "auto"}
398
+ + info := &relaycommon.RelayInfo{
399
+ + OriginModelName: "gpt-5.6-sol",
400
+ + ChannelMeta: &relaycommon.ChannelMeta{
401
+ + UpstreamModelName: "gpt-5.6-sol",
402
+ + },
403
+ + ReasoningEffort: "high",
404
+ + }
405
+ + request := &dto.OpenAIResponsesRequest{Model: "gpt-5.6-sol", Reasoning: reasoning}
406
+ +
407
+ + applyCodexLunaCompatibility(info, request)
408
+ +
409
+ + assert.Equal(t, "gpt-5.6-sol", info.UpstreamModelName)
410
+ + assert.Equal(t, "high", info.ReasoningEffort)
411
+ + assert.Same(t, reasoning, request.Reasoning)
412
+ +}
413
+ diff --git a/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go b/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go
414
+ index b3a15ad..31002b3 100644
415
+ --- a/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go
416
+ +++ b/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go
417
+ @@ -17,6 +17,8 @@ const (
418
+ responsesEventCreated = "response.created"
419
+ responsesEventCompleted = "response.completed"
420
+ responsesEventIncomplete = "response.incomplete"
421
+ + responsesEventContentPartAdded = "response.content_part.added"
422
+ + responsesEventContentPartDone = "response.content_part.done"
423
+ responsesEventOutputTextDelta = "response.output_text.delta"
424
+ responsesEventOutputItemAdded = "response.output_item.added"
425
+ responsesEventOutputItemDone = "response.output_item.done"
426
+ 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
427
+ index a5034a0..6872def 100644
428
+ --- a/service/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go
429
+ +++ b/service/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go
430
+ @@ -118,18 +118,23 @@ func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *tes
431
+ })...)
432
+ events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
433
+
434
+ - require.Len(t, events, 10)
435
+ + require.Len(t, events, 12)
436
+ assert.Equal(t, responsesEventCreated, events[0].Type)
437
+ - assert.Equal(t, responsesEventOutputTextDelta, events[2].Type)
438
+ - assert.Equal(t, "hello", events[2].Payload.Delta)
439
+ - assert.Equal(t, responsesEventFunctionArgsDelta, events[4].Type)
440
+ - assert.Equal(t, `{"q":"x"}`, events[4].Payload.Delta)
441
+ - assert.Equal(t, responsesEventCompleted, events[9].Type)
442
+ - require.NotNil(t, events[9].Payload.Response)
443
+ - assert.Equal(t, 6, events[9].Payload.Response.Usage.TotalTokens)
444
+ - require.Len(t, events[9].Payload.Response.Output, 2)
445
+ - assert.Equal(t, "hello", events[9].Payload.Response.Output[0].Content[0].Text)
446
+ - assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
447
+ + assert.Equal(t, responsesEventOutputItemAdded, events[1].Type)
448
+ + assert.Equal(t, responsesEventContentPartAdded, events[2].Type)
449
+ + assert.Equal(t, responsesEventOutputTextDelta, events[3].Type)
450
+ + assert.Equal(t, "hello", events[3].Payload.Delta)
451
+ + assert.Equal(t, responsesEventFunctionArgsDelta, events[5].Type)
452
+ + assert.Equal(t, `{"q":"x"}`, events[5].Payload.Delta)
453
+ + assert.Equal(t, "response.output_text.done", events[6].Type)
454
+ + assert.Equal(t, responsesEventContentPartDone, events[7].Type)
455
+ + assert.Equal(t, "hello", events[7].Payload.Part.Text)
456
+ + assert.Equal(t, responsesEventCompleted, events[11].Type)
457
+ + require.NotNil(t, events[11].Payload.Response)
458
+ + assert.Equal(t, 6, events[11].Payload.Response.Usage.TotalTokens)
459
+ + require.Len(t, events[11].Payload.Response.Output, 2)
460
+ + assert.Equal(t, "hello", events[11].Payload.Response.Output[0].Content[0].Text)
461
+ + assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[11].Payload.Response.Output[1].Arguments))
462
+ }
463
+
464
+ func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
465
+ 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
466
+ index 75c602d..4c3ee45 100644
467
+ --- a/service/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
468
+ +++ b/service/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
469
+ @@ -152,6 +152,18 @@ func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToRespo
470
+ Content: []dto.ResponsesOutputContent{},
471
+ },
472
+ }))
473
+ + events = append(events, responsesStreamEvent(responsesEventContentPartAdded, dto.ResponsesStreamResponse{
474
+ + Type: responsesEventContentPartAdded,
475
+ + OutputIndex: intPtr(s.textOutputIndex),
476
+ + ContentIndex: intPtr(0),
477
+ + ItemID: s.messageID(),
478
+ + Part: &dto.ResponsesReasoningSummaryPart{
479
+ + Type: "output_text",
480
+ + Text: "",
481
+ + Annotations: []interface{}{},
482
+ + Logprobs: []interface{}{},
483
+ + },
484
+ + }))
485
+ }
486
+ s.text.WriteString(delta)
487
+ events = append(events, responsesStreamEvent(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
488
+ @@ -251,6 +263,20 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
489
+ OutputIndex: intPtr(s.textOutputIndex),
490
+ ContentIndex: intPtr(0),
491
+ ItemID: s.messageID(),
492
+ + Text: s.text.String(),
493
+ + Logprobs: []interface{}{},
494
+ + }))
495
+ + events = append(events, responsesStreamEvent(responsesEventContentPartDone, dto.ResponsesStreamResponse{
496
+ + Type: responsesEventContentPartDone,
497
+ + OutputIndex: intPtr(s.textOutputIndex),
498
+ + ContentIndex: intPtr(0),
499
+ + ItemID: s.messageID(),
500
+ + Part: &dto.ResponsesReasoningSummaryPart{
501
+ + Type: "output_text",
502
+ + Text: s.text.String(),
503
+ + Annotations: []interface{}{},
504
+ + Logprobs: []interface{}{},
505
+ + },
506
+ }))
507
+ events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
508
+ Type: responsesEventOutputItemDone,