Skip to content
Merged
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
28 changes: 28 additions & 0 deletions internal/provider/builtin/builtin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package builtin
import (
"testing"

"neo-code/internal/provider"
"neo-code/internal/provider/openai"
)

Expand All @@ -20,3 +21,30 @@ func TestNewRegistry(t *testing.T) {
t.Fatalf("expected registry to include %q driver", openai.DriverName)
}
}

func TestRegister(t *testing.T) {
t.Parallel()

t.Run("nil registry", func(t *testing.T) {
t.Parallel()
err := Register(nil)
if err == nil {
t.Fatal("expected error for nil registry")
}
if err.Error() != "builtin provider registry is nil" {
t.Fatalf("unexpected error: %v", err)
}
})

t.Run("valid registry", func(t *testing.T) {
t.Parallel()
registry := provider.NewRegistry()
err := Register(registry)
if err != nil {
t.Fatalf("Register() error = %v", err)
}
if !registry.Supports(openai.DriverName) {
t.Fatalf("expected registry to include %q driver", openai.DriverName)
}
})
}
171 changes: 171 additions & 0 deletions internal/provider/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,177 @@ package provider

import "testing"

func TestModelDescriptorsFromIDs(t *testing.T) {
t.Parallel()

tests := []struct {
name string
modelIDs []string
want []ModelDescriptor
}{
{
name: "empty slice",
modelIDs: []string{},
want: nil,
},
{
name: "nil slice",
modelIDs: nil,
want: nil,
},
{
name: "single model ID",
modelIDs: []string{"gpt-4"},
want: []ModelDescriptor{
{ID: "gpt-4", Name: "gpt-4"},
},
},
{
name: "multiple model IDs",
modelIDs: []string{"gpt-4", "gpt-3.5-turbo"},
want: []ModelDescriptor{
{ID: "gpt-4", Name: "gpt-4"},
{ID: "gpt-3.5-turbo", Name: "gpt-3.5-turbo"},
},
},
{
name: "IDs with whitespace",
modelIDs: []string{" gpt-4 ", "\tgpt-3.5-turbo\t"},
want: []ModelDescriptor{
{ID: "gpt-4", Name: "gpt-4"},
{ID: "gpt-3.5-turbo", Name: "gpt-3.5-turbo"},
},
},
{
name: "empty strings are skipped",
modelIDs: []string{"gpt-4", "", " ", "gpt-3.5-turbo"},
want: []ModelDescriptor{
{ID: "gpt-4", Name: "gpt-4"},
{ID: "gpt-3.5-turbo", Name: "gpt-3.5-turbo"},
},
},
{
name: "all empty strings",
modelIDs: []string{"", " ", "\t"},
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := modelDescriptorsFromIDs(tt.modelIDs)
if len(got) != len(tt.want) {
t.Fatalf("expected %d descriptors, got %d", len(tt.want), len(got))
}
for i := range got {
if got[i].ID != tt.want[i].ID || got[i].Name != tt.want[i].Name {
t.Fatalf("descriptor %d: expected %+v, got %+v", i, tt.want[i], got[i])
}
}
})
}
}

func TestFirstPositiveInt(t *testing.T) {
t.Parallel()

tests := []struct {
name string
values []any
want int
}{
{
name: "empty values",
values: []any{},
want: 0,
},
{
name: "int positive",
values: []any{int(100)},
want: 100,
},
{
name: "int negative returns zero",
values: []any{int(-100)},
want: 0,
},
{
name: "int zero returns zero",
values: []any{int(0)},
want: 0,
},
{
name: "int32 positive",
values: []any{int32(200)},
want: 200,
},
{
name: "int32 negative returns zero",
values: []any{int32(-200)},
want: 0,
},
{
name: "int64 positive",
values: []any{int64(300)},
want: 300,
},
{
name: "int64 negative returns zero",
values: []any{int64(-300)},
want: 0,
},
{
name: "float32 positive",
values: []any{float32(400.5)},
want: 400,
},
{
name: "float32 negative returns zero",
values: []any{float32(-400.5)},
want: 0,
},
{
name: "float64 positive",
values: []any{float64(500.7)},
want: 500,
},
{
name: "float64 negative returns zero",
values: []any{float64(-500.7)},
want: 0,
},
{
name: "first positive wins",
values: []any{0, -10, 100, 200},
want: 100,
},
{
name: "all negative or zero",
values: []any{-1, -2, 0, -3},
want: 0,
},
{
name: "mixed types",
values: []any{int(0), int32(-10), int64(1000), float64(2000)},
want: 1000,
},
{
name: "non-numeric type ignored",
values: []any{"100", 200},
want: 200,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := firstPositiveInt(tt.values...)
if got != tt.want {
t.Fatalf("expected %d, got %d", tt.want, got)
}
})
}
}

func TestDescriptorFromRawModelNormalizesUsefulFields(t *testing.T) {
t.Parallel()

Expand Down
105 changes: 75 additions & 30 deletions internal/provider/openai/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,37 @@ func (p *Provider) consumeStream(ctx context.Context, body io.Reader, events cha
}
}

for _, discovered := range mergeToolCallDeltas(toolCalls, choice.Delta.ToolCalls) {
if err := emitToolCallStart(ctx, events, discovered.ID, discovered.Name); err != nil {
return err
// 处理 tool call 增量
for _, delta := range choice.Delta.ToolCalls {
call, exists := toolCalls[delta.Index]
if !exists {
call = &domain.ToolCall{}
toolCalls[delta.Index] = call
}

// 首次发现且有名称,发送 tool_call_start
if !exists && strings.TrimSpace(delta.Function.Name) != "" {
call.ID = delta.ID
call.Name = delta.Function.Name
if err := emitToolCallStart(ctx, events, delta.Index, delta.ID, delta.Function.Name); err != nil {
return err
}
} else {
// 更新现有 tool call
if strings.TrimSpace(delta.ID) != "" {
call.ID = delta.ID
}
if strings.TrimSpace(delta.Function.Name) != "" {
call.Name = delta.Function.Name
}
}

// 发送 tool_call_delta
if delta.Function.Arguments != "" {
call.Arguments += delta.Function.Arguments
if err := emitToolCallDelta(ctx, events, delta.Index, delta.Function.Arguments); err != nil {
return err
}
}
}
}
Expand All @@ -267,6 +295,10 @@ func (p *Provider) consumeStream(ctx context.Context, body io.Reader, events cha
return domain.ChatResponse{}, flushErr
}
if done {
// 发送 message_done 事件
if err := emitMessageDone(ctx, events, finishReason, &usage); err != nil {
return domain.ChatResponse{}, err
}
return finalizeResponse(contentBuilder.String(), toolCalls, finishReason, usage), nil
}
case strings.HasPrefix(trimmed, ":"):
Expand All @@ -277,6 +309,10 @@ func (p *Provider) consumeStream(ctx context.Context, body io.Reader, events cha
if flushErr := flushEvent(); flushErr != nil {
return domain.ChatResponse{}, flushErr
}
// 发送 message_done 事件
if err := emitMessageDone(ctx, events, finishReason, &usage); err != nil {
return domain.ChatResponse{}, err
}
return finalizeResponse(contentBuilder.String(), toolCalls, finishReason, usage), nil
}
}
Expand Down Expand Up @@ -342,49 +378,58 @@ func emitTextDelta(ctx context.Context, events chan<- domain.StreamEvent, text s
}
}

func emitToolCallStart(ctx context.Context, events chan<- domain.StreamEvent, id, name string) error {
func emitToolCallStart(ctx context.Context, events chan<- domain.StreamEvent, index int, id, name string) error {
if events == nil || name == "" {
return nil
}

select {
case events <- domain.StreamEvent{
Type: domain.StreamEventToolCallStart,
ToolCallID: id,
ToolName: name,
Type: domain.StreamEventToolCallStart,
ToolCallID: id,
ToolName: name,
ToolCallIndex: index,
}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}

// mergeToolCallDeltas 将流式增量合并到 target map 中,并返回本次新发现的 tool call 列表。
// 当一个新的 tool call 索引首次出现且拥有非空工具名称时,视为新发现。
func mergeToolCallDeltas(target map[int]*domain.ToolCall, deltas []toolCallDelta) []domain.ToolCall {
var discovered []domain.ToolCall
for _, delta := range deltas {
call, exists := target[delta.Index]
if !exists {
call = &domain.ToolCall{}
target[delta.Index] = call
}
// emitToolCallDelta 发送工具调用参数增量事件。
func emitToolCallDelta(ctx context.Context, events chan<- domain.StreamEvent, index int, argumentsDelta string) error {
if events == nil || argumentsDelta == "" {
return nil
}

if strings.TrimSpace(delta.ID) != "" {
call.ID = delta.ID
}
if strings.TrimSpace(delta.Function.Name) != "" {
call.Name = delta.Function.Name
}
if delta.Function.Arguments != "" {
call.Arguments += delta.Function.Arguments
}
select {
case events <- domain.StreamEvent{
Type: domain.StreamEventToolCallDelta,
ToolCallIndex: index,
ToolArgumentsDelta: argumentsDelta,
}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}

if !exists && strings.TrimSpace(call.Name) != "" {
discovered = append(discovered, *call)
}
// emitMessageDone 发送消息完成事件。
func emitMessageDone(ctx context.Context, events chan<- domain.StreamEvent, finishReason string, usage *domain.Usage) error {
if events == nil {
return nil
}

select {
case events <- domain.StreamEvent{
Type: domain.StreamEventMessageDone,
FinishReason: finishReason,
Usage: usage,
}:
return nil
case <-ctx.Done():
return ctx.Err()
}
return discovered
}

func finalizeResponse(content string, toolCalls map[int]*domain.ToolCall, finishReason string, usage domain.Usage) domain.ChatResponse {
Expand Down
Loading
Loading