Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 248 additions & 87 deletions internal/protocol/chat/adapter.go

Large diffs are not rendered by default.

133 changes: 133 additions & 0 deletions internal/protocol/chat/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,25 @@ func TestFromCoreRequest_Tools(t *testing.T) {
}
}

func TestFromCoreRequest_ToolsDefaultEmptyParameters(t *testing.T) {
adapter := newTestAdapter()
result, err := adapter.FromCoreRequest(context.Background(), &format.CoreRequest{
Model: "gpt-4o",
Messages: []format.CoreMessage{
{Role: "user", Content: []format.CoreContentBlock{{Type: "text", Text: "call tool"}}},
},
Tools: []format.CoreTool{{Name: "ping"}},
})
if err != nil {
t.Fatal(err)
}
chatReq := result.(*chat.ChatRequest)
params := chatReq.Tools[0].Function.Parameters
if got := params["type"]; got != "object" {
t.Fatalf("default parameters type = %v, want object; params=%+v", got, params)
}
}

func TestFromCoreRequest_ToolChoice(t *testing.T) {
adapter := newTestAdapter()
tests := []struct {
Expand Down Expand Up @@ -1505,6 +1524,43 @@ func TestToCoreResponse_ToolCalls(t *testing.T) {
}
}

func TestToCoreResponse_SplitsThinkTaggedText(t *testing.T) {
adapter := newTestAdapter()
chatResp := &chat.ChatResponse{
ID: "chatcmpl-think",
Model: "MiniMax-M3",
Choices: []chat.Choice{{
Index: 0,
Message: chat.ChatMessage{
Role: "assistant",
Content: "<think>\nplan privately\n</think>\npong",
},
FinishReason: "stop",
}},
}

result, err := adapter.ToCoreResponse(context.Background(), chatResp)
if err != nil {
t.Fatal(err)
}
if result.Model != "MiniMax-M3" {
t.Fatalf("Model = %q, want MiniMax-M3", result.Model)
}
if len(result.Messages) != 1 {
t.Fatalf("Messages: got %d, want 1", len(result.Messages))
}
content := result.Messages[0].Content
if len(content) != 2 {
t.Fatalf("content len = %d, want 2: %+v", len(content), content)
}
if content[0].Type != "reasoning" || content[0].ReasoningText != "\nplan privately\n" {
t.Fatalf("reasoning block = %+v", content[0])
}
if content[1].Type != "text" || content[1].Text != "pong" {
t.Fatalf("text block = %+v", content[1])
}
}

func TestToCoreResponse_FinishReasonVariants(t *testing.T) {
adapter := newTestAdapter()
tests := []struct {
Expand Down Expand Up @@ -1749,6 +1805,83 @@ func TestToCoreStream_ToolCallArgsDelta(t *testing.T) {
}
}

func TestToCoreStream_SplitsThinkTaggedTextAcrossChunks(t *testing.T) {
adapter := newTestAdapter()
src := make(chan chat.ChatStreamChunk, 6)
src <- chat.ChatStreamChunk{
Model: "MiniMax-M3",
Choices: []chat.StreamChoice{{
Index: 0,
Delta: chat.Delta{Role: "assistant", Content: "<thi"},
}},
}
src <- chat.ChatStreamChunk{
Choices: []chat.StreamChoice{{
Index: 0,
Delta: chat.Delta{Content: "nk>\nplan"},
}},
}
src <- chat.ChatStreamChunk{
Choices: []chat.StreamChoice{{
Index: 0,
Delta: chat.Delta{Content: "\n</thi"},
}},
}
src <- chat.ChatStreamChunk{
Choices: []chat.StreamChoice{{
Index: 0,
Delta: chat.Delta{Content: "nk>\npong"},
}},
}
src <- chat.ChatStreamChunk{
Choices: []chat.StreamChoice{{Index: 0, FinishReason: "stop"}},
}
close(src)

events, err := adapter.ToCoreStream(context.Background(), (<-chan chat.ChatStreamChunk)(src))
if err != nil {
t.Fatal(err)
}

var reasoningStarted bool
var reasoningDone *format.CoreStreamEvent
var text string
var completed *format.CoreStreamEvent
for e := range events {
if e.Type == format.CoreContentBlockStarted && e.ContentBlock != nil && e.ContentBlock.Type == "reasoning" {
reasoningStarted = true
}
if e.Type == format.CoreTextDelta {
if strings.Contains(e.Delta, "<think") || strings.Contains(e.Delta, "</think") {
t.Fatalf("think tag leaked in text delta: %+v", e)
}
if e.Index == 1 {
text += e.Delta
}
}
if e.Type == format.CoreContentBlockDone && e.ContentBlock != nil && e.ContentBlock.Type == "reasoning" {
ev := e
reasoningDone = &ev
}
if e.Type == format.CoreEventCompleted {
ev := e
completed = &ev
}
}
if !reasoningStarted {
t.Fatal("reasoning block was not started")
}
if reasoningDone == nil || reasoningDone.ContentBlock.ReasoningText != "\nplan\n" {
t.Fatalf("reasoning done = %+v", reasoningDone)
}
if text != "pong" {
t.Fatalf("text = %q, want pong", text)
}
if completed == nil || completed.Model != "MiniMax-M3" {
t.Fatalf("completed = %+v, want model MiniMax-M3", completed)
}
}

func TestToCoreStream_EmptyChunk(t *testing.T) {
adapter := newTestAdapter()
src := make(chan chat.ChatStreamChunk, 2)
Expand Down
9 changes: 6 additions & 3 deletions internal/protocol/openai/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ type OpenAIAdapter struct {
hooks format.CorePluginHooks

disablePatchProxy func(string) bool
streamMu sync.Mutex
streamEvents []StreamEvent
streamMu sync.Mutex
streamEvents []StreamEvent
}

// NewOpenAIAdapter creates a new OpenAIAdapter with the given config and hooks.
func NewOpenAIAdapter(hooks format.CorePluginHooks) *OpenAIAdapter {
return &OpenAIAdapter{
hooks: hooks.WithDefaults(),
hooks: hooks.WithDefaults(),
disablePatchProxy: hooks.DisablePatchProxy,
}
}
Expand Down Expand Up @@ -676,6 +676,9 @@ func (a *OpenAIAdapter) streamLoop(ctx context.Context, coreReq *format.CoreRequ
// Lifecycle: completed
// ==================================================================
case format.CoreEventCompleted:
if event.Model != "" {
response.Model = event.Model
}
// Build output_text from message items, same as FromCoreResponse.
var texts []string
for _, item := range response.Output {
Expand Down
31 changes: 29 additions & 2 deletions internal/protocol/openai/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,8 @@ func TestToCoreRequest_BatchesCustomToolCallsAndOutputsIntoSingleRound(t *testin
for i, want := range []struct {
assistantTextIdx int
msgIdx int
callID string
outcome string
callID string
outcome string
}{
{0, 1, "call_a", "ok a"},
{3, 4, "call_b", "ok b"},
Expand Down Expand Up @@ -365,3 +365,30 @@ func TestFromCoreStream_NoDuplicateDoneForToolUse(t *testing.T) {
t.Fatalf("output_item.done (tool) count=%d, want 1", itemDone)
}
}

func TestFromCoreStream_CompletedCarriesModel(t *testing.T) {
adapter := openai.NewOpenAIAdapter(format.CorePluginHooks{})
coreReq := &format.CoreRequest{Model: "minimax-test"}
evCh := make(chan format.CoreStreamEvent, 1)
evCh <- format.CoreStreamEvent{Type: format.CoreEventCompleted, Status: "completed", Model: "MiniMax-M3"}
close(evCh)

streamAny, err := adapter.FromCoreStream(context.Background(), coreReq, evCh)
if err != nil {
t.Fatal(err)
}
stream := streamAny.(<-chan openai.StreamEvent)
var completed *openai.ResponseLifecycleEvent
for ev := range stream {
if ev.Event == "response.completed" {
data := ev.Data.(openai.ResponseLifecycleEvent)
completed = &data
}
}
if completed == nil {
t.Fatal("response.completed not found")
}
if completed.Response.Model != "MiniMax-M3" {
t.Fatalf("completed model = %q, want MiniMax-M3", completed.Response.Model)
}
}
1 change: 0 additions & 1 deletion internal/service/server/adapter_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2143,7 +2143,6 @@ func normalizeAnthropicRequest(upstream any) (anthropic.MessageRequest, error) {
}
}


// injectCoreWebSearch replaces web_search tools in coreReq.Tools with injected
// tavily_search/firecrawl_fetch tools when the resolved web search mode is "injected".
// Returns true if injection was applied.
Expand Down
Loading