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