| |
| |
| |
| |
| @@ -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 |
| |
| |
| |
| |
| @@ -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-") { |
| |
| new file mode 100644 |
| |
| |
| |
| @@ -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) |
| +} |
| |
| |
| |
| |
| @@ -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 != "" { |
| |
| new file mode 100644 |
| |
| |
| |
| @@ -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) |
| +} |
| |
| |
| |
| |
| @@ -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 { |
| |
| new file mode 100644 |
| |
| |
| |
| @@ -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 |
| +} |
| |
| new file mode 100644 |
| |
| |
| |
| @@ -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()) |
| +} |
| |
| |
| |
| |
| @@ -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 { |
| |
| new file mode 100644 |
| |
| |
| |
| @@ -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) |
| +} |
| |
| |
| |
| |
| @@ -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" |
| |
| |
| |
| |
| @@ -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 { |
| |
| |
| |
| |
| @@ -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 |
| Output |
| Content |
| 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, |
|
|