diff --git a/backend/internal/conversion/anthropic/stream_decoder.go b/backend/internal/conversion/anthropic/stream_decoder.go index 543b32a..79fafbf 100644 --- a/backend/internal/conversion/anthropic/stream_decoder.go +++ b/backend/internal/conversion/anthropic/stream_decoder.go @@ -251,7 +251,7 @@ func (d *StreamDecoder) processMessageDelta(data []byte) []canonical.CanonicalSt } if d.accumulatedUsage != nil { - d.accumulatedUsage.OutputTokens += raw.Usage.OutputTokens + d.accumulatedUsage.OutputTokens = raw.Usage.OutputTokens } return []canonical.CanonicalStreamEvent{ diff --git a/backend/internal/conversion/anthropic/stream_decoder_test.go b/backend/internal/conversion/anthropic/stream_decoder_test.go index 0554621..f993ca7 100644 --- a/backend/internal/conversion/anthropic/stream_decoder_test.go +++ b/backend/internal/conversion/anthropic/stream_decoder_test.go @@ -272,3 +272,218 @@ func TestStreamDecoder_RedactedDeltaSuppressed(t *testing.T) { events := d.ProcessChunk(raw) assert.Empty(t, events) } + +func TestStreamDecoder_ServerToolUse_Suppressed(t *testing.T) { + d := NewStreamDecoder() + + payload := map[string]any{ + "type": "content_block_start", + "index": 2, + "content_block": map[string]any{ + "type": "server_tool_use", + "id": "server_tool_1", + "name": "web_search", + }, + } + raw := makeAnthropicEvent("content_block_start", payload) + events := d.ProcessChunk(raw) + assert.Empty(t, events) + assert.True(t, d.redactedBlocks[2]) +} + +func TestStreamDecoder_WebSearchToolResult_Suppressed(t *testing.T) { + d := NewStreamDecoder() + + payload := map[string]any{ + "type": "content_block_start", + "index": 3, + "content_block": map[string]any{ + "type": "web_search_tool_result", + "tool_use_id": "search_1", + }, + } + raw := makeAnthropicEvent("content_block_start", payload) + events := d.ProcessChunk(raw) + assert.Empty(t, events) + assert.True(t, d.redactedBlocks[3]) +} + +func TestStreamDecoder_CodeExecutionToolResult_Suppressed(t *testing.T) { + d := NewStreamDecoder() + + payload := map[string]any{ + "type": "content_block_start", + "index": 4, + "content_block": map[string]any{ + "type": "code_execution_tool_result", + }, + } + raw := makeAnthropicEvent("content_block_start", payload) + events := d.ProcessChunk(raw) + assert.Empty(t, events) + assert.True(t, d.redactedBlocks[4]) +} + +func TestStreamDecoder_CitationsDelta_Discarded(t *testing.T) { + d := NewStreamDecoder() + + payload := map[string]any{ + "type": "content_block_delta", + "index": 0, + "delta": map[string]any{ + "type": "citations_delta", + "citation": map[string]any{"title": "ref1"}, + }, + } + raw := makeAnthropicEvent("content_block_delta", payload) + events := d.ProcessChunk(raw) + assert.Empty(t, events) +} + +func TestStreamDecoder_SignatureDelta_Discarded(t *testing.T) { + d := NewStreamDecoder() + + payload := map[string]any{ + "type": "content_block_delta", + "index": 0, + "delta": map[string]any{ + "type": "signature_delta", + "signature": "sig_123", + }, + } + raw := makeAnthropicEvent("content_block_delta", payload) + events := d.ProcessChunk(raw) + assert.Empty(t, events) +} + +func TestStreamDecoder_UnknownEventType(t *testing.T) { + d := NewStreamDecoder() + + raw := makeAnthropicEvent("unknown_event", map[string]any{"type": "unknown_event"}) + events := d.ProcessChunk(raw) + assert.Empty(t, events) +} + +func TestStreamDecoder_InvalidJSON(t *testing.T) { + d := NewStreamDecoder() + + raw := []byte("event: message_start\ndata: {invalid}\n\n") + events := d.ProcessChunk(raw) + assert.Empty(t, events) +} + +func TestStreamDecoder_MultipleEventsInSingleChunk(t *testing.T) { + d := NewStreamDecoder() + + startPayload := map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": "msg_multi", + "model": "claude-3", + }, + } + deltaPayload := map[string]any{ + "type": "content_block_delta", + "index": 0, + "delta": map[string]any{ + "type": "text_delta", + "text": "Hello", + }, + } + stopPayload := map[string]any{"type": "message_stop"} + + var raw []byte + raw = append(raw, makeAnthropicEvent("message_start", startPayload)...) + raw = append(raw, makeAnthropicEvent("content_block_delta", deltaPayload)...) + raw = append(raw, makeAnthropicEvent("message_stop", stopPayload)...) + + events := d.ProcessChunk(raw) + require.Len(t, events, 3) + assert.Equal(t, canonical.EventMessageStart, events[0].Type) + assert.Equal(t, canonical.EventContentBlockDelta, events[1].Type) + assert.Equal(t, canonical.EventMessageStop, events[2].Type) +} + +func TestStreamDecoder_ErrorInvalidJSON(t *testing.T) { + d := NewStreamDecoder() + + raw := []byte("event: error\ndata: {invalid}\n\n") + events := d.ProcessChunk(raw) + require.Len(t, events, 1) + assert.Equal(t, canonical.EventError, events[0].Type) + assert.Contains(t, events[0].Error.Message, "解析错误事件失败") +} + +func TestStreamDecoder_MessageStartWithUsage(t *testing.T) { + d := NewStreamDecoder() + + payload := map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": "msg_usage", + "model": "claude-3", + "usage": map[string]any{"input_tokens": 25, "output_tokens": 0}, + }, + } + raw := makeAnthropicEvent("message_start", payload) + + events := d.ProcessChunk(raw) + require.Len(t, events, 1) + assert.Equal(t, canonical.EventMessageStart, events[0].Type) + require.NotNil(t, events[0].Message.Usage) + assert.Equal(t, 25, events[0].Message.Usage.InputTokens) +} + +func TestStreamDecoder_ThinkingBlockStart(t *testing.T) { + d := NewStreamDecoder() + + payload := map[string]any{ + "type": "content_block_start", + "index": 0, + "content_block": map[string]any{ + "type": "thinking", + "thinking": "", + }, + } + raw := makeAnthropicEvent("content_block_start", payload) + + events := d.ProcessChunk(raw) + require.Len(t, events, 1) + assert.Equal(t, canonical.EventContentBlockStart, events[0].Type) + require.NotNil(t, events[0].ContentBlock) + assert.Equal(t, "thinking", events[0].ContentBlock.Type) +} + +func TestStreamDecoder_MessageDelta_UsageNotAccumulated(t *testing.T) { + d := NewStreamDecoder() + + startPayload := map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": "msg_usage_test", + "model": "claude-3", + "usage": map[string]any{"input_tokens": 10, "output_tokens": 0}, + }, + } + deltaPayload1 := map[string]any{ + "type": "message_delta", + "delta": map[string]any{"stop_reason": "end_turn"}, + "usage": map[string]any{"output_tokens": 25}, + } + + d.ProcessChunk(makeAnthropicEvent("message_start", startPayload)) + events := d.ProcessChunk(makeAnthropicEvent("message_delta", deltaPayload1)) + + require.Len(t, events, 1) + assert.Equal(t, 25, events[0].Usage.OutputTokens) + + deltaPayload2 := map[string]any{ + "type": "message_delta", + "delta": map[string]any{"stop_reason": "end_turn"}, + "usage": map[string]any{"output_tokens": 30}, + } + events = d.ProcessChunk(makeAnthropicEvent("message_delta", deltaPayload2)) + require.Len(t, events, 1) + assert.Equal(t, 30, events[0].Usage.OutputTokens, "output_tokens should be replaced, not accumulated") + assert.Equal(t, 30, d.accumulatedUsage.OutputTokens, "accumulated usage should match last value") +} diff --git a/backend/internal/conversion/anthropic/supplemental_test.go b/backend/internal/conversion/anthropic/supplemental_test.go new file mode 100644 index 0000000..381a8a5 --- /dev/null +++ b/backend/internal/conversion/anthropic/supplemental_test.go @@ -0,0 +1,477 @@ +package anthropic + +import ( + "encoding/json" + "testing" + "time" + + "nex/backend/internal/conversion" + "nex/backend/internal/conversion/canonical" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeTools(t *testing.T) { + body := []byte(`{ + "model": "claude-3", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + {"name": "search", "description": "Search", "input_schema": {"type":"object"}}, + {"name": "calc", "input_schema": {"type":"object"}} + ] + }`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Len(t, req.Tools, 2) + assert.Equal(t, "search", req.Tools[0].Name) + assert.Equal(t, "Search", req.Tools[0].Description) + assert.Equal(t, "calc", req.Tools[1].Name) +} + +func TestDecodeToolChoice(t *testing.T) { + tests := []struct { + name string + jsonBody string + wantType string + wantName string + }{ + { + "auto string", + `{"model":"claude-3","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tool_choice":"auto"}`, + "auto", "", + }, + { + "none string", + `{"model":"claude-3","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tool_choice":"none"}`, + "none", "", + }, + { + "any string", + `{"model":"claude-3","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tool_choice":"any"}`, + "any", "", + }, + { + "tool object", + `{"model":"claude-3","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tool_choice":{"type":"tool","name":"search"}}`, + "tool", "search", + }, + { + "auto object", + `{"model":"claude-3","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tool_choice":{"type":"auto"}}`, + "auto", "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := decodeRequest([]byte(tt.jsonBody)) + require.NoError(t, err) + require.NotNil(t, req.ToolChoice) + assert.Equal(t, tt.wantType, req.ToolChoice.Type) + assert.Equal(t, tt.wantName, req.ToolChoice.Name) + }) + } +} + +func TestDecodeParameters_TopK(t *testing.T) { + topK := 10 + body := []byte(`{ + "model": "claude-3", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + "top_k": 10, + "stop_sequences": ["STOP"] + }`) + req, err := decodeRequest(body) + require.NoError(t, err) + require.NotNil(t, req.Parameters.TopK) + assert.Equal(t, topK, *req.Parameters.TopK) + assert.Equal(t, []string{"STOP"}, req.Parameters.StopSequences) +} + +func TestDecodeRequest_MetadataUserID(t *testing.T) { + body := []byte(`{ + "model": "claude-3", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + "metadata": {"user_id": "user-123"} + }`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Equal(t, "user-123", req.UserID) +} + +func TestDecodeSystem_Empty(t *testing.T) { + body := []byte(`{ + "model": "claude-3", + "max_tokens": 1024, + "system": "", + "messages": [{"role": "user", "content": "hi"}] + }`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Nil(t, req.System) +} + +func TestDecodeSystem_Nil(t *testing.T) { + body := []byte(`{ + "model": "claude-3", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}] + }`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Nil(t, req.System) +} + +func TestDecodeThinking_WithEffort(t *testing.T) { + body := []byte(`{ + "model": "claude-3", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}], + "thinking": {"type": "enabled", "budget_tokens": 5000}, + "output_config": {"effort": "high"} + }`) + req, err := decodeRequest(body) + require.NoError(t, err) + require.NotNil(t, req.Thinking) + assert.Equal(t, "enabled", req.Thinking.Type) + assert.Equal(t, "high", req.Thinking.Effort) +} + +func TestDecodeOutputFormat_NilOutputConfig(t *testing.T) { + body := []byte(`{ + "model": "claude-3", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "hi"}] + }`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Nil(t, req.OutputFormat) +} + +func TestDecodeMessage_UserWithOnlyToolResults(t *testing.T) { + body := []byte(`{ + "model": "claude-3", + "max_tokens": 1024, + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "fn", "input": {}}]}, + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "result"}] + } + ] + }`) + req, err := decodeRequest(body) + require.NoError(t, err) + lastMsg := req.Messages[len(req.Messages)-1] + assert.Equal(t, canonical.RoleTool, lastMsg.Role) + assert.Equal(t, "t1", lastMsg.Content[0].ToolUseID) +} + +func TestDecodeContentBlocks_Nil(t *testing.T) { + blocks := decodeContentBlocks(nil) + assert.Len(t, blocks, 1) + assert.Equal(t, "", blocks[0].Text) +} + +func TestDecodeContentBlocks_String(t *testing.T) { + blocks := decodeContentBlocks("hello") + assert.Len(t, blocks, 1) + assert.Equal(t, "hello", blocks[0].Text) +} + +func TestParseTimestamp(t *testing.T) { + tests := []struct { + name string + input string + want int64 + }{ + {"valid RFC3339", "2024-01-15T00:00:00Z", time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC).Unix()}, + {"empty", "", 0}, + {"invalid", "not-a-date", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, parseTimestamp(tt.input)) + }) + } +} + +func TestEncodeToolChoice(t *testing.T) { + tests := []struct { + name string + choice *canonical.ToolChoice + want map[string]any + }{ + {"auto", canonical.NewToolChoiceAuto(), map[string]any{"type": "auto"}}, + {"none", canonical.NewToolChoiceNone(), map[string]any{"type": "none"}}, + {"any", canonical.NewToolChoiceAny(), map[string]any{"type": "any"}}, + {"tool", canonical.NewToolChoiceNamed("search"), map[string]any{"type": "tool", "name": "search"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := encodeToolChoice(tt.choice) + assert.Equal(t, tt.want["type"], result.(map[string]any)["type"]) + assert.Equal(t, tt.want["name"], result.(map[string]any)["name"]) + }) + } +} + +func TestEncodeThinkingConfig(t *testing.T) { + budget := 5000 + tests := []struct { + name string + cfg *canonical.ThinkingConfig + want map[string]any + }{ + {"enabled", &canonical.ThinkingConfig{Type: "enabled", BudgetTokens: &budget}, map[string]any{"type": "enabled", "budget_tokens": float64(5000)}}, + {"disabled", &canonical.ThinkingConfig{Type: "disabled"}, map[string]any{"type": "disabled"}}, + {"adaptive", &canonical.ThinkingConfig{Type: "adaptive"}, map[string]any{"type": "adaptive"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := encodeThinkingConfig(tt.cfg) + assert.Equal(t, tt.want["type"], result["type"]) + }) + } +} + +func TestEncodeRequest_PublicFields(t *testing.T) { + maxTokens := 1024 + parallel := false + req := &canonical.CanonicalRequest{ + Model: "claude-3", + Parameters: canonical.RequestParameters{MaxTokens: &maxTokens}, + Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}}, + UserID: "user-123", + ParallelToolUse: ¶llel, + } + provider := conversion.NewTargetProvider("", "key", "model") + + body, err := encodeRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, map[string]any{"user_id": "user-123"}, result["metadata"]) + assert.Equal(t, true, result["disable_parallel_tool_use"]) +} + +func TestEncodeRequest_DefaultMaxTokens(t *testing.T) { + req := &canonical.CanonicalRequest{ + Model: "claude-3", + Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}}, + } + provider := conversion.NewTargetProvider("", "key", "model") + + body, err := encodeRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, float64(4096), result["max_tokens"]) +} + +func TestEncodeRequest_TopK(t *testing.T) { + maxTokens := 1024 + topK := 10 + req := &canonical.CanonicalRequest{ + Model: "claude-3", + Parameters: canonical.RequestParameters{MaxTokens: &maxTokens, TopK: &topK}, + Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}}, + } + provider := conversion.NewTargetProvider("", "key", "model") + + body, err := encodeRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, float64(10), result["top_k"]) +} + +func TestEncodeRequest_WithTools(t *testing.T) { + maxTokens := 1024 + req := &canonical.CanonicalRequest{ + Model: "claude-3", + Parameters: canonical.RequestParameters{MaxTokens: &maxTokens}, + Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}}, + Tools: []canonical.CanonicalTool{ + {Name: "search", Description: "Search things", InputSchema: json.RawMessage(`{"type":"object"}`)}, + }, + ToolChoice: canonical.NewToolChoiceAuto(), + } + provider := conversion.NewTargetProvider("", "key", "model") + + body, err := encodeRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + tools := result["tools"].([]any) + assert.Len(t, tools, 1) + tool := tools[0].(map[string]any) + assert.Equal(t, "search", tool["name"]) + assert.Equal(t, "Search things", tool["description"]) + tc := result["tool_choice"].(map[string]any) + assert.Equal(t, "auto", tc["type"]) +} + +func TestEncodeRequest_ThinkingWithEffort(t *testing.T) { + maxTokens := 1024 + req := &canonical.CanonicalRequest{ + Model: "claude-3", + Parameters: canonical.RequestParameters{MaxTokens: &maxTokens}, + Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}}, + Thinking: &canonical.ThinkingConfig{Type: "enabled", Effort: "high"}, + } + provider := conversion.NewTargetProvider("", "key", "model") + + body, err := encodeRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + oc, ok := result["output_config"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "high", oc["effort"]) +} + +func TestEncodeResponse_UsageWithCacheAndCreation(t *testing.T) { + cacheRead := 30 + cacheCreation := 10 + sr := canonical.StopReasonEndTurn + resp := &canonical.CanonicalResponse{ + ID: "msg-1", + Model: "claude-3", + Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")}, + StopReason: &sr, + Usage: canonical.CanonicalUsage{ + InputTokens: 100, + OutputTokens: 50, + CacheReadTokens: &cacheRead, + CacheCreationTokens: &cacheCreation, + }, + } + + body, err := encodeResponse(resp) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + usage := result["usage"].(map[string]any) + assert.Equal(t, float64(100), usage["input_tokens"]) + assert.Equal(t, float64(30), usage["cache_read_input_tokens"]) + assert.Equal(t, float64(10), usage["cache_creation_input_tokens"]) +} + +func TestEncodeResponse_StopReasons(t *testing.T) { + tests := []struct { + name string + stopReason canonical.StopReason + want string + }{ + {"end_turn", canonical.StopReasonEndTurn, "end_turn"}, + {"max_tokens", canonical.StopReasonMaxTokens, "max_tokens"}, + {"tool_use", canonical.StopReasonToolUse, "tool_use"}, + {"stop_sequence", canonical.StopReasonStopSequence, "stop_sequence"}, + {"refusal", canonical.StopReasonRefusal, "refusal"}, + {"content_filter→end_turn", canonical.StopReasonContentFilter, "end_turn"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sr := tt.stopReason + resp := &canonical.CanonicalResponse{ + ID: "r1", + Model: "claude-3", + Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")}, + StopReason: &sr, + } + body, err := encodeResponse(resp) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, tt.want, result["stop_reason"]) + }) + } +} + +func TestEncodeSystem_SystemBlocks(t *testing.T) { + result := encodeSystem([]canonical.SystemBlock{{Text: "part1"}, {Text: "part2"}}) + blocks, ok := result.([]map[string]any) + require.True(t, ok) + assert.Len(t, blocks, 2) + assert.Equal(t, "part1", blocks[0]["text"]) +} + +func TestEncodeModelInfoResponse(t *testing.T) { + info := &canonical.CanonicalModelInfo{ + ID: "claude-3-opus", + Name: "Claude 3 Opus", + Created: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC).Unix(), + } + + body, err := encodeModelInfoResponse(info) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, "claude-3-opus", result["id"]) + assert.Equal(t, "Claude 3 Opus", result["display_name"]) +} + +func TestDecodeModelInfoResponse(t *testing.T) { + body := []byte(`{"id":"claude-3-opus","type":"model","display_name":"Claude 3 Opus","created_at":"2024-01-15T00:00:00Z"}`) + info, err := decodeModelInfoResponse(body) + require.NoError(t, err) + assert.Equal(t, "claude-3-opus", info.ID) + assert.Equal(t, "Claude 3 Opus", info.Name) + assert.NotEqual(t, int64(0), info.Created) +} + +func TestDecodeResponse_PauseTurn(t *testing.T) { + body := []byte(`{ + "id": "msg-1", "type": "message", "role": "assistant", "model": "claude-3", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "pause_turn", + "usage": {"input_tokens": 1, "output_tokens": 1} + }`) + resp, err := decodeResponse(body) + require.NoError(t, err) + assert.Equal(t, canonical.StopReason("pause_turn"), *resp.StopReason) +} + +func TestEncodeResponse_NoStopReason(t *testing.T) { + resp := &canonical.CanonicalResponse{ + ID: "msg-1", + Model: "claude-3", + Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")}, + } + + body, err := encodeResponse(resp) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, "end_turn", result["stop_reason"]) +} + +func TestDecodeRequest_MaxTokensZero(t *testing.T) { + body := []byte(`{ + "model": "claude-3", + "max_tokens": 0, + "messages": [{"role": "user", "content": "hi"}] + }`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Nil(t, req.Parameters.MaxTokens) +} diff --git a/backend/internal/conversion/canonical/types_test.go b/backend/internal/conversion/canonical/types_test.go new file mode 100644 index 0000000..5982f3c --- /dev/null +++ b/backend/internal/conversion/canonical/types_test.go @@ -0,0 +1,114 @@ +package canonical + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetSystemString(t *testing.T) { + tests := []struct { + name string + system any + want string + }{ + {"string", "hello", "hello"}, + {"nil", nil, ""}, + {"empty string", "", ""}, + {"system blocks", []SystemBlock{{Text: "part1"}, {Text: "part2"}}, "part1\n\npart2"}, + {"single block", []SystemBlock{{Text: "only"}}, "only"}, + {"other type", 123, "123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &CanonicalRequest{System: tt.system} + assert.Equal(t, tt.want, req.GetSystemString()) + }) + } +} + +func TestSetSystemString(t *testing.T) { + req := &CanonicalRequest{} + + req.SetSystemString("hello") + assert.Equal(t, "hello", req.System) + + req.SetSystemString("") + assert.Nil(t, req.System) +} + +func TestNewTextBlock(t *testing.T) { + b := NewTextBlock("hello") + assert.Equal(t, "text", b.Type) + assert.Equal(t, "hello", b.Text) +} + +func TestNewToolUseBlock(t *testing.T) { + input := json.RawMessage(`{"key":"val"}`) + b := NewToolUseBlock("id-1", "tool_name", input) + assert.Equal(t, "tool_use", b.Type) + assert.Equal(t, "id-1", b.ID) + assert.Equal(t, "tool_name", b.Name) + assert.Equal(t, input, b.Input) +} + +func TestNewToolResultBlock(t *testing.T) { + b := NewToolResultBlock("tool-1", "result", false) + assert.Equal(t, "tool_result", b.Type) + assert.Equal(t, "tool-1", b.ToolUseID) + assert.NotNil(t, b.IsError) + assert.False(t, *b.IsError) +} + +func TestNewThinkingBlock(t *testing.T) { + b := NewThinkingBlock("thought") + assert.Equal(t, "thinking", b.Type) + assert.Equal(t, "thought", b.Thinking) +} + +func TestNewToolChoice(t *testing.T) { + assert.Equal(t, &ToolChoice{Type: "auto"}, NewToolChoiceAuto()) + assert.Equal(t, &ToolChoice{Type: "none"}, NewToolChoiceNone()) + assert.Equal(t, &ToolChoice{Type: "any"}, NewToolChoiceAny()) + assert.Equal(t, &ToolChoice{Type: "tool", Name: "fn"}, NewToolChoiceNamed("fn")) +} + +func TestCanonicalRequest_RoundTrip(t *testing.T) { + req := &CanonicalRequest{ + Model: "gpt-4", + System: "system prompt", + Messages: []CanonicalMessage{{Role: RoleUser, Content: []ContentBlock{NewTextBlock("hi")}}}, + Stream: true, + } + + data, err := json.Marshal(req) + require.NoError(t, err) + + var decoded CanonicalRequest + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, "gpt-4", decoded.Model) + assert.Equal(t, "system prompt", decoded.System) + assert.True(t, decoded.Stream) +} + +func TestCanonicalResponse_RoundTrip(t *testing.T) { + sr := StopReasonEndTurn + resp := &CanonicalResponse{ + ID: "resp-1", + Model: "gpt-4", + Content: []ContentBlock{NewTextBlock("hello")}, + StopReason: &sr, + Usage: CanonicalUsage{InputTokens: 10, OutputTokens: 5}, + } + + data, err := json.Marshal(resp) + require.NoError(t, err) + + var decoded CanonicalResponse + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, "resp-1", decoded.ID) + assert.Equal(t, StopReasonEndTurn, *decoded.StopReason) +} diff --git a/backend/internal/conversion/engine_supplemental_test.go b/backend/internal/conversion/engine_supplemental_test.go new file mode 100644 index 0000000..9f84224 --- /dev/null +++ b/backend/internal/conversion/engine_supplemental_test.go @@ -0,0 +1,323 @@ +package conversion + +import ( + "encoding/json" + "errors" + "testing" + + "nex/backend/internal/conversion/canonical" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConversionError_WithProviderProtocol(t *testing.T) { + err := NewConversionError(ErrorCodeInvalidInput, "test").WithProviderProtocol("anthropic") + assert.Equal(t, "anthropic", err.ProviderProtocol) +} + +func TestConversionError_WithInterfaceType(t *testing.T) { + err := NewConversionError(ErrorCodeInvalidInput, "test").WithInterfaceType("CHAT") + assert.Equal(t, "CHAT", err.InterfaceType) +} + +func TestConversionError_FullBuilder(t *testing.T) { + err := NewConversionError(ErrorCodeInvalidInput, "bad"). + WithClientProtocol("openai"). + WithProviderProtocol("anthropic"). + WithInterfaceType("CHAT"). + WithDetail("field", "model"). + WithCause(errors.New("root")) + + assert.Equal(t, ErrorCodeInvalidInput, err.Code) + assert.Equal(t, "openai", err.ClientProtocol) + assert.Equal(t, "anthropic", err.ProviderProtocol) + assert.Equal(t, "CHAT", err.InterfaceType) + assert.Equal(t, "model", err.Details["field"]) + assert.Equal(t, "root", err.Cause.Error()) +} + +func TestEngine_Use(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + called := false + engine.Use(&testMiddleware{fn: func(req *canonical.CanonicalRequest, cp, pp string, ctx *ConversionContext) (*canonical.CanonicalRequest, error) { + called = true + return req, nil + }}) + + clientAdapter := newMockAdapter("client", false) + clientAdapter.decodeReqFn = func(raw []byte) (*canonical.CanonicalRequest, error) { + return &canonical.CanonicalRequest{Model: "test"}, nil + } + providerAdapter := newMockAdapter("provider", false) + providerAdapter.encodeReqFn = func(req *canonical.CanonicalRequest, p *TargetProvider) ([]byte, error) { + return json.Marshal(req) + } + _ = engine.RegisterAdapter(clientAdapter) + _ = engine.RegisterAdapter(providerAdapter) + + _, err := engine.ConvertHttpRequest(HTTPRequestSpec{ + URL: "/v1/chat/completions", Method: "POST", Body: []byte(`{}`), + }, "client", "provider", NewTargetProvider("https://example.com", "key", "model")) + require.NoError(t, err) + assert.True(t, called) +} + +func TestConvertHttpRequest_DecodeError(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + clientAdapter := newMockAdapter("client", false) + clientAdapter.decodeReqFn = func(raw []byte) (*canonical.CanonicalRequest, error) { + return nil, errors.New("decode failed") + } + _ = engine.RegisterAdapter(clientAdapter) + _ = engine.RegisterAdapter(newMockAdapter("provider", false)) + + _, err := engine.ConvertHttpRequest(HTTPRequestSpec{ + URL: "/v1/chat/completions", Method: "POST", Body: []byte(`{}`), + }, "client", "provider", NewTargetProvider("", "", "")) + assert.Error(t, err) +} + +func TestConvertHttpRequest_EncodeError(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + _ = engine.RegisterAdapter(newMockAdapter("client", false)) + providerAdapter := newMockAdapter("provider", false) + providerAdapter.encodeReqFn = func(req *canonical.CanonicalRequest, p *TargetProvider) ([]byte, error) { + return nil, errors.New("encode failed") + } + _ = engine.RegisterAdapter(providerAdapter) + + _, err := engine.ConvertHttpRequest(HTTPRequestSpec{ + URL: "/v1/chat/completions", Method: "POST", Body: []byte(`{}`), + }, "client", "provider", NewTargetProvider("", "", "")) + assert.Error(t, err) +} + +func TestConvertHttpResponse_CrossProtocol(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + + clientAdapter := newMockAdapter("client", false) + clientAdapter.encodeRespFn = func(resp *canonical.CanonicalResponse) ([]byte, error) { + return json.Marshal(map[string]string{"id": resp.ID}) + } + providerAdapter := newMockAdapter("provider", false) + providerAdapter.decodeRespFn = func(raw []byte) (*canonical.CanonicalResponse, error) { + return &canonical.CanonicalResponse{ID: "resp-1", Model: "test"}, nil + } + _ = engine.RegisterAdapter(clientAdapter) + _ = engine.RegisterAdapter(providerAdapter) + + result, err := engine.ConvertHttpResponse(HTTPResponseSpec{ + StatusCode: 200, Body: []byte(`{"id":"resp-1"}`), + }, "client", "provider", InterfaceTypeChat) + require.NoError(t, err) + assert.Equal(t, 200, result.StatusCode) + assert.Contains(t, string(result.Body), "resp-1") +} + +func TestConvertHttpResponse_DecodeError(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + providerAdapter := newMockAdapter("provider", false) + providerAdapter.decodeRespFn = func(raw []byte) (*canonical.CanonicalResponse, error) { + return nil, errors.New("decode error") + } + _ = engine.RegisterAdapter(providerAdapter) + _ = engine.RegisterAdapter(newMockAdapter("client", false)) + + _, err := engine.ConvertHttpResponse(HTTPResponseSpec{Body: []byte(`{}`)}, "client", "provider", InterfaceTypeChat) + assert.Error(t, err) +} + +func TestConvertHttpRequest_EmbeddingInterface(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + + clientAdapter := newMockAdapter("client", false) + clientAdapter.ifaceType = InterfaceTypeEmbeddings + clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeEmbeddings: true} + clientAdapter.decodeReqFn = func(raw []byte) (*canonical.CanonicalRequest, error) { + return &canonical.CanonicalRequest{Model: "test"}, nil + } + providerAdapter := newMockAdapter("provider", false) + providerAdapter.ifaceType = InterfaceTypeEmbeddings + providerAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeEmbeddings: true} + _ = engine.RegisterAdapter(clientAdapter) + _ = engine.RegisterAdapter(providerAdapter) + + result, err := engine.ConvertHttpRequest(HTTPRequestSpec{ + URL: "/v1/embeddings", Method: "POST", Body: []byte(`{"model":"text-embedding","input":"hello"}`), + }, "client", "provider", NewTargetProvider("https://example.com", "key", "model")) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestConvertHttpRequest_RerankInterface(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + + clientAdapter := newMockAdapter("client", false) + clientAdapter.ifaceType = InterfaceTypeRerank + clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeRerank: true} + providerAdapter := newMockAdapter("provider", false) + providerAdapter.ifaceType = InterfaceTypeRerank + providerAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeRerank: true} + _ = engine.RegisterAdapter(clientAdapter) + _ = engine.RegisterAdapter(providerAdapter) + + result, err := engine.ConvertHttpRequest(HTTPRequestSpec{ + URL: "/v1/rerank", Method: "POST", Body: []byte(`{"model":"rerank","query":"test","documents":["a"]}`), + }, "client", "provider", NewTargetProvider("https://example.com", "key", "model")) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestConvertHttpResponse_EmbeddingInterface(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + + clientAdapter := newMockAdapter("client", false) + clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeEmbeddings: true} + providerAdapter := newMockAdapter("provider", false) + providerAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeEmbeddings: true} + _ = engine.RegisterAdapter(clientAdapter) + _ = engine.RegisterAdapter(providerAdapter) + + result, err := engine.ConvertHttpResponse(HTTPResponseSpec{ + StatusCode: 200, Body: []byte(`{"object":"list","data":[],"model":"test"}`), + }, "client", "provider", InterfaceTypeEmbeddings) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestConvertHttpResponse_RerankInterface(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + + clientAdapter := newMockAdapter("client", false) + clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeRerank: true} + providerAdapter := newMockAdapter("provider", false) + providerAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeRerank: true} + _ = engine.RegisterAdapter(clientAdapter) + _ = engine.RegisterAdapter(providerAdapter) + + result, err := engine.ConvertHttpResponse(HTTPResponseSpec{ + StatusCode: 200, Body: []byte(`{"results":[],"model":"test"}`), + }, "client", "provider", InterfaceTypeRerank) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestConvertHttpRequest_ModelsInterface_Passthrough(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + clientAdapter := newMockAdapter("client", false) + clientAdapter.ifaceType = InterfaceTypeModels + providerAdapter := newMockAdapter("provider", false) + providerAdapter.ifaceType = InterfaceTypeModels + _ = engine.RegisterAdapter(clientAdapter) + _ = engine.RegisterAdapter(providerAdapter) + + body := []byte(`{"object":"list","data":[]}`) + result, err := engine.ConvertHttpRequest(HTTPRequestSpec{ + URL: "/v1/models", Method: "GET", Body: body, + }, "client", "provider", NewTargetProvider("https://example.com", "key", "")) + require.NoError(t, err) + assert.Equal(t, body, result.Body) +} + +func TestConvertHttpResponse_ModelsInterface(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + clientAdapter := newMockAdapter("client", false) + clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeModels: true} + providerAdapter := newMockAdapter("provider", false) + providerAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeModels: true} + _ = engine.RegisterAdapter(clientAdapter) + _ = engine.RegisterAdapter(providerAdapter) + + result, err := engine.ConvertHttpResponse(HTTPResponseSpec{ + StatusCode: 200, Body: []byte(`{"object":"list","data":[]}`), + }, "client", "provider", InterfaceTypeModels) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestConvertHttpResponse_ModelInfoInterface(t *testing.T) { + registry := NewMemoryRegistry() + engine := NewConversionEngine(registry) + clientAdapter := newMockAdapter("client", false) + clientAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeModelInfo: true} + providerAdapter := newMockAdapter("provider", false) + providerAdapter.supportsIface = map[InterfaceType]bool{InterfaceTypeModelInfo: true} + _ = engine.RegisterAdapter(clientAdapter) + _ = engine.RegisterAdapter(providerAdapter) + + result, err := engine.ConvertHttpResponse(HTTPResponseSpec{ + StatusCode: 200, Body: []byte(`{"id":"gpt-4","object":"model"}`), + }, "client", "provider", InterfaceTypeModelInfo) + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestRegistry_ListProtocols(t *testing.T) { + registry := NewMemoryRegistry() + _ = registry.Register(newMockAdapter("openai", true)) + _ = registry.Register(newMockAdapter("anthropic", true)) + + protocols := registry.ListProtocols() + assert.Len(t, protocols, 2) + assert.Contains(t, protocols, "openai") + assert.Contains(t, protocols, "anthropic") +} + +func TestRegistry_ConcurrentAccess(t *testing.T) { + registry := NewMemoryRegistry() + done := make(chan bool, 2) + + go func() { + for i := 0; i < 100; i++ { + _ = registry.Register(newMockAdapter("proto-"+string(rune(i)), true)) + } + done <- true + }() + + go func() { + for i := 0; i < 100; i++ { + _, _ = registry.Get("proto-" + string(rune(i))) + } + _ = registry.ListProtocols() + done <- true + }() + + <-done + <-done +} + +func TestNewConversionContext(t *testing.T) { + ctx := NewConversionContext(InterfaceTypeChat) + assert.NotEmpty(t, ctx.ConversionID) + assert.Equal(t, InterfaceTypeChat, ctx.InterfaceType) + assert.NotNil(t, ctx.Metadata) +} + +type testMiddleware struct { + fn func(req *canonical.CanonicalRequest, clientProtocol, providerProtocol string, ctx *ConversionContext) (*canonical.CanonicalRequest, error) +} + +func (m *testMiddleware) Intercept(req *canonical.CanonicalRequest, clientProtocol, providerProtocol string, ctx *ConversionContext) (*canonical.CanonicalRequest, error) { + if m.fn != nil { + return m.fn(req, clientProtocol, providerProtocol, ctx) + } + return req, nil +} + +func (m *testMiddleware) InterceptStreamEvent(event *canonical.CanonicalStreamEvent, clientProtocol, providerProtocol string, ctx *ConversionContext) (*canonical.CanonicalStreamEvent, error) { + return event, nil +} + +var _ = json.Marshal diff --git a/backend/internal/conversion/openai/stream_decoder_test.go b/backend/internal/conversion/openai/stream_decoder_test.go index 21a147d..3c4a4ad 100644 --- a/backend/internal/conversion/openai/stream_decoder_test.go +++ b/backend/internal/conversion/openai/stream_decoder_test.go @@ -353,3 +353,120 @@ func TestStreamDecoder_MultipleChunks_Text(t *testing.T) { } assert.Equal(t, []string{"你好", "世界"}, deltas) } + +func TestStreamDecoder_UTF8Truncation(t *testing.T) { + d := NewStreamDecoder() + + chunk := map[string]any{ + "id": "chatcmpl-utf8", + "model": "gpt-4", + "choices": []any{ + map[string]any{ + "index": 0, + "delta": map[string]any{"content": "你"}, + }, + }, + } + data, _ := json.Marshal(chunk) + sseData := []byte("data: " + string(data) + "\n\n") + + mid := len(sseData) - 5 + part1 := sseData[:mid] + part2 := sseData[mid:] + + events1 := d.ProcessChunk(part1) + for _, e := range events1 { + if e.Type == canonical.EventContentBlockDelta && e.Delta != nil { + assert.Equal(t, "你", e.Delta.Text) + } + } + + events2 := d.ProcessChunk(part2) + _ = events2 +} + +func TestStreamDecoder_ToolCallSubsequentDelta(t *testing.T) { + d := NewStreamDecoder() + + idx := 0 + chunk1 := map[string]any{ + "id": "chatcmpl-tc", + "model": "gpt-4", + "choices": []any{ + map[string]any{ + "index": 0, + "delta": map[string]any{ + "tool_calls": []any{ + map[string]any{ + "index": &idx, + "id": "call_1", + "type": "function", + "function": map[string]any{ + "name": "get_weather", + "arguments": "", + }, + }, + }, + }, + }, + }, + } + chunk2 := map[string]any{ + "id": "chatcmpl-tc", + "model": "gpt-4", + "choices": []any{ + map[string]any{ + "index": 0, + "delta": map[string]any{ + "tool_calls": []any{ + map[string]any{ + "index": &idx, + "function": map[string]any{ + "arguments": "{\"city\":\"Beijing\"}", + }, + }, + }, + }, + }, + }, + } + + events1 := d.ProcessChunk(makeChunkSSE(chunk1)) + require.NotEmpty(t, events1) + + events2 := d.ProcessChunk(makeChunkSSE(chunk2)) + require.NotEmpty(t, events2) + + foundInputJSON := false + for _, e := range events2 { + if e.Type == canonical.EventContentBlockDelta && e.Delta != nil && e.Delta.Type == "input_json_delta" { + foundInputJSON = true + assert.Equal(t, "{\"city\":\"Beijing\"}", e.Delta.PartialJSON) + } + } + assert.True(t, foundInputJSON, "subsequent tool call delta should emit input_json_delta") +} + +func TestStreamDecoder_InvalidJSON(t *testing.T) { + d := NewStreamDecoder() + + raw := []byte("data: {invalid json}\n\n") + events := d.ProcessChunk(raw) + assert.Nil(t, events) +} + +func TestStreamDecoder_NonDataLines(t *testing.T) { + d := NewStreamDecoder() + + raw := []byte(": comment line\ndata: {\"id\":\"1\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n") + events := d.ProcessChunk(raw) + require.NotEmpty(t, events) + found := false + for _, e := range events { + if e.Type == canonical.EventContentBlockDelta && e.Delta != nil { + found = true + assert.Equal(t, "hi", e.Delta.Text) + } + } + assert.True(t, found) +} diff --git a/backend/internal/conversion/openai/stream_encoder.go b/backend/internal/conversion/openai/stream_encoder.go index 775fe18..4323aaa 100644 --- a/backend/internal/conversion/openai/stream_encoder.go +++ b/backend/internal/conversion/openai/stream_encoder.go @@ -137,15 +137,10 @@ func (e *StreamEncoder) encodeInputJSONDelta(event canonical.CanonicalStreamEven } // 后续 delta,仅含 arguments - // 通过 index 查找 tool call + // 使用 canonical 事件中的 index 直接映射到 OpenAI tool_calls index tcIdx := 0 if event.Index != nil { - for id, idx := range e.toolCallIndexMap { - if idx == tcIdx { - _ = id - break - } - } + tcIdx = *event.Index } delta := map[string]any{ "tool_calls": []map[string]any{{ diff --git a/backend/internal/conversion/openai/stream_encoder_test.go b/backend/internal/conversion/openai/stream_encoder_test.go index 83ca20f..a8c46c1 100644 --- a/backend/internal/conversion/openai/stream_encoder_test.go +++ b/backend/internal/conversion/openai/stream_encoder_test.go @@ -170,3 +170,116 @@ func TestStreamEncoder_MessageDelta_WithUsage(t *testing.T) { assert.Contains(t, s, "usage") assert.Contains(t, s, "prompt_tokens") } + +func TestStreamEncoder_InputJSONDelta_SubsequentDelta(t *testing.T) { + e := NewStreamEncoder() + + e.EncodeEvent(canonical.NewContentBlockStartEvent(0, canonical.StreamContentBlock{ + Type: "tool_use", + ID: "call_1", + Name: "get_weather", + })) + + e.EncodeEvent(canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{ + Type: string(canonical.DeltaTypeInputJSON), + PartialJSON: "{\"city\":", + })) + + event := canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{ + Type: string(canonical.DeltaTypeInputJSON), + PartialJSON: "\"Beijing\"}", + }) + + chunks := e.EncodeEvent(event) + require.NotEmpty(t, chunks) + + s := string(chunks[0]) + assert.Contains(t, s, "tool_calls") + assert.Contains(t, s, "Beijing") +} + +func TestStreamEncoder_MessageStart_NilMessage(t *testing.T) { + e := NewStreamEncoder() + event := canonical.CanonicalStreamEvent{Type: canonical.EventMessageStart} + + chunks := e.EncodeEvent(event) + require.Len(t, chunks, 1) + + s := string(chunks[0]) + assert.Contains(t, s, "chat.completion.chunk") +} + +func TestStreamEncoder_UnknownEvent_ReturnsNil(t *testing.T) { + e := NewStreamEncoder() + event := canonical.CanonicalStreamEvent{Type: "unknown_type"} + chunks := e.EncodeEvent(event) + assert.Nil(t, chunks) +} + +func TestStreamEncoder_ContentBlockDelta_NilDelta(t *testing.T) { + e := NewStreamEncoder() + event := canonical.CanonicalStreamEvent{Type: canonical.EventContentBlockDelta} + chunks := e.EncodeEvent(event) + assert.Nil(t, chunks) +} + +func TestStreamEncoder_MultiToolCall_IndexMapping(t *testing.T) { + e := NewStreamEncoder() + + e.EncodeEvent(canonical.NewContentBlockStartEvent(0, canonical.StreamContentBlock{ + Type: "tool_use", + ID: "call_1", + Name: "get_weather", + })) + + firstDelta := canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{ + Type: string(canonical.DeltaTypeInputJSON), + PartialJSON: `{"city":"北京"}`, + }) + chunks := e.EncodeEvent(firstDelta) + require.NotEmpty(t, chunks) + s := string(chunks[0]) + assert.Contains(t, s, `"index":0`) + assert.Contains(t, s, "get_weather") + assert.Contains(t, s, "北京") + + e.EncodeEvent(canonical.NewContentBlockStopEvent(0)) + + e.EncodeEvent(canonical.NewContentBlockStartEvent(1, canonical.StreamContentBlock{ + Type: "tool_use", + ID: "call_2", + Name: "get_time", + })) + + secondDelta := canonical.NewContentBlockDeltaEvent(1, canonical.StreamDelta{ + Type: string(canonical.DeltaTypeInputJSON), + PartialJSON: `{"tz":"Asia/Shanghai"}`, + }) + chunks = e.EncodeEvent(secondDelta) + require.NotEmpty(t, chunks) + s = string(chunks[0]) + assert.Contains(t, s, `"index":1`) + assert.Contains(t, s, "get_time") + assert.Contains(t, s, "Asia/Shanghai") + + subsequentDelta0 := canonical.NewContentBlockDeltaEvent(0, canonical.StreamDelta{ + Type: string(canonical.DeltaTypeInputJSON), + PartialJSON: `"more_data"`, + }) + chunks = e.EncodeEvent(subsequentDelta0) + require.NotEmpty(t, chunks) + s = string(chunks[0]) + assert.Contains(t, s, `"index":0`) + assert.NotContains(t, s, "get_weather") + assert.Contains(t, s, "more_data") + + subsequentDelta1 := canonical.NewContentBlockDeltaEvent(1, canonical.StreamDelta{ + Type: string(canonical.DeltaTypeInputJSON), + PartialJSON: `"more_time"`, + }) + chunks = e.EncodeEvent(subsequentDelta1) + require.NotEmpty(t, chunks) + s = string(chunks[0]) + assert.Contains(t, s, `"index":1`) + assert.Contains(t, s, "more_time") +} diff --git a/backend/internal/conversion/openai/supplemental_test.go b/backend/internal/conversion/openai/supplemental_test.go new file mode 100644 index 0000000..8056c14 --- /dev/null +++ b/backend/internal/conversion/openai/supplemental_test.go @@ -0,0 +1,434 @@ +package openai + +import ( + "encoding/json" + "testing" + + "nex/backend/internal/conversion" + "nex/backend/internal/conversion/canonical" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeEmbeddingRequest(t *testing.T) { + body := []byte(`{"model":"text-embedding-3-small","input":"hello world","encoding_format":"float","dimensions":256}`) + req, err := decodeEmbeddingRequest(body) + require.NoError(t, err) + assert.Equal(t, "text-embedding-3-small", req.Model) + assert.Equal(t, "hello world", req.Input) + assert.Equal(t, "float", req.EncodingFormat) + require.NotNil(t, req.Dimensions) + assert.Equal(t, 256, *req.Dimensions) +} + +func TestDecodeEmbeddingRequest_ArrayInput(t *testing.T) { + body := []byte(`{"model":"text-embedding","input":["hello","world"]}`) + req, err := decodeEmbeddingRequest(body) + require.NoError(t, err) + assert.Equal(t, "text-embedding", req.Model) + inputArr, ok := req.Input.([]any) + require.True(t, ok) + assert.Len(t, inputArr, 2) +} + +func TestDecodeEmbeddingRequest_InvalidJSON(t *testing.T) { + _, err := decodeEmbeddingRequest([]byte(`invalid`)) + assert.Error(t, err) +} + +func TestDecodeEmbeddingResponse(t *testing.T) { + body := []byte(`{ + "object": "list", + "data": [{"index": 0, "embedding": [0.1, 0.2, 0.3]}], + "model": "text-embedding-3-small", + "usage": {"prompt_tokens": 5, "total_tokens": 5} + }`) + resp, err := decodeEmbeddingResponse(body) + require.NoError(t, err) + assert.Equal(t, "text-embedding-3-small", resp.Model) + assert.Len(t, resp.Data, 1) + assert.Equal(t, 0, resp.Data[0].Index) + assert.Equal(t, 5, resp.Usage.PromptTokens) +} + +func TestDecodeRerankRequest(t *testing.T) { + topN := 3 + returnDocs := true + body := []byte(`{"model":"rerank-1","query":"what is AI","documents":["doc1","doc2"],"top_n":3,"return_documents":true}`) + req, err := decodeRerankRequest(body) + require.NoError(t, err) + assert.Equal(t, "rerank-1", req.Model) + assert.Equal(t, "what is AI", req.Query) + assert.Equal(t, []string{"doc1", "doc2"}, req.Documents) + require.NotNil(t, req.TopN) + assert.Equal(t, topN, *req.TopN) + require.NotNil(t, req.ReturnDocuments) + assert.Equal(t, returnDocs, *req.ReturnDocuments) +} + +func TestDecodeRerankResponse(t *testing.T) { + doc := "relevant doc" + body := []byte(`{ + "results": [{"index": 0, "relevance_score": 0.95, "document": "relevant doc"}], + "model": "rerank-1" + }`) + resp, err := decodeRerankResponse(body) + require.NoError(t, err) + assert.Equal(t, "rerank-1", resp.Model) + assert.Len(t, resp.Results, 1) + assert.Equal(t, 0, resp.Results[0].Index) + assert.InDelta(t, 0.95, resp.Results[0].RelevanceScore, 0.001) + require.NotNil(t, resp.Results[0].Document) + assert.Equal(t, doc, *resp.Results[0].Document) +} + +func TestDecodeModelInfoResponse(t *testing.T) { + body := []byte(`{"id":"gpt-4","object":"model","created":1700000000,"owned_by":"openai"}`) + info, err := decodeModelInfoResponse(body) + require.NoError(t, err) + assert.Equal(t, "gpt-4", info.ID) + assert.Equal(t, int64(1700000000), info.Created) + assert.Equal(t, "openai", info.OwnedBy) +} + +func TestEncodeEmbeddingRequest(t *testing.T) { + req := &canonical.CanonicalEmbeddingRequest{ + Model: "text-embedding-3-small", + Input: "hello", + EncodingFormat: "float", + } + provider := conversion.NewTargetProvider("", "key", "my-embedding-model") + + body, err := encodeEmbeddingRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, "my-embedding-model", result["model"]) + assert.Equal(t, "hello", result["input"]) + assert.Equal(t, "float", result["encoding_format"]) +} + +func TestEncodeEmbeddingRequest_WithDimensions(t *testing.T) { + dims := 256 + req := &canonical.CanonicalEmbeddingRequest{ + Model: "text-embedding", + Input: "test", + Dimensions: &dims, + } + provider := conversion.NewTargetProvider("", "key", "model") + + body, err := encodeEmbeddingRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, float64(256), result["dimensions"]) +} + +func TestEncodeEmbeddingResponse(t *testing.T) { + resp := &canonical.CanonicalEmbeddingResponse{ + Data: []canonical.EmbeddingData{{Index: 0, Embedding: []float64{0.1, 0.2}}}, + Model: "text-embedding", + Usage: canonical.EmbeddingUsage{PromptTokens: 3, TotalTokens: 3}, + } + + body, err := encodeEmbeddingResponse(resp) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, "list", result["object"]) + assert.Equal(t, "text-embedding", result["model"]) +} + +func TestEncodeRerankRequest(t *testing.T) { + topN := 5 + req := &canonical.CanonicalRerankRequest{ + Model: "rerank-1", + Query: "what is AI", + Documents: []string{"doc1", "doc2"}, + TopN: &topN, + } + provider := conversion.NewTargetProvider("", "key", "my-rerank-model") + + body, err := encodeRerankRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, "my-rerank-model", result["model"]) + assert.Equal(t, "what is AI", result["query"]) +} + +func TestEncodeRerankResponse(t *testing.T) { + doc := "relevant passage" + resp := &canonical.CanonicalRerankResponse{ + Results: []canonical.RerankResult{ + {Index: 0, RelevanceScore: 0.95, Document: &doc}, + }, + Model: "rerank-1", + } + + body, err := encodeRerankResponse(resp) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, "rerank-1", result["model"]) + results := result["results"].([]any) + assert.Len(t, results, 1) +} + +func TestEncodeModelInfoResponse(t *testing.T) { + info := &canonical.CanonicalModelInfo{ + ID: "gpt-4", + Name: "GPT-4", + Created: 1700000000, + OwnedBy: "openai", + } + + body, err := encodeModelInfoResponse(info) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, "gpt-4", result["id"]) + assert.Equal(t, "model", result["object"]) +} + +func TestDecodeEmbeddingResponse_InvalidJSON(t *testing.T) { + _, err := decodeEmbeddingResponse([]byte(`invalid`)) + assert.Error(t, err) +} + +func TestDecodeRerankRequest_InvalidJSON(t *testing.T) { + _, err := decodeRerankRequest([]byte(`invalid`)) + assert.Error(t, err) +} + +func TestDecodeRerankResponse_InvalidJSON(t *testing.T) { + _, err := decodeRerankResponse([]byte(`invalid`)) + assert.Error(t, err) +} + +func TestDecodeModelInfoResponse_InvalidJSON(t *testing.T) { + _, err := decodeModelInfoResponse([]byte(`invalid`)) + assert.Error(t, err) +} + +func TestDecodeRequest_ThinkingNone(t *testing.T) { + body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`) + req, err := decodeRequest(body) + require.NoError(t, err) + require.NotNil(t, req.Thinking) + assert.Equal(t, "disabled", req.Thinking.Type) +} + +func TestDecodeRequest_ThinkingMinimal(t *testing.T) { + body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"minimal"}`) + req, err := decodeRequest(body) + require.NoError(t, err) + require.NotNil(t, req.Thinking) + assert.Equal(t, "enabled", req.Thinking.Type) + assert.Equal(t, "low", req.Thinking.Effort) +} + +func TestDecodeRequest_OutputFormat_Text(t *testing.T) { + body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"text"}}`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Nil(t, req.OutputFormat) +} + +func TestDecodeRequest_DeprecatedFunctionCall(t *testing.T) { + body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"function_call":"auto","functions":[{"name":"fn1","parameters":{}}]}`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Equal(t, "auto", req.ToolChoice.Type) + assert.Len(t, req.Tools, 1) +} + +func TestDecodeRequest_FunctionMessage(t *testing.T) { + body := []byte(`{ + "model": "gpt-4", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "function", "name": "get_weather", "content": "sunny"} + ] + }`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Len(t, req.Messages, 2) + assert.Equal(t, canonical.RoleTool, req.Messages[1].Role) +} + +func TestDecodeRequest_StopString(t *testing.T) { + body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stop":"END"}`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Equal(t, []string{"END"}, req.Parameters.StopSequences) +} + +func TestDecodeRequest_StopEmptyString(t *testing.T) { + body := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stop":""}`) + req, err := decodeRequest(body) + require.NoError(t, err) + assert.Nil(t, req.Parameters.StopSequences) +} + +func TestDecodeResponse_EmptyChoices(t *testing.T) { + body := []byte(`{"id":"resp-1","model":"gpt-4","choices":[],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}`) + resp, err := decodeResponse(body) + require.NoError(t, err) + assert.Equal(t, "resp-1", resp.ID) + assert.Len(t, resp.Content, 1) + assert.Equal(t, "", resp.Content[0].Text) +} + +func TestDecodeResponse_FunctionCallFinishReason(t *testing.T) { + body := []byte(`{ + "id":"r1","model":"gpt-4", + "choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"function_call"}], + "usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2} + }`) + resp, err := decodeResponse(body) + require.NoError(t, err) + assert.Equal(t, canonical.StopReasonToolUse, *resp.StopReason) +} + +func TestEncodeRequest_DisabledThinking(t *testing.T) { + req := &canonical.CanonicalRequest{ + Model: "gpt-4", + Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}}, + Thinking: &canonical.ThinkingConfig{Type: "disabled"}, + } + provider := conversion.NewTargetProvider("", "key", "model") + + body, err := encodeRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, "none", result["reasoning_effort"]) +} + +func TestEncodeRequest_OutputFormat_JSONObject(t *testing.T) { + req := &canonical.CanonicalRequest{ + Model: "gpt-4", + Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}}, + OutputFormat: &canonical.OutputFormat{Type: "json_object"}, + } + provider := conversion.NewTargetProvider("", "key", "model") + + body, err := encodeRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + rf, ok := result["response_format"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "json_object", rf["type"]) +} + +func TestEncodeRequest_PublicFields(t *testing.T) { + parallel := true + req := &canonical.CanonicalRequest{ + Model: "gpt-4", + Messages: []canonical.CanonicalMessage{{Role: canonical.RoleUser, Content: []canonical.ContentBlock{canonical.NewTextBlock("hi")}}}, + UserID: "user-123", + ParallelToolUse: ¶llel, + } + provider := conversion.NewTargetProvider("", "key", "model") + + body, err := encodeRequest(req, provider) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, "user-123", result["user"]) + assert.Equal(t, true, result["parallel_tool_calls"]) +} + +func TestEncodeResponse_UsageWithCacheAndReasoning(t *testing.T) { + cache := 80 + reasoning := 20 + sr := canonical.StopReasonEndTurn + resp := &canonical.CanonicalResponse{ + ID: "r1", + Model: "gpt-4", + Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")}, + StopReason: &sr, + Usage: canonical.CanonicalUsage{ + InputTokens: 100, + OutputTokens: 50, + CacheReadTokens: &cache, + ReasoningTokens: &reasoning, + }, + } + + body, err := encodeResponse(resp) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + usage := result["usage"].(map[string]any) + assert.Equal(t, float64(100), usage["prompt_tokens"]) + ptd, ok := usage["prompt_tokens_details"].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(80), ptd["cached_tokens"]) + ctd, ok := usage["completion_tokens_details"].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(20), ctd["reasoning_tokens"]) +} + +func TestEncodeResponse_StopReasons(t *testing.T) { + tests := []struct { + name string + stopReason canonical.StopReason + want string + }{ + {"end_turn→stop", canonical.StopReasonEndTurn, "stop"}, + {"max_tokens→length", canonical.StopReasonMaxTokens, "length"}, + {"tool_use→tool_calls", canonical.StopReasonToolUse, "tool_calls"}, + {"content_filter→content_filter", canonical.StopReasonContentFilter, "content_filter"}, + {"stop_sequence→stop", canonical.StopReasonStopSequence, "stop"}, + {"refusal→stop", canonical.StopReasonRefusal, "stop"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sr := tt.stopReason + resp := &canonical.CanonicalResponse{ + ID: "r1", + Model: "gpt-4", + Content: []canonical.ContentBlock{canonical.NewTextBlock("ok")}, + StopReason: &sr, + Usage: canonical.CanonicalUsage{}, + } + body, err := encodeResponse(resp) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(body, &result)) + choices := result["choices"].([]any) + choice := choices[0].(map[string]any) + assert.Equal(t, tt.want, choice["finish_reason"]) + }) + } +} + +func TestMapErrorCode_AllCodes(t *testing.T) { + assert.Equal(t, "invalid_request_error", mapErrorCode(conversion.ErrorCodeInvalidInput)) + assert.Equal(t, "invalid_request_error", mapErrorCode(conversion.ErrorCodeMissingRequiredField)) + assert.Equal(t, "invalid_request_error", mapErrorCode(conversion.ErrorCodeIncompatibleFeature)) + assert.Equal(t, "invalid_request_error", mapErrorCode(conversion.ErrorCodeFieldMappingFailure)) + assert.Equal(t, "invalid_request_error", mapErrorCode(conversion.ErrorCodeToolCallParseError)) + assert.Equal(t, "invalid_request_error", mapErrorCode(conversion.ErrorCodeJSONParseError)) + assert.Equal(t, "invalid_request_error", mapErrorCode(conversion.ErrorCodeProtocolConstraint)) + assert.Equal(t, "server_error", mapErrorCode(conversion.ErrorCodeStreamStateError)) + assert.Equal(t, "server_error", mapErrorCode(conversion.ErrorCodeUTF8DecodeError)) + assert.Equal(t, "server_error", mapErrorCode(conversion.ErrorCodeEncodingFailure)) + assert.Equal(t, "server_error", mapErrorCode(conversion.ErrorCodeInterfaceNotSupported)) +} diff --git a/backend/internal/conversion/stream_test.go b/backend/internal/conversion/stream_test.go index 4531cc5..c7f111d 100644 --- a/backend/internal/conversion/stream_test.go +++ b/backend/internal/conversion/stream_test.go @@ -1,6 +1,7 @@ package conversion import ( + "fmt" "testing" "nex/backend/internal/conversion/canonical" @@ -128,3 +129,71 @@ func TestCanonicalStreamConverter_EmptyDecoder(t *testing.T) { assert.Nil(t, result) } + +func TestCanonicalStreamConverter_MiddlewareError_Continue(t *testing.T) { + event := canonical.NewMessageStartEvent("id-1", "gpt-4") + decoder := &mockStreamDecoder{ + chunks: [][]canonical.CanonicalStreamEvent{{event}}, + } + encoder := &mockStreamEncoder{ + events: [][]byte{[]byte("data: ok\n\n")}, + } + + chain := NewMiddlewareChain() + chain.Use(&errorMiddleware{}) + ctx := NewConversionContext(InterfaceTypeChat) + + converter := NewCanonicalStreamConverterWithMiddleware(decoder, encoder, chain, *ctx, "openai", "anthropic") + result := converter.ProcessChunk([]byte("raw")) + + assert.Nil(t, result, "middleware error should cause the event to be skipped (continue)") +} + +func TestCanonicalStreamConverter_Flush_MiddlewareError_Continue(t *testing.T) { + event := canonical.NewMessageStartEvent("id-1", "gpt-4") + decoder := &mockStreamDecoder{ + flush: []canonical.CanonicalStreamEvent{event}, + } + encoder := &mockStreamEncoder{ + events: [][]byte{[]byte("data: ok\n\n")}, + flush: [][]byte{[]byte("data: encoder_flush\n\n")}, + } + + chain := NewMiddlewareChain() + chain.Use(&errorMiddleware{}) + ctx := NewConversionContext(InterfaceTypeChat) + + converter := NewCanonicalStreamConverterWithMiddleware(decoder, encoder, chain, *ctx, "openai", "anthropic") + result := converter.Flush() + + assert.Len(t, result, 1) + assert.Equal(t, []byte("data: encoder_flush\n\n"), result[0]) +} + +func TestCanonicalStreamConverter_Flush_DecoderAndEncoderBothProduce(t *testing.T) { + event := canonical.NewMessageStartEvent("id-1", "gpt-4") + decoder := &mockStreamDecoder{ + flush: []canonical.CanonicalStreamEvent{event}, + } + encoder := &mockStreamEncoder{ + events: [][]byte{[]byte("data: decoder_flush\n\n")}, + flush: [][]byte{[]byte("data: encoder_flush\n\n")}, + } + + converter := NewCanonicalStreamConverter(decoder, encoder) + result := converter.Flush() + + assert.Len(t, result, 2) + assert.Equal(t, []byte("data: decoder_flush\n\n"), result[0]) + assert.Equal(t, []byte("data: encoder_flush\n\n"), result[1]) +} + +type errorMiddleware struct{} + +func (m *errorMiddleware) Intercept(req *canonical.CanonicalRequest, clientProtocol, providerProtocol string, ctx *ConversionContext) (*canonical.CanonicalRequest, error) { + return nil, fmt.Errorf("middleware error") +} + +func (m *errorMiddleware) InterceptStreamEvent(event *canonical.CanonicalStreamEvent, clientProtocol, providerProtocol string, ctx *ConversionContext) (*canonical.CanonicalStreamEvent, error) { + return nil, fmt.Errorf("stream middleware error") +} diff --git a/backend/internal/handler/handler_supplemental_test.go b/backend/internal/handler/handler_supplemental_test.go new file mode 100644 index 0000000..82a43a5 --- /dev/null +++ b/backend/internal/handler/handler_supplemental_test.go @@ -0,0 +1,165 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "nex/backend/internal/domain" +) + +func TestProviderHandler_CreateProvider_Success(t *testing.T) { + h := NewProviderHandler(&mockProviderService{}) + + body, _ := json.Marshal(map[string]string{ + "id": "p1", + "name": "Test", + "api_key": "sk-test", + "base_url": "https://api.test.com", + }) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/providers", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.CreateProvider(c) + assert.Equal(t, 201, w.Code) + + var result domain.Provider + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Equal(t, "p1", result.ID) + assert.Contains(t, result.APIKey, "***") +} + +func TestProviderHandler_CreateProvider_WithProtocol(t *testing.T) { + h := NewProviderHandler(&mockProviderService{}) + + body, _ := json.Marshal(map[string]string{ + "id": "p1", + "name": "Test", + "api_key": "sk-test", + "base_url": "https://api.test.com", + "protocol": "anthropic", + }) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/providers", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.CreateProvider(c) + assert.Equal(t, 201, w.Code) +} + +func TestProviderHandler_UpdateProvider(t *testing.T) { + h := NewProviderHandler(&mockProviderService{ + provider: &domain.Provider{ID: "p1", Name: "Updated", APIKey: "***"}, + }) + + body, _ := json.Marshal(map[string]string{"name": "Updated"}) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "p1"}} + c.Request = httptest.NewRequest("PUT", "/api/providers/p1", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.UpdateProvider(c) + assert.Equal(t, 200, w.Code) +} + +func TestProviderHandler_UpdateProvider_InvalidBody(t *testing.T) { + h := NewProviderHandler(&mockProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "p1"}} + c.Request = httptest.NewRequest("PUT", "/api/providers/p1", nil) + + h.UpdateProvider(c) + assert.Equal(t, 400, w.Code) +} + +func TestProviderHandler_DeleteProvider(t *testing.T) { + h := NewProviderHandler(&mockProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "p1"}} + c.Request = httptest.NewRequest("DELETE", "/api/providers/p1", bytes.NewReader([]byte{})) + c.Request.Header.Set("Content-Type", "application/json") + + h.DeleteProvider(c) + assert.True(t, w.Code == 204 || w.Code == 200) +} + +func TestModelHandler_DeleteModel(t *testing.T) { + h := NewModelHandler(&mockModelService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "m1"}} + c.Request = httptest.NewRequest("DELETE", "/api/models/m1", bytes.NewReader([]byte{})) + c.Request.Header.Set("Content-Type", "application/json") + + h.DeleteModel(c) + assert.True(t, w.Code == 204 || w.Code == 200) +} + +func TestModelHandler_CreateModel_Success(t *testing.T) { + h := NewModelHandler(&mockModelService{}) + + body, _ := json.Marshal(map[string]string{ + "id": "m1", + "provider_id": "p1", + "model_name": "gpt-4", + }) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/models", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.CreateModel(c) + assert.Equal(t, 201, w.Code) + + var result domain.Model + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Equal(t, "m1", result.ID) +} + +func TestModelHandler_GetModel(t *testing.T) { + h := NewModelHandler(&mockModelService{ + model: &domain.Model{ID: "m1", ModelName: "gpt-4"}, + }) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "m1"}} + c.Request = httptest.NewRequest("GET", "/api/models/m1", nil) + + h.GetModel(c) + assert.Equal(t, 200, w.Code) + + var result domain.Model + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + assert.Equal(t, "gpt-4", result.ModelName) +} + +func TestModelHandler_UpdateModel(t *testing.T) { + h := NewModelHandler(&mockModelService{ + model: &domain.Model{ID: "m1", ModelName: "gpt-4o"}, + }) + + body, _ := json.Marshal(map[string]string{"model_name": "gpt-4o"}) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "m1"}} + c.Request = httptest.NewRequest("PUT", "/api/models/m1", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.UpdateModel(c) + assert.Equal(t, 200, w.Code) +} diff --git a/backend/internal/handler/proxy_handler_test.go b/backend/internal/handler/proxy_handler_test.go new file mode 100644 index 0000000..c04cba0 --- /dev/null +++ b/backend/internal/handler/proxy_handler_test.go @@ -0,0 +1,761 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "nex/backend/internal/conversion" + "nex/backend/internal/conversion/anthropic" + "nex/backend/internal/conversion/openai" + "nex/backend/internal/domain" + "nex/backend/internal/provider" + appErrors "nex/backend/pkg/errors" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +type mockProxyProviderClient struct { + sendFn func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) + sendStreamFn func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) +} + +func (m *mockProxyProviderClient) Send(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + if m.sendFn != nil { + return m.sendFn(ctx, spec) + } + return &conversion.HTTPResponseSpec{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`{"id":"resp-1","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`), + }, nil +} + +func (m *mockProxyProviderClient) SendStream(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) { + if m.sendStreamFn != nil { + return m.sendStreamFn(ctx, spec) + } + ch := make(chan provider.StreamEvent, 10) + go func() { + defer close(ch) + ch <- provider.StreamEvent{Data: []byte("data: {\"id\":\"1\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n")} + ch <- provider.StreamEvent{Data: []byte("data: [DONE]\n\n")} + ch <- provider.StreamEvent{Done: true} + }() + return ch, nil +} + +type mockProxyRoutingService struct { + result *domain.RouteResult + err error +} + +func (m *mockProxyRoutingService) Route(modelName string) (*domain.RouteResult, error) { + return m.result, m.err +} + +type mockProxyProviderService struct { + providers []domain.Provider + err error +} + +func (m *mockProxyProviderService) Create(p *domain.Provider) error { return nil } +func (m *mockProxyProviderService) Get(id string, maskKey bool) (*domain.Provider, error) { return nil, nil } +func (m *mockProxyProviderService) List() ([]domain.Provider, error) { return m.providers, m.err } +func (m *mockProxyProviderService) Update(id string, updates map[string]interface{}) error { return nil } +func (m *mockProxyProviderService) Delete(id string) error { return nil } + +type mockProxyStatsService struct{} + +func (m *mockProxyStatsService) Record(providerID, modelName string) error { return nil } +func (m *mockProxyStatsService) Get(providerID, modelName string, startDate, endDate *time.Time) ([]domain.UsageStats, error) { return nil, nil } +func (m *mockProxyStatsService) Aggregate(stats []domain.UsageStats, groupBy string) []map[string]interface{} { return nil } + +func setupProxyEngine(t *testing.T) *conversion.ConversionEngine { + t.Helper() + registry := conversion.NewMemoryRegistry() + engine := conversion.NewConversionEngine(registry) + require.NoError(t, registry.Register(openai.NewAdapter())) + return engine +} + +func newTestProxyHandler(engine *conversion.ConversionEngine, client *mockProxyProviderClient, routingSvc *mockProxyRoutingService, providerSvc *mockProxyProviderService) *ProxyHandler { + return NewProxyHandler( + engine, + client, + routingSvc, + providerSvc, + &mockProxyStatsService{}, + ) +} + +func TestProxyHandler_HandleProxy_MissingProtocol(t *testing.T) { + engine := setupProxyEngine(t) + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, &mockProxyRoutingService{}, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/", bytes.NewReader([]byte(`{}`))) + + h.HandleProxy(c) + assert.Equal(t, 400, w.Code) +} + +func TestProxyHandler_HandleProxy_NonStreamSuccess(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return &conversion.HTTPResponseSpec{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`{"id":"resp-1","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"Hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}}`), + }, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`))) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) + + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "resp-1", resp["id"]) +} + +func TestProxyHandler_HandleProxy_RoutingError_WithBody(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{err: appErrors.ErrModelNotFound} + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"unknown","messages":[{"role":"user","content":"hi"}]}`))) + + h.HandleProxy(c) + assert.Equal(t, 404, w.Code) +} + +func TestProxyHandler_HandleProxy_ConversionError(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return nil, context.DeadlineExceeded + }, + } + h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`))) + + h.HandleProxy(c) + assert.Equal(t, 500, w.Code) +} + +func TestProxyHandler_HandleProxy_ClientSendError(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return nil, context.DeadlineExceeded + }, + } + h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`))) + + h.HandleProxy(c) + assert.Equal(t, 500, w.Code) +} + +func TestProxyHandler_HandleProxy_StreamSuccess(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + client := &mockProxyProviderClient{ + sendStreamFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) { + ch := make(chan provider.StreamEvent, 10) + go func() { + defer close(ch) + ch <- provider.StreamEvent{Data: []byte("data: {\"id\":\"1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}\n\n")} + ch <- provider.StreamEvent{Data: []byte("data: {\"id\":\"1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\n")} + ch <- provider.StreamEvent{Data: []byte("data: [DONE]\n\n")} + ch <- provider.StreamEvent{Done: true} + }() + return ch, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`))) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) + assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type")) + assert.Contains(t, w.Body.String(), "Hello") +} + +func TestProxyHandler_HandleProxy_StreamError(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + client := &mockProxyProviderClient{ + sendStreamFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) { + return nil, context.DeadlineExceeded + }, + } + h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`))) + + h.HandleProxy(c) + assert.Equal(t, 500, w.Code) +} + +func TestProxyHandler_ForwardPassthrough_GET(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{err: appErrors.ErrModelNotFound} + providerSvc := &mockProxyProviderService{ + providers: []domain.Provider{ + {ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai"}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return &conversion.HTTPResponseSpec{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`{"object":"list","data":[{"id":"gpt-4","object":"model"}]}`), + }, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, providerSvc) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}} + c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) +} + +func TestProxyHandler_ForwardPassthrough_UnsupportedProtocol(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{err: appErrors.ErrModelNotFound} + providerSvc := &mockProxyProviderService{} + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, routingSvc, providerSvc) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "unknown"}, {Key: "path", Value: "/models"}} + c.Request = httptest.NewRequest("GET", "/unknown/v1/models", nil) + + h.HandleProxy(c) + assert.Equal(t, 400, w.Code) +} + +func TestProxyHandler_ForwardPassthrough_NoProviders(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{err: appErrors.ErrModelNotFound} + providerSvc := &mockProxyProviderService{providers: []domain.Provider{}} + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, routingSvc, providerSvc) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}} + c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil) + + h.HandleProxy(c) + assert.Equal(t, 404, w.Code) +} + +func TestExtractModelName(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {"basic", `{"model":"gpt-4","messages":[]}`, "gpt-4"}, + {"nested", `{"stream":true,"model":"claude-3","messages":[]}`, "claude-3"}, + {"no_model", `{"messages":[]}`, ""}, + {"empty", "", ""}, + {"escaped", `{"model":"gpt\"4","messages":[]}`, `gpt\"4`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractModelName([]byte(tt.body)) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestExtractHeaders(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/", nil) + c.Request.Header.Set("Authorization", "Bearer test") + c.Request.Header.Set("Content-Type", "application/json") + + headers := extractHeaders(c) + assert.Equal(t, "Bearer test", headers["Authorization"]) + assert.Equal(t, "application/json", headers["Content-Type"]) +} + +func TestIsStreamRequest(t *testing.T) { + engine := setupProxyEngine(t) + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, &mockProxyRoutingService{}, &mockProxyProviderService{}) + + tests := []struct { + name string + body string + path string + expected bool + }{ + {"stream true chat", `{"model":"gpt-4","stream":true}`, "/v1/chat/completions", true}, + {"stream false chat", `{"model":"gpt-4","stream":false}`, "/v1/chat/completions", false}, + {"no stream field", `{"model":"gpt-4"}`, "/v1/chat/completions", false}, + {"stream true non-chat", `{"model":"gpt-4","stream":true}`, "/v1/models", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := h.isStreamRequest([]byte(tt.body), "openai", tt.path) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestProxyHandler_HandleProxy_ProviderProtocolDefault(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return &conversion.HTTPResponseSpec{ + StatusCode: 200, + Body: []byte(`{"id":"r1","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`), + }, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`))) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) +} + +func TestProxyHandler_WriteConversionError_NonConversionError(t *testing.T) { + engine := setupProxyEngine(t) + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, &mockProxyRoutingService{}, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/", nil) + + h.writeConversionError(c, context.DeadlineExceeded, "openai") + assert.Equal(t, 500, w.Code) +} + +func TestProxyHandler_WriteConversionError_ConversionError(t *testing.T) { + engine := setupProxyEngine(t) + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, &mockProxyRoutingService{}, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/", nil) + + convErr := conversion.NewConversionError(conversion.ErrorCodeInvalidInput, "bad request") + h.writeConversionError(c, convErr, "openai") + assert.Equal(t, 500, w.Code) +} + +func TestProxyHandler_HandleProxy_EmptyBody(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{err: appErrors.ErrModelNotFound} + providerSvc := &mockProxyProviderService{ + providers: []domain.Provider{ + {ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai"}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return &conversion.HTTPResponseSpec{ + StatusCode: 200, + Body: []byte(`{"object":"list","data":[]}`), + }, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, providerSvc) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}} + c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) +} + +func TestProxyHandler_HandleStream_MidStreamError(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + client := &mockProxyProviderClient{ + sendStreamFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) { + ch := make(chan provider.StreamEvent, 10) + go func() { + defer close(ch) + ch <- provider.StreamEvent{Data: []byte("data: {\"id\":\"1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\n")} + ch <- provider.StreamEvent{Error: fmt.Errorf("connection reset by peer")} + }() + return ch, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`))) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) + assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type")) + body := w.Body.String() + assert.Contains(t, body, "Hello") +} + +func TestProxyHandler_HandleStream_FlushOutput(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + client := &mockProxyProviderClient{ + sendStreamFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (<-chan provider.StreamEvent, error) { + ch := make(chan provider.StreamEvent, 10) + go func() { + defer close(ch) + ch <- provider.StreamEvent{Data: []byte("data: {\"id\":\"1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hi\"}}]}\n\n")} + ch <- provider.StreamEvent{Data: []byte("data: [DONE]\n\n")} + ch <- provider.StreamEvent{Done: true} + }() + return ch, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`))) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) + assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type")) + assert.Equal(t, "no-cache", w.Header().Get("Cache-Control")) + assert.Equal(t, "keep-alive", w.Header().Get("Connection")) + body := w.Body.String() + assert.Contains(t, body, "Hi") + assert.Contains(t, body, "[DONE]") +} + +func TestProxyHandler_HandleStream_CreateStreamConverterError(t *testing.T) { + registry := conversion.NewMemoryRegistry() + engine := conversion.NewConversionEngine(registry) + err := registry.Register(openai.NewAdapter()) + require.NoError(t, err) + + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "nonexistent", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`))) + + h.HandleProxy(c) + assert.Equal(t, 500, w.Code) +} + +func TestProxyHandler_HandleStream_ConvertRequestError(t *testing.T) { + registry := conversion.NewMemoryRegistry() + engine := conversion.NewConversionEngine(registry) + require.NoError(t, registry.Register(openai.NewAdapter())) + + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "nonexistent", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`))) + + h.HandleProxy(c) + assert.Equal(t, 500, w.Code) +} + +func TestProxyHandler_HandleNonStream_ConvertResponseError(t *testing.T) { + registry := conversion.NewMemoryRegistry() + engine := conversion.NewConversionEngine(registry) + require.NoError(t, registry.Register(openai.NewAdapter())) + require.NoError(t, registry.Register(anthropic.NewAdapter())) + + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "anthropic", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "claude-3", Enabled: true}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return &conversion.HTTPResponseSpec{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`invalid json`), + }, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"claude-3","messages":[{"role":"user","content":"hi"}]}`))) + + h.HandleProxy(c) + assert.Equal(t, 500, w.Code) +} + +func TestProxyHandler_HandleNonStream_ResponseHeaders(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{ + result: &domain.RouteResult{ + Provider: &domain.Provider{ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai", Enabled: true}, + Model: &domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4", Enabled: true}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return &conversion.HTTPResponseSpec{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json", "X-Custom": "test-value"}, + Body: []byte(`{"id":"r1","object":"chat.completion","model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`), + }, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/chat/completions"}} + c.Request = httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`))) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) + assert.Equal(t, "test-value", w.Header().Get("X-Custom")) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) +} + +func TestProxyHandler_ForwardPassthrough_CrossProtocol(t *testing.T) { + registry := conversion.NewMemoryRegistry() + engine := conversion.NewConversionEngine(registry) + require.NoError(t, registry.Register(openai.NewAdapter())) + + anthropicAdapter := anthropic.NewAdapter() + require.NoError(t, registry.Register(anthropicAdapter)) + + routingSvc := &mockProxyRoutingService{err: appErrors.ErrModelNotFound} + providerSvc := &mockProxyProviderService{ + providers: []domain.Provider{ + {ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "anthropic"}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return &conversion.HTTPResponseSpec{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`{"object":"list","data":[]}`), + }, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, providerSvc) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}} + c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) +} + +func TestProxyHandler_ForwardPassthrough_NoBody_NoModel(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{err: appErrors.ErrModelNotFound} + providerSvc := &mockProxyProviderService{ + providers: []domain.Provider{ + {ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai"}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return &conversion.HTTPResponseSpec{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`{"object":"list","data":[{"id":"gpt-4","object":"model"}]}`), + }, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, providerSvc) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}} + c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) +} + +func TestIsStreamRequest_EdgeCases(t *testing.T) { + engine := setupProxyEngine(t) + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, &mockProxyRoutingService{}, &mockProxyProviderService{}) + + tests := []struct { + name string + body string + path string + expected bool + }{ + {"stream at end of JSON", `{"messages":[],"stream":true}`, "/v1/chat/completions", true}, + {"stream with spaces", `{"stream" : true}`, "/v1/chat/completions", true}, + {"stream embedded in string value", `{"model":"stream:true"}`, "/v1/chat/completions", false}, + {"empty body", "", "/v1/chat/completions", false}, + {"stream true embeddings", `{"model":"text-emb","stream":true}`, "/v1/embeddings", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := h.isStreamRequest([]byte(tt.body), "openai", tt.path) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestProxyHandler_WriteError_RouteError(t *testing.T) { + engine := setupProxyEngine(t) + h := newTestProxyHandler(engine, &mockProxyProviderClient{}, &mockProxyRoutingService{}, &mockProxyProviderService{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/", nil) + + h.writeError(c, fmt.Errorf("model not found"), "openai") + assert.Equal(t, 404, w.Code) +} + +func TestProxyHandler_HandleProxy_RouteEmptyBody_NoModel(t *testing.T) { + engine := setupProxyEngine(t) + routingSvc := &mockProxyRoutingService{err: appErrors.ErrModelNotFound} + providerSvc := &mockProxyProviderService{ + providers: []domain.Provider{ + {ID: "p1", Name: "Test", APIKey: "sk-test", BaseURL: "https://api.test.com", Protocol: "openai"}, + }, + } + client := &mockProxyProviderClient{ + sendFn: func(ctx context.Context, spec conversion.HTTPRequestSpec) (*conversion.HTTPResponseSpec, error) { + return &conversion.HTTPResponseSpec{ + StatusCode: 200, + Body: []byte(`{"object":"list","data":[]}`), + }, nil + }, + } + h := newTestProxyHandler(engine, client, routingSvc, providerSvc) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "protocol", Value: "openai"}, {Key: "path", Value: "/models"}} + c.Request = httptest.NewRequest("GET", "/openai/v1/models", nil) + + h.HandleProxy(c) + assert.Equal(t, 200, w.Code) +} diff --git a/backend/internal/provider/client.go b/backend/internal/provider/client.go index fafcda5..2e65c21 100644 --- a/backend/internal/provider/client.go +++ b/backend/internal/provider/client.go @@ -173,22 +173,22 @@ func (c *Client) readStream(ctx context.Context, cancel context.CancelFunc, body } n, err := body.Read(buf) + if n > 0 { + dataBuf = append(dataBuf, buf[:n]...) + } if err != nil { - if err == io.EOF { + if err != io.EOF { + if isNetworkError(err) { + c.logger.Error("流网络错误", zap.String("error", err.Error())) + eventChan <- StreamEvent{Error: fmt.Errorf("网络错误: %w", err)} + } else { + c.logger.Error("流读取错误", zap.String("error", err.Error())) + eventChan <- StreamEvent{Error: fmt.Errorf("读取错误: %w", err)} + } return } - if isNetworkError(err) { - c.logger.Error("流网络错误", zap.String("error", err.Error())) - eventChan <- StreamEvent{Error: fmt.Errorf("网络错误: %w", err)} - } else { - c.logger.Error("流读取错误", zap.String("error", err.Error())) - eventChan <- StreamEvent{Error: fmt.Errorf("读取错误: %w", err)} - } - return } - dataBuf = append(dataBuf, buf[:n]...) - if len(dataBuf) > bufSize/2 && bufSize < c.streamCfg.MaxBufferSize { newSize := bufSize * 2 if newSize > c.streamCfg.MaxBufferSize { @@ -214,6 +214,10 @@ func (c *Client) readStream(ctx context.Context, cancel context.CancelFunc, body eventChan <- StreamEvent{Data: rawEvent} } + + if err == io.EOF { + return + } } } diff --git a/backend/internal/provider/client_test.go b/backend/internal/provider/client_test.go index 5661b92..7fd3c84 100644 --- a/backend/internal/provider/client_test.go +++ b/backend/internal/provider/client_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -129,6 +130,184 @@ func TestClient_SendStream_ErrorResponse(t *testing.T) { assert.Error(t, err) } +func TestClient_SendStream_SSEEvents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, ok := w.(http.Flusher) + require.True(t, ok) + w.Write([]byte("data: {\"id\":\"1\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n")) + flusher.Flush() + w.Write([]byte("data: {\"id\":\"1\",\"choices\":[{\"delta\":{\"content\":\" World\"}}]}\n\n")) + flusher.Flush() + time.Sleep(50 * time.Millisecond) + w.Write([]byte("data: [DONE]\n\n")) + flusher.Flush() + time.Sleep(50 * time.Millisecond) + })) + defer server.Close() + + client := NewClient() + spec := conversion.HTTPRequestSpec{ + URL: server.URL + "/v1/chat/completions", + Method: "POST", + Headers: map[string]string{"Authorization": "Bearer test-key"}, + Body: []byte(`{"model":"gpt-4","messages":[],"stream":true}`), + } + + eventChan, err := client.SendStream(context.Background(), spec) + require.NoError(t, err) + + var dataEvents [][]byte + var doneEvents int + for event := range eventChan { + if event.Done { + doneEvents++ + } else if event.Error != nil { + t.Fatalf("unexpected error: %v", event.Error) + } else { + dataEvents = append(dataEvents, event.Data) + } + } + + assert.Equal(t, 2, len(dataEvents), "expected exactly 2 data events from SSE stream") + assert.Contains(t, string(dataEvents[0]), "Hello") + assert.Contains(t, string(dataEvents[1]), "World") + assert.Equal(t, 1, doneEvents) +} + +func TestClient_SendStream_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + time.Sleep(10 * time.Second) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + client := NewClient() + spec := conversion.HTTPRequestSpec{ + URL: server.URL + "/v1/chat/completions", + Method: "POST", + Headers: map[string]string{"Authorization": "Bearer test-key"}, + Body: []byte(`{}`), + } + + eventChan, err := client.SendStream(ctx, spec) + require.NoError(t, err) + + cancel() + + var gotError bool + for event := range eventChan { + if event.Error != nil { + gotError = true + } + } + assert.True(t, gotError) +} + +func TestClient_Send_EmptyBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"result":"ok"}`)) + })) + defer server.Close() + + client := NewClient() + spec := conversion.HTTPRequestSpec{ + URL: server.URL + "/v1/models", + Method: "GET", + Headers: map[string]string{"Authorization": "Bearer test-key"}, + } + + result, err := client.Send(context.Background(), spec) + require.NoError(t, err) + assert.Equal(t, 200, result.StatusCode) + assert.Contains(t, string(result.Body), "ok") +} + +func TestClient_SendStream_SlowSSE(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, ok := w.(http.Flusher) + require.True(t, ok) + w.Write([]byte("data: {\"id\":\"1\"}\n\n")) + flusher.Flush() + time.Sleep(100 * time.Millisecond) + w.Write([]byte("data: [DONE]\n\n")) + flusher.Flush() + time.Sleep(100 * time.Millisecond) + })) + defer server.Close() + + client := NewClient() + spec := conversion.HTTPRequestSpec{ + URL: server.URL + "/v1/chat/completions", + Method: "POST", + Headers: map[string]string{"Authorization": "Bearer test-key"}, + Body: []byte(`{}`), + } + + eventChan, err := client.SendStream(context.Background(), spec) + require.NoError(t, err) + + var dataCount int + var doneCount int + for event := range eventChan { + if event.Done { + doneCount++ + } else if event.Error != nil { + t.Fatalf("unexpected error: %v", event.Error) + } else { + dataCount++ + } + } + assert.Equal(t, 1, dataCount, "expected exactly 1 data event from slow SSE") + assert.Equal(t, 1, doneCount, "expected exactly 1 done event from slow SSE") +} + +func TestClient_SendStream_SplitSSEEvents(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, ok := w.(http.Flusher) + require.True(t, ok) + w.Write([]byte("data: {\"id\":\"1\"}\n\ndata: {\"id\":\"2\"}\n\n")) + flusher.Flush() + time.Sleep(50 * time.Millisecond) + w.Write([]byte("data: [DONE]\n\n")) + flusher.Flush() + time.Sleep(50 * time.Millisecond) + })) + defer server.Close() + + client := NewClient() + spec := conversion.HTTPRequestSpec{ + URL: server.URL + "/v1/chat/completions", + Method: "POST", + Headers: map[string]string{"Authorization": "Bearer test-key"}, + Body: []byte(`{}`), + } + + eventChan, err := client.SendStream(context.Background(), spec) + require.NoError(t, err) + + var dataEvents int + var doneEvents int + for event := range eventChan { + if event.Done { + doneEvents++ + } else { + dataEvents++ + } + } + assert.Equal(t, 2, dataEvents, "expected exactly 2 data events from split SSE") + assert.Equal(t, 1, doneEvents) +} + func TestIsNetworkError(t *testing.T) { tests := []struct { input string @@ -147,3 +326,42 @@ func TestIsNetworkError(t *testing.T) { assert.Equal(t, tt.want, isNetworkError(err), "isNetworkError(%q)", tt.input) } } + +func TestClient_SendStream_MidStreamNetworkError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, ok := w.(http.Flusher) + require.True(t, ok) + w.Write([]byte("data: {\"id\":\"1\"}\n\n")) + flusher.Flush() + time.Sleep(50 * time.Millisecond) + if hijacker, ok := w.(http.Hijacker); ok { + conn, _, _ := hijacker.Hijack() + if conn != nil { + conn.Close() + } + } + })) + defer server.Close() + + client := NewClient() + spec := conversion.HTTPRequestSpec{ + URL: server.URL + "/v1/chat/completions", + Method: "POST", + Headers: map[string]string{"Authorization": "Bearer test-key"}, + Body: []byte(`{}`), + } + + eventChan, err := client.SendStream(context.Background(), spec) + require.NoError(t, err) + + var gotData bool + for event := range eventChan { + if event.Error != nil { + } else if !event.Done { + gotData = true + } + } + assert.True(t, gotData, "should have received at least one data event before error") +} diff --git a/backend/internal/service/service_supplemental_test.go b/backend/internal/service/service_supplemental_test.go new file mode 100644 index 0000000..78be90e --- /dev/null +++ b/backend/internal/service/service_supplemental_test.go @@ -0,0 +1,154 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "nex/backend/internal/domain" + "nex/backend/internal/repository" +) + +func TestProviderService_Update(t *testing.T) { + db := setupServiceTestDB(t) + repo := repository.NewProviderRepository(db) + svc := NewProviderService(repo) + + svc.Create(&domain.Provider{ID: "p1", Name: "Original", APIKey: "key", BaseURL: "https://test.com"}) + + err := svc.Update("p1", map[string]interface{}{"name": "Updated"}) + require.NoError(t, err) + + result, err := svc.Get("p1", false) + require.NoError(t, err) + assert.Equal(t, "Updated", result.Name) +} + +func TestProviderService_Update_NotFound(t *testing.T) { + db := setupServiceTestDB(t) + repo := repository.NewProviderRepository(db) + svc := NewProviderService(repo) + + err := svc.Update("nonexistent", map[string]interface{}{"name": "test"}) + assert.Error(t, err) +} + +func TestModelService_Get(t *testing.T) { + db := setupServiceTestDB(t) + providerRepo := repository.NewProviderRepository(db) + modelRepo := repository.NewModelRepository(db) + svc := NewModelService(modelRepo, providerRepo) + + providerRepo.Create(&domain.Provider{ID: "p1", Name: "P1", APIKey: "key", BaseURL: "https://test.com"}) + svc.Create(&domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4"}) + + model, err := svc.Get("m1") + require.NoError(t, err) + assert.Equal(t, "gpt-4", model.ModelName) +} + +func TestModelService_Update(t *testing.T) { + db := setupServiceTestDB(t) + providerRepo := repository.NewProviderRepository(db) + modelRepo := repository.NewModelRepository(db) + svc := NewModelService(modelRepo, providerRepo) + + providerRepo.Create(&domain.Provider{ID: "p1", Name: "P1", APIKey: "key", BaseURL: "https://test.com"}) + svc.Create(&domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4"}) + + err := svc.Update("m1", map[string]interface{}{"model_name": "gpt-4o"}) + require.NoError(t, err) + + model, err := svc.Get("m1") + require.NoError(t, err) + assert.Equal(t, "gpt-4o", model.ModelName) +} + +func TestModelService_Update_ProviderID_Invalid(t *testing.T) { + db := setupServiceTestDB(t) + providerRepo := repository.NewProviderRepository(db) + modelRepo := repository.NewModelRepository(db) + svc := NewModelService(modelRepo, providerRepo) + + providerRepo.Create(&domain.Provider{ID: "p1", Name: "P1", APIKey: "key", BaseURL: "https://test.com"}) + svc.Create(&domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4"}) + + err := svc.Update("m1", map[string]interface{}{"provider_id": "nonexistent"}) + assert.Error(t, err) +} + +func TestModelService_Delete(t *testing.T) { + db := setupServiceTestDB(t) + providerRepo := repository.NewProviderRepository(db) + modelRepo := repository.NewModelRepository(db) + svc := NewModelService(modelRepo, providerRepo) + + providerRepo.Create(&domain.Provider{ID: "p1", Name: "P1", APIKey: "key", BaseURL: "https://test.com"}) + svc.Create(&domain.Model{ID: "m1", ProviderID: "p1", ModelName: "gpt-4"}) + + err := svc.Delete("m1") + require.NoError(t, err) + + _, err = svc.Get("m1") + assert.Error(t, err) +} + +func TestModelService_Delete_NotFound(t *testing.T) { + db := setupServiceTestDB(t) + modelRepo := repository.NewModelRepository(db) + providerRepo := repository.NewProviderRepository(db) + svc := NewModelService(modelRepo, providerRepo) + + err := svc.Delete("nonexistent") + assert.Error(t, err) +} + +func TestStatsService_Aggregate_ByModel(t *testing.T) { + statsRepo := repository.NewStatsRepository(nil) + svc := NewStatsService(statsRepo) + + stats := []domain.UsageStats{ + {ProviderID: "p1", ModelName: "gpt-4", RequestCount: 10}, + {ProviderID: "p1", ModelName: "gpt-4", RequestCount: 5}, + {ProviderID: "p2", ModelName: "gpt-4", RequestCount: 8}, + } + + result := svc.Aggregate(stats, "model") + assert.True(t, len(result) >= 1) + + totalCount := 0 + for _, r := range result { + totalCount += r["request_count"].(int) + } + assert.Equal(t, 23, totalCount) +} + +func TestStatsService_Aggregate_Default(t *testing.T) { + statsRepo := repository.NewStatsRepository(nil) + svc := NewStatsService(statsRepo) + + stats := []domain.UsageStats{ + {ProviderID: "p1", RequestCount: 10}, + {ProviderID: "p2", RequestCount: 5}, + } + + result := svc.Aggregate(stats, "") + assert.Len(t, result, 2) + + totalCount := 0 + for _, r := range result { + totalCount += r["request_count"].(int) + } + assert.Equal(t, 15, totalCount) +} + +func TestModelService_Update_NotFound(t *testing.T) { + db := setupServiceTestDB(t) + modelRepo := repository.NewModelRepository(db) + providerRepo := repository.NewProviderRepository(db) + svc := NewModelService(modelRepo, providerRepo) + + err := svc.Update("nonexistent", map[string]interface{}{"model_name": "test"}) + assert.Error(t, err) +} diff --git a/backend/tests/integration/e2e_conversion_test.go b/backend/tests/integration/e2e_conversion_test.go new file mode 100644 index 0000000..65914b5 --- /dev/null +++ b/backend/tests/integration/e2e_conversion_test.go @@ -0,0 +1,1911 @@ +package integration + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "nex/backend/internal/config" + "nex/backend/internal/conversion" + "nex/backend/internal/conversion/anthropic" + openaiConv "nex/backend/internal/conversion/openai" + "nex/backend/internal/handler" + "nex/backend/internal/handler/middleware" + "nex/backend/internal/provider" + "nex/backend/internal/repository" + "nex/backend/internal/service" +) + +func setupE2ETest(t *testing.T) (*gin.Engine, *httptest.Server) { + t.Helper() + gin.SetMode(gin.TestMode) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"error":"not mocked"}`)) + })) + + dir, _ := os.MkdirTemp("", "e2e-test-*") + db, err := gorm.Open(sqlite.Open(filepath.Join(dir, "test.db")), &gorm.Config{}) + require.NoError(t, err) + err = db.AutoMigrate(&config.Provider{}, &config.Model{}, &config.UsageStats{}) + require.NoError(t, err) + t.Cleanup(func() { + sqlDB, _ := db.DB() + if sqlDB != nil { + sqlDB.Close() + } + upstream.Close() + os.RemoveAll(dir) + }) + + providerRepo := repository.NewProviderRepository(db) + modelRepo := repository.NewModelRepository(db) + statsRepo := repository.NewStatsRepository(db) + + providerService := service.NewProviderService(providerRepo) + modelService := service.NewModelService(modelRepo, providerRepo) + routingService := service.NewRoutingService(modelRepo, providerRepo) + statsService := service.NewStatsService(statsRepo) + + registry := conversion.NewMemoryRegistry() + require.NoError(t, registry.Register(openaiConv.NewAdapter())) + require.NoError(t, registry.Register(anthropic.NewAdapter())) + engine := conversion.NewConversionEngine(registry) + + providerClient := provider.NewClient() + proxyHandler := handler.NewProxyHandler(engine, providerClient, routingService, providerService, statsService) + providerHandler := handler.NewProviderHandler(providerService) + modelHandler := handler.NewModelHandler(modelService) + + _ = modelService + + r := gin.New() + r.Use(middleware.CORS()) + r.Any("/:protocol/v1/*path", proxyHandler.HandleProxy) + + providers := r.Group("/api/providers") + { + providers.POST("", providerHandler.CreateProvider) + } + models := r.Group("/api/models") + { + models.POST("", modelHandler.CreateModel) + } + _ = statsService + + return r, upstream +} + +func e2eCreateProviderAndModel(t *testing.T, r *gin.Engine, providerID, protocol, modelName, upstreamURL string) { + t.Helper() + providerBody, _ := json.Marshal(map[string]string{ + "id": providerID, "name": providerID, "api_key": "test-key", + "base_url": upstreamURL, "protocol": protocol, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/providers", bytes.NewReader(providerBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, 201, w.Code) + + modelBody, _ := json.Marshal(map[string]string{ + "id": modelName, "provider_id": providerID, "model_name": modelName, + }) + w = httptest.NewRecorder() + req = httptest.NewRequest("POST", "/api/models", bytes.NewReader(modelBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, 201, w.Code) +} + +func parseSSEEvents(body string) []map[string]string { + var events []map[string]string + scanner := bufio.NewScanner(strings.NewReader(body)) + var currentEvent, currentData string + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "event: ") { + currentEvent = strings.TrimPrefix(line, "event: ") + } else if strings.HasPrefix(line, "data: ") { + currentData = strings.TrimPrefix(line, "data: ") + } else if line == "" && (currentEvent != "" || currentData != "") { + events = append(events, map[string]string{ + "event": currentEvent, + "data": currentData, + }) + currentEvent = "" + currentData = "" + } + } + return events +} + +func parseOpenAIStreamChunks(body string) []string { + var chunks []string + scanner := bufio.NewScanner(strings.NewReader(body)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "data: ") { + payload := strings.TrimPrefix(line, "data: ") + if payload == "[DONE]" { + chunks = append(chunks, "[DONE]") + } else { + chunks = append(chunks, payload) + } + } + } + return chunks +} + +// ============================================================ +// OpenAI 非流式端到端测试 +// ============================================================ + +func TestE2E_OpenAI_NonStream_BasicText(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "/v1/chat/completions", req.URL.Path) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-e2e-001", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{"role": "assistant", "content": "你好!我是AI助手。"}, + "finish_reason": "stop", + "logprobs": nil, + }}, + "usage": map[string]any{ + "prompt_tokens": 15, "completion_tokens": 10, "total_tokens": 25, + }, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{ + {"role": "user", "content": "你好"}, + }, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "chat.completion", resp["object"]) + assert.Equal(t, "gpt-4o", resp["model"]) + + choices := resp["choices"].([]any) + require.Len(t, choices, 1) + choice := choices[0].(map[string]any) + assert.Equal(t, float64(0), choice["index"]) + msg := choice["message"].(map[string]any) + assert.Equal(t, "assistant", msg["role"]) + assert.Equal(t, "你好!我是AI助手。", msg["content"]) + assert.Equal(t, "stop", choice["finish_reason"]) + + usage := resp["usage"].(map[string]any) + assert.Equal(t, float64(15), usage["prompt_tokens"]) + assert.Equal(t, float64(10), usage["completion_tokens"]) + assert.Equal(t, float64(25), usage["total_tokens"]) +} + +func TestE2E_OpenAI_NonStream_MultiTurn(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + var reqBody map[string]any + json.Unmarshal(body, &reqBody) + msgs := reqBody["messages"].([]any) + assert.GreaterOrEqual(t, len(msgs), 3) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-e2e-002", "object": "chat.completion", "created": 1700000001, "model": "gpt-4o", + "choices": []map[string]any{{ + "index": 0, "message": map[string]any{"role": "assistant", "content": "Go语言的interface是隐式实现的。"}, + "finish_reason": "stop", "logprobs": nil, + }}, + "usage": map[string]any{"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120}, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{ + {"role": "system", "content": "你是编程助手"}, + {"role": "user", "content": "什么是interface?"}, + {"role": "assistant", "content": "Interface定义了一组方法签名。"}, + {"role": "user", "content": "举个例子"}, + }, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Contains(t, resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any)["content"], "interface") +} + +func TestE2E_OpenAI_NonStream_ToolCalls(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-e2e-004", "object": "chat.completion", "created": 1700000003, "model": "gpt-4o", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": nil, + "tool_calls": []map[string]any{{ + "id": "call_e2e_001", + "type": "function", + "function": map[string]any{ + "name": "get_weather", + "arguments": `{"city":"北京"}`, + }, + }}, + }, + "finish_reason": "tool_calls", + "logprobs": nil, + }}, + "usage": map[string]any{"prompt_tokens": 80, "completion_tokens": 18, "total_tokens": 98}, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{ + {"role": "user", "content": "北京天气"}, + }, + "tools": []map[string]any{{ + "type": "function", + "function": map[string]any{ + "name": "get_weather", "description": "获取天气", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + "required": []string{"city"}, + }, + }, + }}, + "tool_choice": "auto", + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + choice := resp["choices"].([]any)[0].(map[string]any) + assert.Equal(t, "tool_calls", choice["finish_reason"]) + msg := choice["message"].(map[string]any) + toolCalls := msg["tool_calls"].([]any) + require.Len(t, toolCalls, 1) + tc := toolCalls[0].(map[string]any) + assert.Equal(t, "call_e2e_001", tc["id"]) + assert.Equal(t, "function", tc["type"]) + fn := tc["function"].(map[string]any) + assert.Equal(t, "get_weather", fn["name"]) + assert.Contains(t, fn["arguments"], "北京") +} + +func TestE2E_OpenAI_NonStream_MaxTokens_Length(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-e2e-014", "object": "chat.completion", "created": 1700000014, "model": "gpt-4o", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{"role": "assistant", "content": "人工智能起源于1950年代..."}, + "finish_reason": "length", + "logprobs": nil, + }}, + "usage": map[string]any{"prompt_tokens": 20, "completion_tokens": 30, "total_tokens": 50}, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{{"role": "user", "content": "介绍AI历史"}}, + "max_tokens": 30, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + choice := resp["choices"].([]any)[0].(map[string]any) + assert.Equal(t, "length", choice["finish_reason"]) +} + +func TestE2E_OpenAI_NonStream_UsageWithReasoning(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-e2e-022", "object": "chat.completion", "created": 1700000022, "model": "o3", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{"role": "assistant", "content": "答案是61。"}, + "finish_reason": "stop", + "logprobs": nil, + }}, + "usage": map[string]any{ + "prompt_tokens": 35, "completion_tokens": 48, "total_tokens": 83, + "completion_tokens_details": map[string]any{"reasoning_tokens": 20}, + }, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "o3", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "o3", + "messages": []map[string]any{{"role": "user", "content": "15+23*2=?"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + usage := resp["usage"].(map[string]any) + assert.Equal(t, float64(48), usage["completion_tokens"]) + details, ok := usage["completion_tokens_details"].(map[string]any) + if ok { + assert.Equal(t, float64(20), details["reasoning_tokens"]) + } +} + +func TestE2E_OpenAI_NonStream_Refusal(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-e2e-007", "object": "chat.completion", "created": 1700000007, "model": "gpt-4o", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": nil, + "refusal": "抱歉,我无法提供涉及危险活动的信息。", + }, + "finish_reason": "stop", + "logprobs": nil, + }}, + "usage": map[string]any{"prompt_tokens": 12, "completion_tokens": 35, "total_tokens": 47}, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{{"role": "user", "content": "做坏事"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + assert.NotNil(t, msg["refusal"]) +} + +// ============================================================ +// OpenAI 流式端到端测试 +// ============================================================ + +func TestE2E_OpenAI_Stream_Text(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + `data: {"id":"chatcmpl-stream-e2e","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-stream-e2e","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-stream-e2e","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"好"},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-stream-e2e","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`, + `data: [DONE]`, + } + for _, e := range events { + fmt.Fprintf(w, "%s\n\n", e) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{{"role": "user", "content": "你好"}}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "text/event-stream") + respBody := w.Body.String() + + assert.Contains(t, respBody, "chat.completion.chunk") + assert.Contains(t, respBody, `"role":"assistant"`) + assert.Contains(t, respBody, `"content":"你"`) + assert.Contains(t, respBody, `"content":"好"`) + assert.Contains(t, respBody, `"finish_reason":"stop"`) +} + +func TestE2E_OpenAI_Stream_ToolCalls(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + `data: {"id":"chatcmpl-stream-tc","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-stream-tc","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_tc1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-stream-tc","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-stream-tc","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"北京\"}"}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-stream-tc","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + `data: [DONE]`, + } + for _, e := range events { + fmt.Fprintf(w, "%s\n\n", e) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{{"role": "user", "content": "北京天气"}}, + "tools": []map[string]any{{ + "type": "function", + "function": map[string]any{ + "name": "get_weather", "description": "获取天气", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + }, + }, + }}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + respBody := w.Body.String() + assert.Contains(t, respBody, "tool_calls") + assert.Contains(t, respBody, "get_weather") + assert.Contains(t, respBody, "tool_calls") +} + +func TestE2E_OpenAI_Stream_WithUsage(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + `data: {"id":"chatcmpl-stream-u","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-stream-u","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-stream-u","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`, + `data: {"id":"chatcmpl-stream-u","object":"chat.completion.chunk","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`, + `data: [DONE]`, + } + for _, e := range events { + fmt.Fprintf(w, "%s\n\n", e) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{{"role": "user", "content": "hi"}}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + respBody := w.Body.String() + assert.Contains(t, respBody, `"Hi"`) + assert.Contains(t, respBody, `"finish_reason":"stop"`) +} + +// ============================================================ +// Anthropic 非流式端到端测试 +// ============================================================ + +func TestE2E_Anthropic_NonStream_BasicText(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_001", "type": "message", "role": "assistant", + "content": []map[string]any{ + {"type": "text", "text": "你好!我是Claude,由Anthropic开发的AI助手。"}, + }, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 15, "output_tokens": 25}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "你好"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "message", resp["type"]) + assert.Equal(t, "assistant", resp["role"]) + assert.Equal(t, "claude-opus-4-7", resp["model"]) + assert.Equal(t, "end_turn", resp["stop_reason"]) + + content := resp["content"].([]any) + require.Len(t, content, 1) + block := content[0].(map[string]any) + assert.Equal(t, "text", block["type"]) + assert.Contains(t, block["text"], "Claude") + + usage := resp["usage"].(map[string]any) + assert.Equal(t, float64(15), usage["input_tokens"]) + assert.Equal(t, float64(25), usage["output_tokens"]) +} + +func TestE2E_Anthropic_NonStream_WithSystem(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + var reqBody map[string]any + json.Unmarshal(body, &reqBody) + assert.NotNil(t, reqBody["system"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_003", "type": "message", "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "递归是函数调用自身。"}}, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 30, "output_tokens": 15}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "system": "你是编程助手", + "messages": []map[string]any{{"role": "user", "content": "什么是递归?"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) +} + +func TestE2E_Anthropic_NonStream_ToolUse(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_009", "type": "message", "role": "assistant", + "content": []map[string]any{{ + "type": "tool_use", "id": "toolu_e2e_009", "name": "get_weather", + "input": map[string]any{"city": "北京"}, + }}, + "model": "claude-opus-4-7", "stop_reason": "tool_use", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 180, "output_tokens": 42}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "北京天气"}}, + "tools": []map[string]any{{ + "name": "get_weather", "description": "获取天气", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + "required": []string{"city"}, + }, + }}, + "tool_choice": map[string]any{"type": "auto"}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "tool_use", resp["stop_reason"]) + content := resp["content"].([]any) + require.Len(t, content, 1) + block := content[0].(map[string]any) + assert.Equal(t, "tool_use", block["type"]) + assert.Equal(t, "toolu_e2e_009", block["id"]) + assert.Equal(t, "get_weather", block["name"]) +} + +func TestE2E_Anthropic_NonStream_Thinking(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_018", "type": "message", "role": "assistant", + "content": []map[string]any{ + {"type": "thinking", "thinking": "这是一个逻辑推理问题..."}, + {"type": "text", "text": "答案是61。"}, + }, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 95, "output_tokens": 280}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 4096, + "messages": []map[string]any{{"role": "user", "content": "15+23*2=?"}}, + "thinking": map[string]any{"type": "enabled", "budget_tokens": 2048}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + content := resp["content"].([]any) + require.Len(t, content, 2) + assert.Equal(t, "thinking", content[0].(map[string]any)["type"]) + assert.Equal(t, "text", content[1].(map[string]any)["type"]) +} + +func TestE2E_Anthropic_NonStream_MaxTokens(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_016", "type": "message", "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "人工智能起源于..."}}, + "model": "claude-opus-4-7", "stop_reason": "max_tokens", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 22, "output_tokens": 20}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 20, + "messages": []map[string]any{{"role": "user", "content": "介绍AI历史"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "max_tokens", resp["stop_reason"]) +} + +func TestE2E_Anthropic_NonStream_StopSequence(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_017", "type": "message", "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "1\n2\n3\n4\n"}}, + "model": "claude-opus-4-7", "stop_reason": "stop_sequence", "stop_sequence": "5", + "usage": map[string]any{"input_tokens": 22, "output_tokens": 10}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "从1数到10"}}, + "stop_sequences": []string{"5"}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "stop_sequence", resp["stop_reason"]) + assert.Equal(t, "5", resp["stop_sequence"]) +} + +func TestE2E_Anthropic_NonStream_MetadataUserID(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + var reqBody map[string]any + json.Unmarshal(body, &reqBody) + metadata, _ := reqBody["metadata"].(map[string]any) + assert.Equal(t, "user_12345", metadata["user_id"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_026", "type": "message", "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "你好!"}}, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 12, "output_tokens": 5}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "你好"}}, + "metadata": map[string]any{"user_id": "user_12345"}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) +} + +func TestE2E_Anthropic_NonStream_UsageWithCache(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_025", "type": "message", "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "你好!"}}, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "usage": map[string]any{ + "input_tokens": 25, "output_tokens": 5, + "cache_creation_input_tokens": 15, "cache_read_input_tokens": 0, + }, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "system": []map[string]any{{"type": "text", "text": "你是编程助手。"}}, + "messages": []map[string]any{{"role": "user", "content": "你好"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + usage := resp["usage"].(map[string]any) + assert.Equal(t, float64(15), usage["cache_creation_input_tokens"]) +} + +// ============================================================ +// Anthropic 流式端到端测试 +// ============================================================ + +func TestE2E_Anthropic_Stream_Text(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_e2e\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-4-7\",\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"你\"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"好\"}}\n\n", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":5}}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + } + for _, e := range events { + w.Write([]byte(e)) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "你好"}}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "text/event-stream") + + respBody := w.Body.String() + assert.Contains(t, respBody, "message_start") + assert.Contains(t, respBody, "content_block_delta") + assert.Contains(t, respBody, "text_delta") + assert.Contains(t, respBody, "你") + assert.Contains(t, respBody, "message_stop") +} + +func TestE2E_Anthropic_Stream_Thinking(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + `event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_stream_think","role":"assistant","model":"claude-opus-4-7","usage":{"input_tokens":30,"output_tokens":0}}}`, + `event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`, + `event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"计算中..."}}`, + `event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`, + `event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`, + `event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"答案是2。"}}`, + `event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":1}`, + `event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":25}}`, + `event: message_stop` + "\n" + `data: {"type":"message_stop"}`, + } + for _, e := range events { + fmt.Fprintf(w, "%s\n\n", e) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 4096, + "messages": []map[string]any{{"role": "user", "content": "1+1=?"}}, + "thinking": map[string]any{"type": "enabled", "budget_tokens": 1024}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + respBody := w.Body.String() + assert.Contains(t, respBody, "thinking_delta") + assert.Contains(t, respBody, "计算中...") + assert.Contains(t, respBody, "text_delta") + assert.Contains(t, respBody, "答案是2。") +} + +// ============================================================ +// 跨协议转换测试 +// ============================================================ + +func TestE2E_CrossProtocol_OpenAIToAnthropic_RequestFormat(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "/v1/messages", req.URL.Path) + body, _ := io.ReadAll(req.Body) + var reqBody map[string]any + require.NoError(t, json.Unmarshal(body, &reqBody)) + + assert.Equal(t, "claude-model", reqBody["model"]) + assert.NotNil(t, reqBody["max_tokens"]) + + msgs := reqBody["messages"].([]any) + require.GreaterOrEqual(t, len(msgs), 1) + firstMsg := msgs[0].(map[string]any) + assert.Equal(t, "user", firstMsg["role"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_cross_001", "type": "message", "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "跨协议响应"}}, + "model": "claude-model", "stop_reason": "end_turn", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 10, "output_tokens": 5}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-model", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-model", + "messages": []map[string]any{{"role": "user", "content": "Hello"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "chat.completion", resp["object"]) + msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + assert.Contains(t, msg["content"], "跨协议响应") +} + +func TestE2E_CrossProtocol_AnthropicToOpenAI_RequestFormat(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "/v1/chat/completions", req.URL.Path) + body, _ := io.ReadAll(req.Body) + var reqBody map[string]any + require.NoError(t, json.Unmarshal(body, &reqBody)) + + assert.Equal(t, "gpt-4", reqBody["model"]) + msgs := reqBody["messages"].([]any) + require.GreaterOrEqual(t, len(msgs), 1) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-cross", "object": "chat.completion", "model": "gpt-4", "created": time.Now().Unix(), + "choices": []map[string]any{{ + "index": 0, "message": map[string]any{"role": "assistant", "content": "跨协议反向响应"}, + "finish_reason": "stop", + }}, + "usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18}, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "Hello"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "message", resp["type"]) + content := resp["content"].([]any) + assert.Contains(t, content[0].(map[string]any)["text"], "跨协议反向响应") +} + +func TestE2E_CrossProtocol_OpenAIToAnthropic_Stream(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "/v1/messages", req.URL.Path) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + `event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_cross_stream","model":"claude-model","usage":{"input_tokens":10,"output_tokens":0}}}`, + `event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `event: content_block_delta` + "\n" + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}`, + `event: content_block_stop` + "\n" + `data: {"type":"content_block_stop","index":0}`, + `event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`, + `event: message_stop` + "\n" + `data: {"type":"message_stop"}`, + } + for _, e := range events { + fmt.Fprintf(w, "%s\n\n", e) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-model", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-model", + "messages": []map[string]any{{"role": "user", "content": "Hello"}}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "text/event-stream") + respBody := w.Body.String() + assert.Contains(t, respBody, "chat.completion.chunk") + assert.Contains(t, respBody, "Hi") + assert.Contains(t, respBody, "[DONE]") +} + +func TestE2E_CrossProtocol_AnthropicToOpenAI_Stream(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "/v1/chat/completions", req.URL.Path) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + `data: {"id":"chatcmpl-cross-s","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant"}}]}`, + `data: {"id":"chatcmpl-cross-s","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{"content":"Hey"}}]}`, + `data: {"id":"chatcmpl-cross-s","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`, + `data: [DONE]`, + } + for _, e := range events { + fmt.Fprintf(w, "%s\n\n", e) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "Hello"}}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + respBody := w.Body.String() + assert.Contains(t, respBody, "content_block_delta") + assert.Contains(t, respBody, "text_delta") + assert.Contains(t, respBody, "Hey") + assert.Contains(t, respBody, "message_stop") +} + +// ============================================================ +// 错误格式测试 +// ============================================================ + +func TestE2E_OpenAI_ErrorResponse(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": "The model `nonexistent` does not exist.", + "type": "invalid_request_error", + "param": nil, + "code": "model_not_found", + }, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "nonexistent", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "nonexistent", + "messages": []map[string]any{{"role": "user", "content": "test"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.True(t, w.Code >= 400) +} + +func TestE2E_Anthropic_ErrorResponse(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "type": "error", + "error": map[string]any{ + "type": "invalid_request_error", + "message": "max_tokens is required", + }, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "test"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.True(t, w.Code >= 400) +} + +// ============================================================ +// 补充场景测试 +// ============================================================ + +func TestE2E_OpenAI_NonStream_ParallelToolCalls(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-e2e-ptc", "object": "chat.completion", "created": 1700000050, "model": "gpt-4o", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": nil, + "tool_calls": []map[string]any{ + { + "id": "call_ptc_1", "type": "function", + "function": map[string]any{"name": "get_weather", "arguments": `{"city":"北京"}`}, + }, + { + "id": "call_ptc_2", "type": "function", + "function": map[string]any{"name": "get_weather", "arguments": `{"city":"上海"}`}, + }, + }, + }, + "finish_reason": "tool_calls", + "logprobs": nil, + }}, + "usage": map[string]any{"prompt_tokens": 100, "completion_tokens": 36, "total_tokens": 136}, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{{"role": "user", "content": "北京和上海的天气"}}, + "tools": []map[string]any{{ + "type": "function", + "function": map[string]any{ + "name": "get_weather", "description": "获取天气", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + "required": []string{"city"}, + }, + }, + }}, + "tool_choice": "auto", + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + toolCalls := msg["tool_calls"].([]any) + require.Len(t, toolCalls, 2) + tc1 := toolCalls[0].(map[string]any) + tc2 := toolCalls[1].(map[string]any) + assert.Equal(t, "call_ptc_1", tc1["id"]) + assert.Equal(t, "call_ptc_2", tc2["id"]) + assert.Contains(t, tc1["function"].(map[string]any)["arguments"], "北京") + assert.Contains(t, tc2["function"].(map[string]any)["arguments"], "上海") +} + +func TestE2E_OpenAI_NonStream_StopSequence(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-e2e-stop", "object": "chat.completion", "created": 1700000060, "model": "gpt-4o", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{"role": "assistant", "content": "1, 2, 3, 4, "}, + "finish_reason": "stop", + "logprobs": nil, + }}, + "usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18}, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{{"role": "user", "content": "从1数到10"}}, + "stop": []string{"5"}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + choice := resp["choices"].([]any)[0].(map[string]any) + assert.Equal(t, "stop", choice["finish_reason"]) +} + +func TestE2E_OpenAI_NonStream_ContentFilter(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-e2e-cf", "object": "chat.completion", "created": 1700000070, "model": "gpt-4o", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": nil, + "refusal": "内容被安全过滤器拦截。", + }, + "finish_reason": "content_filter", + "logprobs": nil, + }}, + "usage": map[string]any{"prompt_tokens": 8, "completion_tokens": 0, "total_tokens": 8}, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{{"role": "user", "content": "危险内容"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + choice := resp["choices"].([]any)[0].(map[string]any) + assert.Equal(t, "content_filter", choice["finish_reason"]) +} + +func TestE2E_Anthropic_NonStream_MultiToolUse(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_mt", "type": "message", "role": "assistant", + "content": []map[string]any{ + {"type": "tool_use", "id": "toolu_mt_1", "name": "get_weather", "input": map[string]any{"city": "北京"}}, + {"type": "tool_use", "id": "toolu_mt_2", "name": "get_weather", "input": map[string]any{"city": "上海"}}, + }, + "model": "claude-opus-4-7", "stop_reason": "tool_use", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 200, "output_tokens": 84}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "北京和上海的天气"}}, + "tools": []map[string]any{{ + "name": "get_weather", "description": "获取天气", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + "required": []string{"city"}, + }, + }}, + "tool_choice": map[string]any{"type": "auto"}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + content := resp["content"].([]any) + require.Len(t, content, 2) + assert.Equal(t, "tool_use", content[0].(map[string]any)["type"]) + assert.Equal(t, "toolu_mt_1", content[0].(map[string]any)["id"]) + assert.Equal(t, "toolu_mt_2", content[1].(map[string]any)["id"]) +} + +func TestE2E_Anthropic_NonStream_ToolChoiceAny(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + var reqBody map[string]any + json.Unmarshal(body, &reqBody) + tc, _ := reqBody["tool_choice"].(map[string]any) + assert.Equal(t, "any", tc["type"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_tca", "type": "message", "role": "assistant", + "content": []map[string]any{ + {"type": "tool_use", "id": "toolu_tca_1", "name": "get_time", "input": map[string]any{"timezone": "Asia/Shanghai"}}, + }, + "model": "claude-opus-4-7", "stop_reason": "tool_use", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 100, "output_tokens": 30}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "现在几点了?"}}, + "tools": []map[string]any{{ + "name": "get_time", "description": "获取当前时间", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{"timezone": map[string]any{"type": "string"}}, + }, + }}, + "tool_choice": map[string]any{"type": "any"}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "tool_use", resp["stop_reason"]) +} + +func TestE2E_Anthropic_NonStream_ArraySystemPrompt(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + var reqBody map[string]any + json.Unmarshal(body, &reqBody) + sys, ok := reqBody["system"].([]any) + require.True(t, ok, "system should be an array") + require.GreaterOrEqual(t, len(sys), 1) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_asys", "type": "message", "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "已收到多条系统指令。"}}, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 50, "output_tokens": 10}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "system": []map[string]any{ + {"type": "text", "text": "你是编程助手。"}, + {"type": "text", "text": "请用中文回答。"}, + }, + "messages": []map[string]any{{"role": "user", "content": "你好"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) +} + +func TestE2E_Anthropic_NonStream_ToolResultMessage(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + var reqBody map[string]any + json.Unmarshal(body, &reqBody) + msgs := reqBody["messages"].([]any) + require.GreaterOrEqual(t, len(msgs), 3) + lastMsg := msgs[len(msgs)-1].(map[string]any) + assert.Equal(t, "user", lastMsg["role"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_e2e_tr", "type": "message", "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "北京当前晴天,温度25°C。"}}, + "model": "claude-opus-4-7", "stop_reason": "end_turn", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 150, "output_tokens": 20}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{ + {"role": "user", "content": "北京天气"}, + {"role": "assistant", "content": []map[string]any{ + {"type": "tool_use", "id": "toolu_prev", "name": "get_weather", "input": map[string]any{"city": "北京"}}, + }}, + {"role": "user", "content": []map[string]any{ + {"type": "tool_result", "tool_use_id": "toolu_prev", "content": "晴天,25°C"}, + }}, + }, + "tools": []map[string]any{{ + "name": "get_weather", "description": "获取天气", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + }, + }}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Contains(t, resp["content"].([]any)[0].(map[string]any)["text"], "25") +} + +func TestE2E_Anthropic_Stream_ToolCalls(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_tc\",\"role\":\"assistant\",\"model\":\"claude-opus-4-7\",\"usage\":{\"input_tokens\":50,\"output_tokens\":0}}}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_stream_tc\",\"name\":\"get_weather\",\"input\":{}}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"北京\\\"}\"}}\n\n", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":30}}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + } + for _, e := range events { + w.Write([]byte(e)) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "北京天气"}}, + "tools": []map[string]any{{ + "name": "get_weather", "description": "获取天气", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + }, + }}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + respBody := w.Body.String() + assert.Contains(t, respBody, "tool_use") + assert.Contains(t, respBody, "get_weather") + assert.Contains(t, respBody, "input_json_delta") +} + +func TestE2E_CrossProtocol_OpenAIToAnthropic_NonStream_ToolCalls(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "/v1/messages", req.URL.Path) + body, _ := io.ReadAll(req.Body) + var reqBody map[string]any + require.NoError(t, json.Unmarshal(body, &reqBody)) + + msgs := reqBody["messages"].([]any) + require.GreaterOrEqual(t, len(msgs), 1) + tools, hasTools := reqBody["tools"].([]any) + require.True(t, hasTools) + require.GreaterOrEqual(t, len(tools), 1) + tool := tools[0].(map[string]any) + assert.Equal(t, "get_weather", tool["name"]) + assert.NotNil(t, tool["input_schema"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_cross_tc", "type": "message", "role": "assistant", + "content": []map[string]any{{ + "type": "tool_use", "id": "toolu_cross_tc", "name": "get_weather", + "input": map[string]any{"city": "北京"}, + }}, + "model": "claude-model", "stop_reason": "tool_use", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 100, "output_tokens": 30}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-model", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-model", + "messages": []map[string]any{{"role": "user", "content": "北京天气"}}, + "tools": []map[string]any{{ + "type": "function", + "function": map[string]any{ + "name": "get_weather", "description": "获取天气", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + "required": []string{"city"}, + }, + }, + }}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + choice := resp["choices"].([]any)[0].(map[string]any) + assert.Equal(t, "tool_calls", choice["finish_reason"]) + msg := choice["message"].(map[string]any) + toolCalls := msg["tool_calls"].([]any) + require.Len(t, toolCalls, 1) + tc := toolCalls[0].(map[string]any) + assert.Equal(t, "get_weather", tc["function"].(map[string]any)["name"]) +} + +func TestE2E_CrossProtocol_AnthropicToOpenAI_NonStream_Thinking(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "/v1/chat/completions", req.URL.Path) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-cross-think", "object": "chat.completion", "model": "gpt-4", "created": time.Now().Unix(), + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": "经过思考,答案是42。", + }, + "finish_reason": "stop", + }}, + "usage": map[string]any{"prompt_tokens": 50, "completion_tokens": 100, "total_tokens": 150}, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4", "max_tokens": 4096, + "messages": []map[string]any{{"role": "user", "content": "宇宙的答案"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "message", resp["type"]) + content := resp["content"].([]any) + assert.Contains(t, content[0].(map[string]any)["text"], "42") +} + +func TestE2E_CrossProtocol_StopReasonMapping(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "msg_cross_stop", "type": "message", "role": "assistant", + "content": []map[string]any{{"type": "text", "text": "被截断的内容..."}}, + "model": "claude-model", "stop_reason": "max_tokens", "stop_sequence": nil, + "usage": map[string]any{"input_tokens": 10, "output_tokens": 20}, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-model", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-model", + "messages": []map[string]any{{"role": "user", "content": "长文"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + choice := resp["choices"].([]any)[0].(map[string]any) + assert.Equal(t, "length", choice["finish_reason"]) +} + +func TestE2E_OpenAI_NonStream_AssistantWithToolResult(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + var reqBody map[string]any + json.Unmarshal(body, &reqBody) + msgs := reqBody["messages"].([]any) + require.GreaterOrEqual(t, len(msgs), 3) + toolMsg := msgs[2].(map[string]any) + assert.Equal(t, "tool", toolMsg["role"]) + assert.Equal(t, "call_e2e_001", toolMsg["tool_call_id"]) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "chatcmpl-e2e-tr", "object": "chat.completion", "created": 1700000080, "model": "gpt-4o", + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{"role": "assistant", "content": "北京当前晴天,温度25°C。"}, + "finish_reason": "stop", + "logprobs": nil, + }}, + "usage": map[string]any{"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120}, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{ + {"role": "user", "content": "北京天气"}, + {"role": "assistant", "content": nil, "tool_calls": []map[string]any{{ + "id": "call_e2e_001", "type": "function", + "function": map[string]any{"name": "get_weather", "arguments": `{"city":"北京"}`}, + }}}, + {"role": "tool", "tool_call_id": "call_e2e_001", "content": "晴天,25°C"}, + }, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + msg := resp["choices"].([]any)[0].(map[string]any)["message"].(map[string]any) + assert.Contains(t, msg["content"], "25") +} + +func TestE2E_CrossProtocol_AnthropicToOpenAI_Stream_ToolCalls(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "/v1/messages", req.URL.Path) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_cross_tc_stream\",\"role\":\"assistant\",\"model\":\"claude-model\",\"usage\":{\"input_tokens\":50,\"output_tokens\":0}}}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_cross_s1\",\"name\":\"get_weather\",\"input\":{}}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"北京\\\"}\"}}\n\n", + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n", + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":30}}\n\n", + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + } + for _, e := range events { + w.Write([]byte(e)) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-model", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-model", + "messages": []map[string]any{{"role": "user", "content": "北京天气"}}, + "tools": []map[string]any{{ + "type": "function", + "function": map[string]any{ + "name": "get_weather", "description": "获取天气", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + }, + }, + }}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + respBody := w.Body.String() + assert.Contains(t, respBody, "tool_calls") + assert.Contains(t, respBody, "get_weather") +} + +func TestE2E_CrossProtocol_OpenAIToAnthropic_Stream_ToolCalls(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + assert.Equal(t, "/v1/chat/completions", req.URL.Path) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + `data: {"id":"chatcmpl-cross-tc-s","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_cross_tc","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-cross-tc-s","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-cross-tc-s","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"北京\"}"}}]},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-cross-tc-s","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`, + `data: [DONE]`, + } + for _, e := range events { + fmt.Fprintf(w, "%s\n\n", e) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "北京天气"}}, + "tools": []map[string]any{{ + "name": "get_weather", "description": "获取天气", + "input_schema": map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]any{"type": "string"}}, + }, + }}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + respBody := w.Body.String() + assert.Contains(t, respBody, "tool_use") + assert.Contains(t, respBody, "get_weather") +} + +func TestE2E_OpenAI_Upstream5xx_ErrorPassthrough(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": "Internal server error", + "type": "server_error", + "code": "internal_error", + }, + }) + }) + e2eCreateProviderAndModel(t, r, "openai-p", "openai", "gpt-4o", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "gpt-4o", + "messages": []map[string]any{{"role": "user", "content": "test"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/openai/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + errObj, ok := resp["error"].(map[string]any) + require.True(t, ok, "response should contain error object") + assert.Contains(t, errObj["message"], "Internal server error") +} + +func TestE2E_Anthropic_Upstream5xx_ErrorPassthrough(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]any{ + "type": "error", + "error": map[string]any{ + "type": "api_error", + "message": "Internal server error", + }, + }) + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "test"}}, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "error", resp["type"]) + errObj, ok := resp["error"].(map[string]any) + require.True(t, ok, "response should contain error object") + assert.Contains(t, errObj["message"], "Internal server error") +} + +func TestE2E_Anthropic_Stream_TruncatedSSE(t *testing.T) { + r, upstream := setupE2ETest(t) + upstream.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher := w.(http.Flusher) + + events := []string{ + "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_trunc\",\"role\":\"assistant\",\"model\":\"claude-opus-4-7\",\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n", + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n", + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"正常\"}}\n\n", + } + for _, e := range events { + w.Write([]byte(e)) + flusher.Flush() + time.Sleep(10 * time.Millisecond) + } + }) + e2eCreateProviderAndModel(t, r, "anthropic-p", "anthropic", "claude-opus-4-7", upstream.URL) + + body, _ := json.Marshal(map[string]any{ + "model": "claude-opus-4-7", "max_tokens": 1024, + "messages": []map[string]any{{"role": "user", "content": "test"}}, + "stream": true, + }) + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/anthropic/v1/messages", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + respBody := w.Body.String() + assert.Contains(t, respBody, "message_start") + assert.Contains(t, respBody, "正常") +} + +var _ = fmt.Sprintf +var _ = time.Now diff --git a/docs/api_reference/test_mock_anthropic.md b/docs/api_reference/test_mock_anthropic.md new file mode 100644 index 0000000..b2c9a96 --- /dev/null +++ b/docs/api_reference/test_mock_anthropic.md @@ -0,0 +1,1715 @@ +# Anthropic Messages API 端到端测试用例与 Mock 设计 + +本文档针对 `POST /v1/messages` 接口,设计端到端测试用例及对应的 Mock 返回值结构。 + +--- + +## 一、测试场景总览 + +| 分类 | 测试用例 | 优先级 | +|------|----------|--------| +| 基础对话 | 1. 单轮纯文本对话 | P0 | +| 基础对话 | 2. 多轮对话(user + assistant + user) | P0 | +| 系统消息 | 3. 字符串 system prompt | P0 | +| 系统消息 | 4. 数组 system prompt(多文本块) | P1 | +| 消息内容 | 5. 用户消息含图片(base64 source) | P1 | +| 消息内容 | 6. 用户消息含图片(URL source) | P1 | +| 消息内容 | 7. 用户消息含 tool_result 内容块 | P0 | +| 工具调用 | 8. 单工具调用 | P0 | +| 工具调用 | 9. 并行多工具调用 | P1 | +| 工具调用 | 10. tool_choice 为 "none" | P2 | +| 工具调用 | 11. tool_choice 指定具体工具名 | P2 | +| 工具调用 | 12. tool_choice 为 "any"(强制调用) | P2 | +| 参数控制 | 13. temperature + top_p + top_k | P1 | +| 参数控制 | 14. max_tokens 截断 | P1 | +| 参数控制 | 15. stop_sequences 截断 | P2 | +| 扩展思考 | 16. thinking enabled(extended thinking) | P1 | +| 扩展思考 | 17. thinking adaptive | P2 | +| 输出格式 | 18. output_config 为 json_object | P1 | +| 输出格式 | 19. output_config 为 json_schema(structured output) | P1 | +| 流式响应 | 20. 流式文本响应(SSE) | P0 | +| 流式响应 | 21. 流式工具调用 | P1 | +| 流式响应 | 22. 流式 + thinking 内容 | P1 | +| 缓存控制 | 23. system prompt cache_control(ephemeral) | P2 | +| 元数据 | 24. metadata.user_id | P2 | +| 错误处理 | 25. 无效 model 返回 400 | P1 | +| 错误处理 | 26. 缺少 max_tokens 返回 400 | P1 | +| 错误处理 | 27. 缺少 messages 返回 400 | P1 | +| 错误处理 | 28. 内容安全策略拒绝(refusal) | P2 | +| 其他 | 29. disable_parallel_tool_use | P2 | +| 其他 | 30. container 复用 | P2 | + +--- + +## 二、测试用例详情 + +### 用例 1:单轮纯文本对话 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "你好,请介绍一下你自己" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test001", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "你好!我是 Claude,由 Anthropic 开发的 AI 助手。我可以帮你回答问题、写作、编程、数学推理、数据分析等多种任务。我的知识截止到 2025 年 1 月。请问有什么我可以帮你的?" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 15, + "output_tokens": 68 + } +} +``` + +**验证点:** +- `content[0].type` == `"text"` +- `stop_reason` == `"end_turn"` +- `usage` 字段完整 + +--- + +### 用例 2:多轮对话 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "什么是 Go 语言的 interface?" + }, + { + "role": "assistant", + "content": "Go 语言的 interface 是一种类型,它定义了一组方法签名。任何实现了这些方法的类型都自动实现了该 interface,无需显式声明。这是 Go 语言"隐式接口"设计的核心特点。" + }, + { + "role": "user", + "content": "能举个例子吗?" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test002", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "当然。例如定义一个 `Reader` interface:\n\n```go\ntype Reader interface {\n Read(p []byte) (n int, err error)\n}\n```\n\n`os.File` 和 `bytes.Buffer` 都实现了 `Read` 方法,所以它们都自动实现了 `Reader` interface,无需显式声明 `implements Reader`。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 120, + "output_tokens": 85 + } +} +``` + +--- + +### 用例 3:字符串 system prompt + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "system": "你是一个专业的编程助手,请用简洁的语言回答问题。", + "messages": [ + { + "role": "user", + "content": "什么是递归?" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test003", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "递归是一种函数调用自身来解决问题的方法。它包含两个关键部分:\n\n1. **基准情况**:直接返回结果的终止条件\n2. **递归情况**:将问题分解为更小的子问题,调用自身解决\n\n例如计算阶乘:`n! = n × (n-1)!`,基准情况是 `0! = 1`。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 45, + "output_tokens": 72 + } +} +``` + +--- + +### 用例 4:数组 system prompt(多文本块) + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "system": [ + { + "type": "text", + "text": "你是一个专业的编程助手。" + }, + { + "type": "text", + "text": "请用简洁的语言回答问题,并尽量提供代码示例。" + } + ], + "messages": [ + { + "role": "user", + "content": "什么是闭包?" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test004", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "闭包是指函数可以访问并"记住"其定义时所在作用域中的变量,即使在该作用域已经执行完毕后仍然可以访问。\n\n```javascript\nfunction makeCounter() {\n let count = 0;\n return function() {\n count++;\n return count;\n };\n}\nconst counter = makeCounter();\nconsole.log(counter()); // 1\nconsole.log(counter()); // 2\n```\n\n`count` 变量被内部函数"捕获",形成了闭包。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 85, + "output_tokens": 95 + } +} +``` + +--- + +### 用例 5:用户消息含图片(base64 source) + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "这张图片里有什么?" + }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": "/9j/4AAQSkZJRgABAQEAYABgAAD..." + } + } + ] + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test005", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "图片中有一只橘色的猫,它正躺在棕色的沙发上晒太阳。猫的毛色是橘白相间的,眼睛是绿色的,看起来非常放松和满足。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 1280, + "output_tokens": 45 + } +} +``` + +--- + +### 用例 6:用户消息含图片(URL source) + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "请描述这张图片" + }, + { + "type": "image", + "source": { + "type": "url", + "url": "https://example.com/images/landscape.jpg" + } + } + ] + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test006", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "这是一幅美丽的自然风景图。前景是一片翠绿的草地,点缀着野花。中景有一条蜿蜒的小河,河水清澈见底。远处是连绵起伏的青山,山顶覆盖着白雪。天空湛蓝,飘着几朵白云。整体画面给人一种宁静祥和的感觉。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 1520, + "output_tokens": 82 + } +} +``` + +--- + +### 用例 7:用户消息含 tool_result 内容块 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "北京天气怎么样?" + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01test001", + "name": "get_weather", + "input": {"city": "北京"} + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01test001", + "content": "北京今天晴,气温 25°C,东南风 2 级。" + } + ] + } + ], + "tools": [ + { + "name": "get_weather", + "description": "获取指定城市的天气信息", + "input_schema": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "城市名称" + } + }, + "required": ["city"] + } + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test007", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "北京今天天气很好,晴天,气温 25°C,东南风 2 级,非常适合外出活动。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 250, + "output_tokens": 35 + } +} +``` + +**验证点:** +- 能正确理解 tool_result 内容 +- 基于工具返回结果生成自然语言回复 + +--- + +### 用例 8:单工具调用 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "北京天气怎么样?" + } + ], + "tools": [ + { + "name": "get_weather", + "description": "获取指定城市的天气信息", + "input_schema": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "城市名称" + } + }, + "required": ["city"] + } + } + ], + "tool_choice": { + "type": "auto" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test008", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01test008", + "name": "get_weather", + "input": { + "city": "北京" + } + } + ], + "model": "claude-opus-4-7", + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 180, + "output_tokens": 42 + } +} +``` + +**验证点:** +- `stop_reason` == `"tool_use"` +- `content[0].type` == `"tool_use"` +- `input` 包含正确的参数 + +--- + +### 用例 9:并行多工具调用 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "帮我查一下北京、上海、广州三个城市的天气" + } + ], + "tools": [ + { + "name": "get_weather", + "description": "获取指定城市的天气信息", + "input_schema": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test009", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01test009a", + "name": "get_weather", + "input": {"city": "北京"} + }, + { + "type": "tool_use", + "id": "toolu_01test009b", + "name": "get_weather", + "input": {"city": "上海"} + }, + { + "type": "tool_use", + "id": "toolu_01test009c", + "name": "get_weather", + "input": {"city": "广州"} + } + ], + "model": "claude-opus-4-7", + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 200, + "output_tokens": 120 + } +} +``` + +**验证点:** +- `content` 数组包含 3 个 tool_use 块 +- 每个 tool_use 有不同的 ID + +--- + +### 用例 10:tool_choice 为 "none" + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "随便聊聊" + } + ], + "tools": [ + { + "name": "get_weather", + "description": "获取天气", + "input_schema": { + "type": "object", + "properties": { + "city": {"type": "string"} + } + } + } + ], + "tool_choice": { + "type": "none" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test010", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "好的!今天天气不错,很适合聊天。你想聊些什么话题呢?可以是技术、生活、娱乐等任何你感兴趣的内容。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 150, + "output_tokens": 42 + } +} +``` + +**验证点:** +- 尽管定义了 tools,响应中不包含 tool_use 块 +- 纯文本回复 + +--- + +### 用例 11:tool_choice 指定具体工具名 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "查天气" + } + ], + "tools": [ + { + "name": "get_weather", + "description": "获取天气", + "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}} + }, + { + "name": "get_news", + "description": "获取新闻", + "input_schema": {"type": "object", "properties": {"topic": {"type": "string"}}} + } + ], + "tool_choice": { + "type": "tool", + "name": "get_weather" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test011", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01test011", + "name": "get_weather", + "input": {} + } + ], + "model": "claude-opus-4-7", + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 200, + "output_tokens": 35 + } +} +``` + +**验证点:** +- 强制使用了 `get_weather` 而非 `get_news` + +--- + +### 用例 12:tool_choice 为 "any"(强制调用) + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "帮我做点什么" + } + ], + "tools": [ + { + "name": "get_weather", + "description": "获取天气", + "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}} + } + ], + "tool_choice": { + "type": "any" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test012", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01test012", + "name": "get_weather", + "input": {} + } + ], + "model": "claude-opus-4-7", + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 150, + "output_tokens": 30 + } +} +``` + +**验证点:** +- 模型必须调用至少一个工具 + +--- + +### 用例 13:temperature + top_p + top_k + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "写一首关于春天的短诗" + } + ], + "temperature": 0.9, + "top_p": 0.95, + "top_k": 50 +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test013", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "春风拂面花自开,\n柳絮飞舞满园香。\n燕归巢中呢喃语,\n万物复苏迎朝阳。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 25, + "output_tokens": 32 + } +} +``` + +--- + +### 用例 14:max_tokens 截断 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 20, + "messages": [ + { + "role": "user", + "content": "请详细介绍一下人工智能的发展历史" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test014", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "人工智能起源于1950年代,图灵提出了机器能否思考的问题。1956年达特茅斯会议正式确立了AI领域。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "max_tokens", + "stop_sequence": null, + "usage": { + "input_tokens": 22, + "output_tokens": 20 + } +} +``` + +**验证点:** +- `stop_reason` == `"max_tokens"` +- `output_tokens` == 20(等于 max_tokens) +- 内容被截断 + +--- + +### 用例 15:stop_sequences 截断 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "从1数到10,每行一个数字" + } + ], + "stop_sequences": ["5"] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test015", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "1\n2\n3\n4\n" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "stop_sequence", + "stop_sequence": "5", + "usage": { + "input_tokens": 22, + "output_tokens": 10 + } +} +``` + +**验证点:** +- `stop_reason` == `"stop_sequence"` +- `stop_sequence` 字段包含触发截断的序列 + +--- + +### 用例 16:thinking enabled(extended thinking) + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 4096, + "messages": [ + { + "role": "user", + "content": "一个房间里有3个灯泡,房间外有3个开关,每个开关控制一个灯泡。你只能进房间一次,如何确定哪个开关控制哪个灯泡?" + } + ], + "thinking": { + "type": "enabled", + "budget_tokens": 2048 + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test016", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "这是一个经典的逻辑推理问题。我需要找到一种方法,通过一次观察来区分三个开关。关键是灯泡有两种可观察的状态:亮或灭。但如果只用亮灭,我只能区分一个开关(开的那个)。我需要找到另一种可观察的属性...温度!灯泡开着会发热。所以我可以:先开一个开关等一会儿,然后关掉,再开另一个开关,然后进房间。亮着的是第二个开关,热的是第一个开关,冷且灭的是第三个开关。" + }, + { + "type": "text", + "text": "解决方案如下:\n\n1. **打开第一个开关**,等待几分钟\n2. **关闭第一个开关**,**打开第二个开关**\n3. **立即进入房间**\n\n此时观察:\n- **亮着的灯** → 由第二个开关控制\n- **摸起来发热的灯** → 由第一个开关控制(因为开过几分钟)\n- **既不亮也不热的灯** → 由第三个开关控制" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 95, + "output_tokens": 280 + } +} +``` + +**验证点:** +- `content` 包含 `thinking` 块和 `text` 块 +- `output_tokens` 包含 thinking tokens + +--- + +### 用例 17:thinking adaptive + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 4096, + "messages": [ + { + "role": "user", + "content": "请帮我分析一下这个算法问题" + } + ], + "thinking": { + "type": "adaptive", + "budget_tokens": 1024 + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test017", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "用户提到了一个算法问题,但没有具体说明是什么问题。我需要请用户提供更多细节。" + }, + { + "type": "text", + "text": "我很乐意帮你分析算法问题!不过你没有具体说明是什么问题。请详细描述一下:\n\n1. 问题的具体要求是什么?\n2. 输入输出的格式是怎样的?\n3. 有没有什么约束条件?\n\n有了这些信息后,我可以帮你分析解题思路和最优解法。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 35, + "output_tokens": 95 + } +} +``` + +--- + +### 用例 18:output_config 为 json_object + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "提取以下信息的姓名和年龄:张三,今年25岁,是一名工程师" + } + ], + "output_config": { + "format": { + "type": "json_object" + } + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test018", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "{\"name\": \"张三\", \"age\": 25, \"occupation\": \"工程师\"}" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 45, + "output_tokens": 28 + } +} +``` + +**验证点:** +- `content[0].text` 是合法的 JSON 字符串 + +--- + +### 用例 19:output_config 为 json_schema(structured output) + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "创建一个用户信息记录,姓名李四,年龄30岁" + } + ], + "output_config": { + "format": { + "type": "json_schema", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"type": "string"} + }, + "required": ["name", "age"] + } + } + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test019", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "{\"name\": \"李四\", \"age\": 30}" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 120, + "output_tokens": 22 + } +} +``` + +**验证点:** +- 输出严格符合 JSON schema 定义 +- 不包含 schema 未定义的字段 + +--- + +### 用例 20:流式文本响应(SSE) + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "你好" + } + ], + "stream": true +} +``` + +**期望 Mock 响应(200 OK, Content-Type: text/event-stream):** + +``` +event: message_start +data: {"type":"message","id":"msg_01stream001","role":"assistant","content":[],"model":"claude-opus-4-7","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"好"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"我"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"是"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Claude"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":","}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"很高兴"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"为你"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"服务"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"。"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":8}} + +event: message_stop +data: {"type":"message_stop"} +``` + +**验证点:** +- 事件顺序:message_start → content_block_start → content_block_delta* → content_block_stop → message_delta → message_stop +- `message_start` 包含完整的 message 对象 +- 每个 `content_block_delta` 包含增量文本 +- `message_delta` 包含 stop_reason 和最终 usage +- 以 `message_stop` 结束 + +--- + +### 用例 21:流式工具调用 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "北京天气怎么样?" + } + ], + "tools": [ + { + "name": "get_weather", + "description": "获取天气", + "input_schema": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + } + ], + "stream": true +} +``` + +**期望 Mock 响应(200 OK, Content-Type: text/event-stream):** + +``` +event: message_start +data: {"type":"message","id":"msg_01stream003","role":"assistant","content":[],"model":"claude-opus-4-7","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":180,"output_tokens":0}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01stream003","name":"get_weather"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"city"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\":\""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"北京"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"}\""}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":42}} + +event: message_stop +data: {"type":"message_stop"} +``` + +**验证点:** +- `content_block_start` 包含 tool_use 类型和工具名 +- `input_json_delta` 逐步拼接完整的 JSON 输入 +- `stop_reason` == `"tool_use"` + +--- + +### 用例 22:流式 + thinking 内容 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 4096, + "messages": [ + { + "role": "user", + "content": "1+1=?" + } + ], + "thinking": { + "type": "enabled", + "budget_tokens": 1024 + }, + "stream": true +} +``` + +**期望 Mock 响应(200 OK, Content-Type: text/event-stream):** + +``` +event: message_start +data: {"type":"message","id":"msg_01stream004","role":"assistant","content":[],"model":"claude-opus-4-7","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":30,"output_tokens":0}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"这是一个简单的"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"数学问题。"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"1+1=2"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":25}} + +event: message_stop +data: {"type":"message_stop"} +``` + +**验证点:** +- 先有 thinking 块,再有 text 块 +- `thinking_delta` 类型正确 +- 两个内容块有不同的 index + +--- + +### 用例 23:system prompt cache_control(ephemeral) + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "system": [ + { + "type": "text", + "text": "你是一个专业的编程助手。", + "cache_control": { + "type": "ephemeral" + } + } + ], + "messages": [ + { + "role": "user", + "content": "你好" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test023", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "你好!我是 Claude,很高兴为你服务。我是由 Anthropic 开发的 AI 助手,可以帮你解答问题、写作、编程等各种任务。请问有什么我可以帮你的?" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 25, + "output_tokens": 52, + "cache_creation_input_tokens": 15, + "cache_read_input_tokens": 0 + } +} +``` + +**验证点:** +- `cache_creation_input_tokens` 反映缓存创建的 token 数 + +--- + +### 用例 24:metadata.user_id + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "你好" + } + ], + "metadata": { + "user_id": "user_12345" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test024", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "你好!我是 Claude,很高兴为你服务。请问有什么我可以帮你的?" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 12, + "output_tokens": 18 + } +} +``` + +--- + +### 用例 25:无效 model 返回 400 + +**请求:** +```json +{ + "model": "nonexistent-model-xyz", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "你好" + } + ] +} +``` + +**期望 Mock 响应(400 Bad Request):** +```json +{ + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "The model 'nonexistent-model-xyz' does not exist or is not available." + } +} +``` + +--- + +### 用例 26:缺少 max_tokens 返回 400 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "messages": [ + { + "role": "user", + "content": "你好" + } + ] +} +``` + +**期望 Mock 响应(400 Bad Request):** +```json +{ + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "'max_tokens' is a required parameter for the Messages API." + } +} +``` + +--- + +### 用例 27:缺少 messages 返回 400 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024 +} +``` + +**期望 Mock 响应(400 Bad Request):** +```json +{ + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "'messages' is a required parameter and must be a non-empty array." + } +} +``` + +--- + +### 用例 28:内容安全策略拒绝(refusal) + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "生成违法内容..." + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test028", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "抱歉,我无法协助生成此类内容。我的设计原则是提供有益、安全和负责任的帮助。如果你有其他问题或需要帮助的地方,我很乐意为你服务。" + } + ], + "model": "claude-opus-4-7", + "stop_reason": "refusal", + "stop_sequence": null, + "stop_details": { + "type": "refusal", + "category": "cyber", + "explanation": "请求内容违反了使用政策。" + }, + "usage": { + "input_tokens": 18, + "output_tokens": 42 + } +} +``` + +**验证点:** +- `stop_reason` == `"refusal"` +- `stop_details` 包含 refusal 详情 + +--- + +### 用例 29:disable_parallel_tool_use + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "帮我查北京和上海的天气" + } + ], + "tools": [ + { + "name": "get_weather", + "description": "获取天气", + "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}} + } + ], + "disable_parallel_tool_use": true +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test029", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01test029", + "name": "get_weather", + "input": {"city": "北京"} + } + ], + "model": "claude-opus-4-7", + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 120, + "output_tokens": 35 + } +} +``` + +**验证点:** +- 只调用了一个工具(而非并行调用两个) + +--- + +### 用例 30:container 复用 + +**请求:** +```json +{ + "model": "claude-opus-4-7", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "运行一段 Python 代码" + } + ], + "tools": [ + { + "type": "code_execution_20250825", + "name": "code_execution", + "container": { + "type": "python", + "version": "3.11" + } + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "msg_01test030", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "toolu_01test030", + "name": "code_execution", + "input": { + "code": "print('Hello, World!')", + "language": "python" + }, + "caller": { + "type": "direct" + } + } + ], + "model": "claude-opus-4-7", + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 150, + "output_tokens": 55 + } +} +``` + +--- + +## 三、Mock 响应通用结构规范 + +### 非流式响应通用结构 + +```json +{ + "id": "msg_", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text" | "tool_use" | "thinking" | "redacted_thinking" | "server_tool_use" | "web_search_tool_result" | "web_fetch_tool_result" | "code_execution_tool_result" | "bash_code_execution_tool_result" | "text_editor_code_execution_tool_result" | "tool_search_tool_result", + "text": "" | null, + "id": "toolu_" | null, + "name": "" | null, + "input": { /* tool input */ } | null, + "thinking": "" | null, + "data": "" | null + } + ], + "model": "", + "stop_reason": "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "pause_turn" | "refusal", + "stop_sequence": "" | null, + "stop_details": { + "type": "refusal", + "category": "cyber" | "bio", + "explanation": "" + } | null, + "usage": { + "input_tokens": , + "output_tokens": , + "cache_read_input_tokens": | null, + "cache_creation_input_tokens": | null + } +} +``` + +### 流式 Event 通用结构 + +| Event Type | Key Fields | +|------------|-----------| +| `message_start` | `type: "message"`, `id`, `role`, `content: []`, `model`, `stop_reason: null`, `usage` | +| `content_block_start` | `type: "content_block_start"`, `index`, `content_block: {type, ...}` | +| `content_block_delta` | `type: "content_block_delta"`, `index`, `delta: {type, text/partial_json/thinking}` | +| `content_block_stop` | `type: "content_block_stop"`, `index` | +| `message_delta` | `type: "message_delta"`, `delta: {stop_reason, stop_sequence}`, `usage: {output_tokens}` | +| `message_stop` | `type: "message_stop"` | + +### 错误响应通用结构 + +```json +{ + "type": "error", + "error": { + "type": "invalid_request_error" | "authentication_error" | "permission_error" | "not_found_error" | "rate_limit_error" | "api_error", + "message": "" + } +} +``` + +### 请求必需参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `model` | string | 模型标识符(如 `claude-opus-4-7`) | +| `max_tokens` | integer | 最大生成 token 数(必需) | +| `messages` | array | 消息数组,至少包含一条(必需) | + +### 请求可选参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `system` | string/array | - | 系统 prompt | +| `temperature` | number | 1.0 | 采样温度(0.0-1.0) | +| `top_p` | number | - | 核采样阈值 | +| `top_k` | number | - | 从 top K 采样 | +| `stop_sequences` | array | - | 停止序列 | +| `tools` | array | - | 工具定义 | +| `tool_choice` | object | auto | 工具选择策略 | +| `thinking` | object | - | 扩展思考配置 | +| `output_config` | object | - | 输出格式配置 | +| `stream` | boolean | false | 是否流式 | +| `metadata` | object | - | 请求元数据 | +| `disable_parallel_tool_use` | boolean | false | 禁用并行工具调用 | +| `container` | any | - | 容器标识符 | + +### Stop Reason 枚举 + +| 值 | 含义 | +|----|------| +| `end_turn` | 自然结束 | +| `max_tokens` | 达到 max_tokens 限制 | +| `stop_sequence` | 遇到自定义停止序列 | +| `tool_use` | 模型调用了工具 | +| `pause_turn` | 长运行 turn 暂停 | +| `refusal` | 安全策略拒绝 | diff --git a/docs/api_reference/test_mock_openai.md b/docs/api_reference/test_mock_openai.md new file mode 100644 index 0000000..f517eb5 --- /dev/null +++ b/docs/api_reference/test_mock_openai.md @@ -0,0 +1,1726 @@ +# OpenAI Chat Completion API 端到端测试用例与 Mock 设计 + +本文档针对 `POST /chat/completions` 接口,设计端到端测试用例及对应的 Mock 返回值结构。 + +--- + +## 一、测试场景总览 + +| 分类 | 测试用例 | 优先级 | +|------|----------|--------| +| 基础对话 | 1. 单轮纯文本对话 | P0 | +| 基础对话 | 2. 多轮对话(system + user + assistant + user) | P0 | +| 消息类型 | 3. developer 消息(o1+ 模型) | P1 | +| 消息类型 | 4. 带 tool_calls 的 assistant 消息 + tool 消息 | P0 | +| 消息内容 | 5. 用户消息含图片 URL(vision) | P1 | +| 消息内容 | 6. 用户消息含多模态内容(text + image) | P1 | +| 消息内容 | 7. assistant 消息含 refusal | P1 | +| 工具调用 | 8. 单工具调用(function tool) | P0 | +| 工具调用 | 9. 并行多工具调用(parallel_tool_calls) | P1 | +| 工具调用 | 10. tool_choice 为 "none" | P2 | +| 工具调用 | 11. tool_choice 指定具体工具名 | P2 | +| 工具调用 | 12. 废弃的 function_call 格式(向后兼容) | P2 | +| 参数控制 | 13. temperature + top_p | P1 | +| 参数控制 | 14. max_tokens 截断 | P1 | +| 参数控制 | 15. stop 序列截断 | P2 | +| 参数控制 | 16. frequency_penalty + presence_penalty | P2 | +| 响应格式 | 17. response_format 为 json_object | P1 | +| 响应格式 | 18. response_format 为 json_schema(structured output) | P1 | +| 流式响应 | 19. 流式文本响应(SSE) | P0 | +| 流式响应 | 20. 流式 + stream_options include_usage | P1 | +| 流式响应 | 21. 流式工具调用 | P1 | +| 推理模型 | 22. reasoning_effort 参数(o1/o3 模型) | P2 | +| 错误处理 | 23. 无效 model 返回 404 | P1 | +| 错误处理 | 24. 缺少 messages 返回 400 | P1 | +| 错误处理 | 25. 内容安全策略拒绝(content_filter) | P2 | +| 其他 | 26. n 参数多选择(multiple choices) | P2 | +| 其他 | 27. seed 参数可复现 | P2 | +| 其他 | 28. logprobs + top_logprobs | P2 | + +--- + +## 二、测试用例详情 + +### 用例 1:单轮纯文本对话 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "你好,请介绍一下你自己" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-001", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "你好!我是由 OpenAI 开发的 AI 助手 GPT-4。我可以帮你回答问题、写作、编程、数学推理等任务。请问有什么我可以帮你的?" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 15, + "completion_tokens": 42, + "total_tokens": 57 + }, + "system_fingerprint": "fp_test123" +} +``` + +**验证点:** +- `choices[0].message.role` == `"assistant"` +- `choices[0].message.content` 非空 +- `choices[0].finish_reason` == `"stop"` +- `usage` 字段完整 + +--- + +### 用例 2:多轮对话 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "system", + "content": "你是一个专业的编程助手,请用简洁的语言回答问题。" + }, + { + "role": "user", + "content": "什么是 Go 语言的 interface?" + }, + { + "role": "assistant", + "content": "Go 语言的 interface 是一种类型,它定义了一组方法签名。任何实现了这些方法的类型都自动实现了该 interface,无需显式声明。" + }, + { + "role": "user", + "content": "能举个例子吗?" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-002", + "object": "chat.completion", + "created": 1700000001, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "当然。例如定义一个 `Reader` interface:\n\n```go\ntype Reader interface {\n Read(p []byte) (n int, err error)\n}\n```\n\n`os.File` 和 `bytes.Buffer` 都实现了 `Read` 方法,所以它们都自动实现了 `Reader` interface。" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 120, + "completion_tokens": 65, + "total_tokens": 185 + }, + "system_fingerprint": "fp_test123" +} +``` + +**验证点:** +- 多轮上下文正确传递 +- 响应与上下文连贯 + +--- + +### 用例 3:developer 消息(o1+ 模型) + +**请求:** +```json +{ + "model": "o1", + "messages": [ + { + "role": "developer", + "content": "你是一名数学专家。请逐步推理后再给出答案。" + }, + { + "role": "user", + "content": "15 + 23 * 2 等于多少?" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-003", + "object": "chat.completion", + "created": 1700000002, + "model": "o1", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "根据运算优先级,先算乘法:23 * 2 = 46,再加 15:46 + 15 = 61。答案是 61。" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 35, + "completion_tokens": 48, + "total_tokens": 83, + "completion_tokens_details": { + "reasoning_tokens": 20 + } + }, + "system_fingerprint": "fp_test123" +} +``` + +**验证点:** +- developer 角色被正确处理 +- reasoning_tokens 在详情中体现 + +--- + +### 用例 4:工具调用 + 工具结果 + +**请求(第一轮 - 模型调用工具):** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "北京今天天气怎么样?" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "获取指定城市的天气信息", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "城市名称" + } + }, + "required": ["city"] + } + } + } + ], + "tool_choice": "auto" +} +``` + +**期望 Mock 响应(200 OK,第一轮):** +```json +{ + "id": "chatcmpl-test-004a", + "object": "chat.completion", + "created": 1700000003, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"city\": \"北京\"}" + } + } + ] + }, + "finish_reason": "tool_calls", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 80, + "completion_tokens": 18, + "total_tokens": 98 + } +} +``` + +**请求(第二轮 - 提交工具结果):** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "北京今天天气怎么样?" + }, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"city\": \"北京\"}" + } + } + ] + }, + { + "role": "tool", + "tool_call_id": "call_abc123", + "content": "北京今天晴,气温 25°C,东南风 2 级。" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "获取指定城市的天气信息", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "城市名称" + } + }, + "required": ["city"] + } + } + } + ] +} +``` + +**期望 Mock 响应(200 OK,第二轮):** +```json +{ + "id": "chatcmpl-test-004b", + "object": "chat.completion", + "created": 1700000004, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "北京今天天气很好,晴天,气温 25°C,东南风 2 级,适合外出活动。" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 120, + "completion_tokens": 30, + "total_tokens": 150 + } +} +``` + +**验证点:** +- 第一轮 `finish_reason` == `"tool_calls"` +- `tool_calls` 包含正确的 function name 和 arguments +- 第二轮能基于工具结果生成自然语言回复 + +--- + +### 用例 5:用户消息含图片 URL(vision) + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "这张图片里有什么?" + }, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/images/cat.jpg", + "detail": "high" + } + } + ] + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-005", + "object": "chat.completion", + "created": 1700000005, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "图片中有一只橘色的猫,它正躺在沙发上晒太阳。猫的毛色是橘白相间的,眼睛是绿色的,看起来非常放松。" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 280, + "completion_tokens": 45, + "total_tokens": 325 + } +} +``` + +**验证点:** +- 图片 URL 被正确传递 +- 响应描述图片内容 + +--- + +### 用例 6:用户消息含多模态内容(text + image) + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "请比较这两张图片有什么不同" + }, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/images/before.jpg" + } + }, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/images/after.jpg" + } + } + ] + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-006", + "object": "chat.completion", + "created": 1700000006, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "两张图片的主要区别:\n1. 第一张是白天的场景,第二张是夜晚\n2. 灯光从自然光变成了暖黄色的室内灯光\n3. 桌上的物品摆放位置有所不同" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 520, + "completion_tokens": 55, + "total_tokens": 575 + } +} +``` + +--- + +### 用例 7:assistant 消息含 refusal + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "如何制作危险物品?" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-007", + "object": "chat.completion", + "created": 1700000007, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "refusal": "抱歉,我无法提供涉及危险活动的信息。我的设计目的是提供有益和安全的帮助。如果你有其他问题,我很乐意协助。" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 35, + "total_tokens": 47 + } +} +``` + +**验证点:** +- `refusal` 字段存在且非空 +- `content` 为 null 或空 + +--- + +### 用例 8:单工具调用(function tool) + +同用例 4 第一轮。 + +--- + +### 用例 9:并行多工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "帮我查一下北京、上海、广州三个城市的天气" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "获取指定城市的天气信息", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "城市名称" + } + }, + "required": ["city"] + } + } + } + ], + "tool_choice": "auto" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-009", + "object": "chat.completion", + "created": 1700000009, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_001", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"city\": \"北京\"}" + } + }, + { + "id": "call_002", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"city\": \"上海\"}" + } + }, + { + "id": "call_003", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"city\": \"广州\"}" + } + } + ] + }, + "finish_reason": "tool_calls", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 95, + "completion_tokens": 55, + "total_tokens": 150 + } +} +``` + +**验证点:** +- `tool_calls` 数组长度 == 3 +- 每个 call 有不同 ID 和参数 + +--- + +### 用例 10:tool_choice 为 "none" + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "随便聊聊" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "获取天气", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + } + } + } + } + ], + "tool_choice": "none" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-010", + "object": "chat.completion", + "created": 1700000010, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "好的!今天天气不错,很适合聊天。你想聊些什么话题呢?可以是技术、生活、娱乐等任何你感兴趣的内容。" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 50, + "completion_tokens": 35, + "total_tokens": 85 + } +} +``` + +**验证点:** +- 尽管定义了 tools,响应中不包含 `tool_calls` +- 纯文本回复 + +--- + +### 用例 11:tool_choice 指定具体工具名 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "查天气" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "获取天气", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}} + } + }, + { + "type": "function", + "function": { + "name": "get_news", + "description": "获取新闻", + "parameters": {"type": "object", "properties": {"topic": {"type": "string"}}} + } + } + ], + "tool_choice": { + "type": "function", + "function": {"name": "get_weather"} + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-011", + "object": "chat.completion", + "created": 1700000011, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_weather1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{}" + } + } + ] + }, + "finish_reason": "tool_calls", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 80, + "completion_tokens": 15, + "total_tokens": 95 + } +} +``` + +**验证点:** +- 强制使用了 `get_weather` 而非 `get_news` + +--- + +### 用例 12:废弃的 function_call 格式(向后兼容) + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "查天气" + } + ], + "functions": [ + { + "name": "get_weather", + "description": "获取天气", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + } + ], + "function_call": "auto" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-012", + "object": "chat.completion", + "created": 1700000012, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "function_call": { + "name": "get_weather", + "arguments": "{\"city\": \"北京\"}" + } + }, + "finish_reason": "function_call", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 60, + "completion_tokens": 20, + "total_tokens": 80 + } +} +``` + +**验证点:** +- 废弃的 `functions`/`function_call` 格式仍能被正确处理 +- `finish_reason` == `"function_call"`(而非 `"tool_calls"`) + +--- + +### 用例 13:temperature + top_p 参数 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "写一首关于春天的短诗" + } + ], + "temperature": 0.9, + "top_p": 0.95 +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-013", + "object": "chat.completion", + "created": 1700000013, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "春风拂面花自开,\n柳絮飞舞满园香。\n燕归巢中呢喃语,\n万物复苏迎朝阳。" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 18, + "completion_tokens": 32, + "total_tokens": 50 + } +} +``` + +--- + +### 用例 14:max_tokens 截断 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "请详细介绍一下人工智能的发展历史" + } + ], + "max_tokens": 30 +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-014", + "object": "chat.completion", + "created": 1700000014, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "人工智能起源于1950年代,图灵提出了机器能否思考的问题。1956年达特茅斯会议正式确立了AI领域。此后经历了多次起伏..." + }, + "finish_reason": "length", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 20, + "completion_tokens": 30, + "total_tokens": 50 + } +} +``` + +**验证点:** +- `finish_reason` == `"length"` +- `completion_tokens` == 30(等于 max_tokens) +- 内容被截断 + +--- + +### 用例 15:stop 序列截断 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "从1数到10,每行一个数字" + } + ], + "stop": ["5"] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-015", + "object": "chat.completion", + "created": 1700000015, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "1\n2\n3\n4\n" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 18, + "completion_tokens": 10, + "total_tokens": 28 + } +} +``` + +**验证点:** +- `finish_reason` == `"stop"` +- 内容在遇到 stop 序列时截断 + +--- + +### 用例 16:frequency_penalty + presence_penalty + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "用不同的词汇描述'好'" + } + ], + "frequency_penalty": 0.5, + "presence_penalty": 0.3 +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-016", + "object": "chat.completion", + "created": 1700000016, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "优秀、出色、精彩、卓越、非凡、完美、绝佳、美妙、精彩、杰出" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 18, + "completion_tokens": 15, + "total_tokens": 33 + } +} +``` + +--- + +### 用例 17:response_format 为 json_object + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "提取以下信息的姓名和年龄:张三,今年25岁,是一名工程师" + } + ], + "response_format": { + "type": "json_object" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-017", + "object": "chat.completion", + "created": 1700000017, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"name\": \"张三\", \"age\": 25, \"occupation\": \"工程师\"}" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 35, + "completion_tokens": 25, + "total_tokens": 60 + } +} +``` + +**验证点:** +- `content` 是合法的 JSON 字符串 + +--- + +### 用例 18:response_format 为 json_schema(structured output) + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "创建一个用户信息记录,姓名李四,年龄30岁" + } + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "user_info", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"type": "string"} + }, + "required": ["name", "age"] + }, + "strict": true + } + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-018", + "object": "chat.completion", + "created": 1700000018, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"name\": \"李四\", \"age\": 30}" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 85, + "completion_tokens": 18, + "total_tokens": 103 + } +} +``` + +**验证点:** +- 输出严格符合 JSON schema 定义 +- 不包含 schema 未定义的字段 + +--- + +### 用例 19:流式文本响应(SSE) + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "你好" + } + ], + "stream": true +} +``` + +**期望 Mock 响应(200 OK, Content-Type: text/event-stream):** + +``` +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"好"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"!我"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"是"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"AI"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"助手"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":","},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"很高兴"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"为你"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"服务"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"。"},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-001","object":"chat.completion.chunk","created":1700000019,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: [DONE] +``` + +**验证点:** +- 每个 chunk 的 `id` 一致 +- 第一个 chunk 的 `delta` 包含 `role` +- 中间 chunk 的 `delta` 只包含 `content` +- 最后一个 chunk 包含 `finish_reason` +- 以 `[DONE]` 结束 + +--- + +### 用例 20:流式 + stream_options include_usage + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "你好" + } + ], + "stream": true, + "stream_options": { + "include_usage": true + } +} +``` + +**期望 Mock 响应(200 OK, Content-Type: text/event-stream):** + +最后一个 chunk 之前增加一个 usage chunk: + +``` +data: {"id":"chatcmpl-stream-020","object":"chat.completion.chunk","created":1700000020,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} + +... (中间内容 chunk) ... + +data: {"id":"chatcmpl-stream-020","object":"chat.completion.chunk","created":1700000020,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} + +data: {"id":"chatcmpl-stream-020","object":"chat.completion.chunk","created":1700000020,"model":"gpt-4o","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":25,"total_tokens":35}} + +data: [DONE] +``` + +**验证点:** +- 倒数第二个 chunk 包含 `usage` 字段 +- 该 chunk 的 `choices` 为空数组 + +--- + +### 用例 21:流式工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "北京天气怎么样?" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "获取天气", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + } + } + ], + "stream": true +} +``` + +**期望 Mock 响应(200 OK, Content-Type: text/event-stream):** + +``` +data: {"id":"chatcmpl-stream-021","object":"chat.completion.chunk","created":1700000021,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-021","object":"chat.completion.chunk","created":1700000021,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_stream1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-021","object":"chat.completion.chunk","created":1700000021,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-021","object":"chat.completion.chunk","created":1700000021,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"city"}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-021","object":"chat.completion.chunk","created":1700000021,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-021","object":"chat.completion.chunk","created":1700000021,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"北京"}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-021","object":"chat.completion.chunk","created":1700000021,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"}\"}"}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl-stream-021","object":"chat.completion.chunk","created":1700000021,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: [DONE] +``` + +**验证点:** +- 第一个 chunk 包含 `role` +- tool_calls 通过增量 chunk 逐步拼接 +- 最后一个 chunk 的 `finish_reason` == `"tool_calls"` + +--- + +### 用例 22:reasoning_effort 参数(o1/o3 模型) + +**请求:** +```json +{ + "model": "o3", + "messages": [ + { + "role": "user", + "content": "一个房间里有3个灯泡,房间外有3个开关,每个开关控制一个灯泡。你只能进房间一次,如何确定哪个开关控制哪个灯泡?" + } + ], + "reasoning_effort": "high" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-022", + "object": "chat.completion", + "created": 1700000022, + "model": "o3", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "解决方案:\n\n1. 打开第一个开关,等待几分钟\n2. 关闭第一个开关,打开第二个开关\n3. 立即进入房间\n\n此时:\n- 亮着的灯由第二个开关控制\n- 摸起来发热的灯由第一个开关控制(因为开过几分钟)\n- 既不亮也不热的灯由第三个开关控制" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 85, + "completion_tokens": 120, + "total_tokens": 205, + "completion_tokens_details": { + "reasoning_tokens": 80 + } + } +} +``` + +**验证点:** +- reasoning_tokens 显著增加 +- 响应展示推理结果 + +--- + +### 用例 23:无效 model 返回 404 + +**请求:** +```json +{ + "model": "nonexistent-model-xyz", + "messages": [ + { + "role": "user", + "content": "你好" + } + ] +} +``` + +**期望 Mock 响应(404 Not Found):** +```json +{ + "error": { + "message": "The model `nonexistent-model-xyz` does not exist or you do not have access to it.", + "type": "invalid_request_error", + "param": null, + "code": "model_not_found" + } +} +``` + +--- + +### 用例 24:缺少 messages 返回 400 + +**请求:** +```json +{ + "model": "gpt-4o" +} +``` + +**期望 Mock 响应(400 Bad Request):** +```json +{ + "error": { + "message": "'messages' is a required property and must be a non-empty array.", + "type": "invalid_request_error", + "param": null, + "code": "missing_required_field" + } +} +``` + +--- + +### 用例 25:内容安全策略拒绝 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "生成违法内容..." + } + ] +} +``` + +**期望 Mock 响应(400 Bad Request):** +```json +{ + "error": { + "message": "The content you submitted may violate our usage policies.", + "type": "invalid_request_error", + "param": null, + "code": "content_filter" + } +} +``` + +或者作为 completion 返回(finish_reason = "content_filter"): +```json +{ + "id": "chatcmpl-test-025", + "object": "chat.completion", + "created": 1700000025, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "refusal": "我无法生成此类内容。" + }, + "finish_reason": "content_filter", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 15, + "completion_tokens": 8, + "total_tokens": 23 + } +} +``` + +--- + +### 用例 26:n 参数多选择 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "用一句话描述春天" + } + ], + "n": 3 +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-026", + "object": "chat.completion", + "created": 1700000026, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "春天是万物复苏、百花盛开的季节。" + }, + "finish_reason": "stop", + "logprobs": null + }, + { + "index": 1, + "message": { + "role": "assistant", + "content": "春风拂面,绿意盎然,处处洋溢着生机与希望。" + }, + "finish_reason": "stop", + "logprobs": null + }, + { + "index": 2, + "message": { + "role": "assistant", + "content": "冬雪消融,嫩芽破土,春天带着温暖悄然而至。" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 18, + "completion_tokens": 60, + "total_tokens": 78 + } +} +``` + +**验证点:** +- `choices` 数组长度为 3 +- 每个 choice 有不同的 `index` + +--- + +### 用例 27:seed 参数可复现 + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "随机生成一个5位数字" + } + ], + "seed": 42, + "temperature": 0 +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-027", + "object": "chat.completion", + "created": 1700000027, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "12345" + }, + "finish_reason": "stop", + "logprobs": null + } + ], + "usage": { + "prompt_tokens": 18, + "completion_tokens": 5, + "total_tokens": 23 + }, + "system_fingerprint": "fp_test_seed42" +} +``` + +**验证点:** +- 相同 seed + temperature=0 应产生相同输出 +- `system_fingerprint` 可用于验证 backend 一致性 + +--- + +### 用例 28:logprobs + top_logprobs + +**请求:** +```json +{ + "model": "gpt-4o", + "messages": [ + { + "role": "user", + "content": "1+1=" + } + ], + "logprobs": true, + "top_logprobs": 3 +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "chatcmpl-test-028", + "object": "chat.completion", + "created": 1700000028, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "2" + }, + "finish_reason": "stop", + "logprobs": { + "content": [ + { + "token": "2", + "logprob": -0.001, + "bytes": [50], + "top_logprobs": [ + {"token": "2", "logprob": -0.001, "bytes": [50]}, + {"token": "二", "logprob": -3.5, "bytes": [201, 147]}, + {"token": " two", "logprob": -5.2, "bytes": [32, 116, 119, 111]} + ] + } + ], + "refusal": null + } + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 1, + "total_tokens": 11 + } +} +``` + +**验证点:** +- `logprobs.content` 数组包含每个生成 token 的概率信息 +- `top_logprobs` 包含前 K 个候选 token + +--- + +## 三、Mock 响应通用结构规范 + +### 非流式响应通用结构 + +```json +{ + "id": "chatcmpl-", + "object": "chat.completion", + "created": , + "model": "", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "" | null, + "refusal": "" | null, + "tool_calls": [ + { + "id": "call_", + "type": "function" | "custom", + "function": {"name": "", "arguments": ""} | null, + "custom": {"name": "", "input": ""} | null + } + ] + }, + "finish_reason": "stop" | "length" | "tool_calls" | "content_filter" | "function_call", + "logprobs": null | { "content": [...], "refusal": null } + } + ], + "usage": { + "prompt_tokens": , + "completion_tokens": , + "total_tokens": , + "prompt_tokens_details": { + "cached_tokens": | null, + "audio_tokens": | null + }, + "completion_tokens_details": { + "reasoning_tokens": | null, + "audio_tokens": | null, + "accepted_prediction_tokens": | null, + "rejected_prediction_tokens": | null + } + }, + "system_fingerprint": "" | null, + "service_tier": "auto" | "default" | "flex" | null +} +``` + +### 流式 Chunk 通用结构 + +```json +{ + "id": "chatcmpl-", + "object": "chat.completion.chunk", + "created": , + "model": "", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant" | null, + "content": "" | null, + "refusal": "" | null, + "tool_calls": [ + { + "index": , + "id": "call_" | null, + "type": "function" | "custom" | null, + "function": {"name": "" | null, "arguments": ""} | null, + "custom": {"name": "" | null, "input": ""} | null + } + ] + }, + "finish_reason": "stop" | "length" | "tool_calls" | "content_filter" | null, + "logprobs": null | { ... } + } + ], + "usage": { ... } | null, + "system_fingerprint": "" | null +} +``` + +### 错误响应通用结构 + +```json +{ + "error": { + "message": "", + "type": "invalid_request_error" | "authentication_error" | "api_error" | "rate_limit_error", + "param": null | "", + "code": "" + } +} +``` + diff --git a/docs/api_reference/test_mock_openai_responses.md b/docs/api_reference/test_mock_openai_responses.md new file mode 100644 index 0000000..1c0cf17 --- /dev/null +++ b/docs/api_reference/test_mock_openai_responses.md @@ -0,0 +1,2457 @@ +# OpenAI Responses API 端到端测试用例与 Mock 设计 + +本文档针对 `POST /responses` 接口(OpenAI Responses API),设计端到端测试用例及对应的 Mock 返回值结构。 + +--- + +## 一、测试场景总览 + +| 分类 | 测试用例 | 优先级 | +|------|----------|--------| +| 基础响应 | 1. 单轮纯文本响应 | P0 | +| 基础响应 | 2. 多轮对话(previous_response_id) | P0 | +| 基础响应 | 3. instructions 系统指令 | P1 | +| 基础响应 | 4. 含图片输入 | P1 | +| 基础响应 | 5. 含文件输入 | P1 | +| 参数控制 | 6. max_output_tokens 截断 | P1 | +| 参数控制 | 7. temperature + top_p | P1 | +| 参数控制 | 8. text.format 为 json_object | P1 | +| 参数控制 | 9. text.format 为 json_schema | P1 | +| 参数控制 | 10. text.verbosity 控制 | P2 | +| 推理模型 | 11. reasoning.effort 参数 | P1 | +| 推理模型 | 12. reasoning.generate_summary | P2 | +| 工具调用 | 13. function 工具调用 | P0 | +| 工具调用 | 14. 并行工具调用(parallel_tool_calls) | P1 | +| 工具调用 | 15. tool_choice 为 "required" | P2 | +| 工具调用 | 16. tool_choice 指定具体工具 | P2 | +| 工具调用 | 17. web_search 工具调用 | P1 | +| 工具调用 | 18. file_search 工具调用 | P2 | +| 工具调用 | 19. code_interpreter 工具调用 | P2 | +| 工具调用 | 20. image_generation 工具调用 | P2 | +| 工具调用 | 21. computer_use 工具调用 | P2 | +| 工具调用 | 22. mcp 工具调用 | P2 | +| 工具调用 | 23. custom 工具调用 | P2 | +| 流式响应 | 24. 流式文本响应(SSE) | P0 | +| 流式响应 | 25. 流式 + stream_options | P1 | +| 流式响应 | 26. 流式工具调用 | P1 | +| 上下文管理 | 27. truncation 为 "auto" | P2 | +| 上下文管理 | 28. context_management 配置 | P2 | +| 缓存 | 29. prompt_cache_key + prompt_cache_retention | P2 | +| 存储 | 30. store + background 模式 | P2 | +| 其他 | 31. metadata 元数据 | P2 | +| 其他 | 32. max_tool_calls 限制 | P2 | +| 其他 | 33. service_tier 选择 | P2 | +| 错误处理 | 34. 无效 model 返回错误 | P1 | +| 错误处理 | 35. 缺少 input 返回错误 | P1 | +| 错误处理 | 36. 内容安全策略拒绝 | P2 | +| Token 计数 | 37. /responses/input_tokens 端点 | P1 | + +--- + +## 二、测试用例详情 + +### 用例 1:单轮纯文本响应 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "你好,请介绍一下你自己" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test001", + "object": "response", + "created_at": 1700000001, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "msg_01test001", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "你好!我是由 OpenAI 开发的 AI 助手 GPT-4。我可以帮你回答问题、写作、编程、数学推理、数据分析等多种任务。我的知识截止到 2025 年 1 月。请问有什么我可以帮你的?" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 12, + "output_tokens": 68, + "total_tokens": 80 + }, + "metadata": {} +} +``` + +**验证点:** +- `status` == `"completed"` +- `output[0].type` == `"message"` +- `output[0].content[0].type` == `"output_text"` +- `usage` 字段完整 + +--- + +### 用例 2:多轮对话(previous_response_id) + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "能举个例子吗?", + "previous_response_id": "resp_01test001" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test002", + "object": "response", + "created_at": 1700000002, + "model": "gpt-4o", + "status": "completed", + "previous_response_id": "resp_01test001", + "output": [ + { + "id": "msg_01test002", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "当然。例如定义一个 `Reader` interface:\n\n```go\ntype Reader interface {\n Read(p []byte) (n int, err error)\n}\n```\n\n`os.File` 和 `bytes.Buffer` 都实现了 `Read` 方法,所以它们都自动实现了 `Reader` interface。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 120, + "output_tokens": 85, + "total_tokens": 205 + }, + "metadata": {} +} +``` + +--- + +### 用例 3:instructions 系统指令 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "什么是递归?", + "instructions": "你是一个专业的编程助手,请用简洁的语言回答问题。" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test003", + "object": "response", + "created_at": 1700000003, + "model": "gpt-4o", + "status": "completed", + "instructions": "你是一个专业的编程助手,请用简洁的语言回答问题。", + "output": [ + { + "id": "msg_01test003", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "递归是一种函数调用自身来解决问题的方法。它包含两个关键部分:\n\n1. **基准情况**:直接返回结果的终止条件\n2. **递归情况**:将问题分解为更小的子问题,调用自身解决\n\n例如计算阶乘:`n! = n × (n-1)!`,基准情况是 `0! = 1`。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 45, + "output_tokens": 72, + "total_tokens": 117 + }, + "metadata": {} +} +``` + +--- + +### 用例 4:含图片输入 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": [ + { + "type": "input_text", + "text": "这张图片里有什么?" + }, + { + "type": "input_image", + "image_url": "https://example.com/images/cat.jpg", + "detail": "auto" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test004", + "object": "response", + "created_at": 1700000004, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "msg_01test004", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "图片中有一只橘色的猫,它正躺在沙发上晒太阳。猫的毛色是橘白相间的,眼睛是绿色的,看起来非常放松。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 1280, + "output_tokens": 45, + "total_tokens": 1325 + }, + "metadata": {} +} +``` + +--- + +### 用例 5:含文件输入 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": [ + { + "type": "input_text", + "text": "请总结这份文档的主要内容" + }, + { + "type": "input_file", + "file_id": "file-abc123", + "filename": "report.pdf" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test005", + "object": "response", + "created_at": 1700000005, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "msg_01test005", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "这份文档是一份关于机器学习入门的指南,主要内容包括:\n\n1. **机器学习概述**:定义、历史发展、主要应用领域\n2. **监督学习**:分类、回归、常见算法\n3. **无监督学习**:聚类、降维\n4. **深度学习基础**:神经网络结构、反向传播\n5. **实践建议**:数据预处理、模型评估、超参数调优\n\n文档适合初学者阅读,提供了清晰的理论框架和实用的学习路径建议。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 5200, + "output_tokens": 145, + "total_tokens": 5345 + }, + "metadata": {} +} +``` + +--- + +### 用例 6:max_output_tokens 截断 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "请详细介绍一下人工智能的发展历史", + "max_output_tokens": 30 +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test006", + "object": "response", + "created_at": 1700000006, + "model": "gpt-4o", + "status": "completed", + "max_output_tokens": 30, + "output": [ + { + "id": "msg_01test006", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "人工智能起源于1950年代,图灵提出了机器能否思考的问题。1956年达特茅斯会议正式确立了AI领域。此后经历了多次起伏..." + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "incomplete_details": { + "reason": "max_output_tokens", + "details": null + }, + "usage": { + "input_tokens": 20, + "output_tokens": 30, + "total_tokens": 50 + }, + "metadata": {} +} +``` + +**验证点:** +- `incomplete_details.reason` == `"max_output_tokens"` +- `output_tokens` == 30(等于 max_output_tokens) + +--- + +### 用例 7:temperature + top_p + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "写一首关于春天的短诗", + "temperature": 0.9, + "top_p": 0.95 +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test007", + "object": "response", + "created_at": 1700000007, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "msg_01test007", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "春风拂面花自开,\n柳絮飞舞满园香。\n燕归巢中呢喃语,\n万物复苏迎朝阳。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 0.9, + "tool_choice": "auto", + "top_p": 0.95, + "usage": { + "input_tokens": 18, + "output_tokens": 32, + "total_tokens": 50 + }, + "metadata": {} +} +``` + +--- + +### 用例 8:text.format 为 json_object + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "提取以下信息的姓名和年龄:张三,今年25岁,是一名工程师", + "text": { + "format": { + "type": "json_object" + } + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test008", + "object": "response", + "created_at": 1700000008, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "msg_01test008", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "{\"name\": \"张三\", \"age\": 25, \"occupation\": \"工程师\"}" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "text": { + "format": { + "type": "json_object" + } + }, + "usage": { + "input_tokens": 35, + "output_tokens": 25, + "total_tokens": 60 + }, + "metadata": {} +} +``` + +**验证点:** +- `content[0].text` 是合法的 JSON 字符串 + +--- + +### 用例 9:text.format 为 json_schema + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "创建一个用户信息记录,姓名李四,年龄30岁", + "text": { + "format": { + "type": "json_schema", + "name": "user_info", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"type": "string"} + }, + "required": ["name", "age"], + "additionalProperties": false, + "strict": true + } + } + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test009", + "object": "response", + "created_at": 1700000009, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "msg_01test009", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "{\"name\": \"李四\", \"age\": 30}" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "text": { + "format": { + "type": "json_schema", + "name": "user_info", + "schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"type": "string"} + }, + "required": ["name", "age"], + "additionalProperties": false, + "strict": true + } + } + }, + "usage": { + "input_tokens": 120, + "output_tokens": 18, + "total_tokens": 138 + }, + "metadata": {} +} +``` + +**验证点:** +- 输出严格符合 JSON schema 定义 + +--- + +### 用例 10:text.verbosity 控制 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "解释什么是 API", + "text": { + "verbosity": "low" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test010", + "object": "response", + "created_at": 1700000010, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "msg_01test010", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "API(应用程序接口)是软件系统之间交互的接口,定义了不同程序之间通信的规则和方法。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "text": { + "verbosity": "low" + }, + "usage": { + "input_tokens": 15, + "output_tokens": 28, + "total_tokens": 43 + }, + "metadata": {} +} +``` + +--- + +### 用例 11:reasoning.effort 参数 + +**请求:** +```json +{ + "model": "o3", + "input": "一个房间里有3个灯泡,房间外有3个开关,每个开关控制一个灯泡。你只能进房间一次,如何确定哪个开关控制哪个灯泡?", + "reasoning": { + "effort": "high" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test011", + "object": "response", + "created_at": 1700000011, + "model": "o3", + "status": "completed", + "reasoning": { + "effort": "high", + "summary": "这是一个经典的逻辑推理问题,需要利用灯泡的两种可观察状态(亮/灭)和一种物理属性(温度)来区分三个开关。" + }, + "output": [ + { + "id": "reason_01test011", + "type": "reasoning", + "content": "我需要找到一种方法,通过一次观察来区分三个开关。关键是灯泡有两种可观察的状态:亮或灭。但如果只用亮灭,我只能区分一个开关(开的那个)。我需要找到另一种可观察的属性...温度!灯泡开着会发热。" + }, + { + "id": "msg_01test011", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "解决方案如下:\n\n1. **打开第一个开关**,等待几分钟\n2. **关闭第一个开关**,**打开第二个开关**\n3. **立即进入房间**\n\n此时观察:\n- **亮着的灯** → 由第二个开关控制\n- **摸起来发热的灯** → 由第一个开关控制\n- **既不亮也不热的灯** → 由第三个开关控制" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 95, + "output_tokens": 280, + "total_tokens": 375, + "output_tokens_details": { + "reasoning_tokens": 180 + } + }, + "metadata": {} +} +``` + +**验证点:** +- `reasoning` 配置被正确返回 +- `output` 包含 `reasoning` 类型的输出项 +- `reasoning_tokens` 在 usage 详情中体现 + +--- + +### 用例 12:reasoning.generate_summary + +**请求:** +```json +{ + "model": "o3", + "input": "请帮我分析这个算法问题", + "reasoning": { + "effort": "medium", + "generate_summary": "concise" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test012", + "object": "response", + "created_at": 1700000012, + "model": "o3", + "status": "completed", + "reasoning": { + "effort": "medium", + "generate_summary": "concise", + "summary": "用户没有提供具体的算法问题,需要请用户提供更多细节。" + }, + "output": [ + { + "id": "reason_01test012", + "type": "reasoning", + "content": "用户提到了一个算法问题,但没有具体说明是什么问题。我需要请用户提供更多细节。" + }, + { + "id": "msg_01test012", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "我很乐意帮你分析算法问题!不过你没有具体说明是什么问题。请详细描述一下:\n\n1. 问题的具体要求是什么?\n2. 输入输出的格式是怎样的?\n3. 有没有什么约束条件?" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 35, + "output_tokens": 95, + "total_tokens": 130, + "output_tokens_details": { + "reasoning_tokens": 45 + } + }, + "metadata": {} +} +``` + +--- + +### 用例 13:function 工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "北京天气怎么样?", + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取指定城市的天气信息", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "城市名称" + } + }, + "required": ["city"] + }, + "strict": true + } + ], + "tool_choice": "auto" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test013", + "object": "response", + "created_at": 1700000013, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "fc_01test013", + "type": "function_call", + "call_id": "call_01test013", + "name": "get_weather", + "arguments": "{\"city\": \"北京\"}", + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取指定城市的天气信息", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + }, + "strict": true + } + ], + "usage": { + "input_tokens": 180, + "output_tokens": 42, + "total_tokens": 222 + }, + "metadata": {} +} +``` + +**验证点:** +- `output[0].type` == `"function_call"` +- `arguments` 是合法的 JSON 字符串 + +--- + +### 用例 14:并行工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "帮我查一下北京、上海、广州三个城市的天气", + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取指定城市的天气信息", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + } + ], + "parallel_tool_calls": true +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test014", + "object": "response", + "created_at": 1700000014, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "fc_01test014a", + "type": "function_call", + "call_id": "call_01test014a", + "name": "get_weather", + "arguments": "{\"city\": \"北京\"}", + "status": "completed" + }, + { + "id": "fc_01test014b", + "type": "function_call", + "call_id": "call_01test014b", + "name": "get_weather", + "arguments": "{\"city\": \"上海\"}", + "status": "completed" + }, + { + "id": "fc_01test014c", + "type": "function_call", + "call_id": "call_01test014c", + "name": "get_weather", + "arguments": "{\"city\": \"广州\"}", + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取指定城市的天气信息", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + } + ], + "usage": { + "input_tokens": 200, + "output_tokens": 120, + "total_tokens": 320 + }, + "metadata": {} +} +``` + +**验证点:** +- `output` 包含 3 个 `function_call` 类型的项 + +--- + +### 用例 15:tool_choice 为 "required" + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "随便聊聊", + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取天气", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + } + } + } + ], + "tool_choice": "required" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test015", + "object": "response", + "created_at": 1700000015, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "fc_01test015", + "type": "function_call", + "call_id": "call_01test015", + "name": "get_weather", + "arguments": "{}", + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "required", + "top_p": 1.0, + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取天气", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + } + } + } + ], + "usage": { + "input_tokens": 150, + "output_tokens": 35, + "total_tokens": 185 + }, + "metadata": {} +} +``` + +**验证点:** +- 模型必须调用工具 + +--- + +### 用例 16:tool_choice 指定具体工具 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "查天气", + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取天气", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}} + }, + { + "type": "function", + "name": "get_news", + "description": "获取新闻", + "parameters": {"type": "object", "properties": {"topic": {"type": "string"}}} + } + ], + "tool_choice": { + "type": "function", + "name": "get_weather" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test016", + "object": "response", + "created_at": 1700000016, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "fc_01test016", + "type": "function_call", + "call_id": "call_01test016", + "name": "get_weather", + "arguments": "{}", + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": { + "type": "function", + "name": "get_weather" + }, + "top_p": 1.0, + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取天气", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}} + }, + { + "type": "function", + "name": "get_news", + "description": "获取新闻", + "parameters": {"type": "object", "properties": {"topic": {"type": "string"}}} + } + ], + "usage": { + "input_tokens": 200, + "output_tokens": 35, + "total_tokens": 235 + }, + "metadata": {} +} +``` + +**验证点:** +- 强制使用了 `get_weather` 而非 `get_news` + +--- + +### 用例 17:web_search 工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "今天有什么重要的科技新闻?", + "tools": [ + { + "type": "web_search", + "search_context_size": "medium", + "user_location": { + "type": "approximate", + "country": "CN" + } + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test017", + "object": "response", + "created_at": 1700000017, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "ws_01test017", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "今天 重要 科技新闻", + "sources": [ + { + "url": "https://example.com/tech-news-1", + "title": "OpenAI 发布 GPT-5 更新", + "summary": "新的 GPT-5 版本在多个基准测试中表现优异..." + } + ] + } + }, + { + "id": "msg_01test017", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "根据最新的搜索结果,今天有以下重要科技新闻:\n\n1. **AI 领域**:OpenAI 发布了 GPT-5 的更新版本,在代码生成和数学推理能力上有显著提升...\n\n2. **智能手机**:Apple 宣布了新一代 iPhone 的发布日期...\n\n3. **电动汽车**:Tesla 公布了最新的自动驾驶技术进展..." + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "tools": [ + { + "type": "web_search", + "search_context_size": "medium", + "user_location": { + "type": "approximate", + "country": "CN" + } + } + ], + "usage": { + "input_tokens": 180, + "output_tokens": 150, + "total_tokens": 330 + }, + "metadata": {} +} +``` + +--- + +### 用例 18:file_search 工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "在我的文档中搜索关于机器学习的内容", + "tools": [ + { + "type": "file_search", + "vector_store_ids": ["vs_abc123"], + "max_num_results": 10, + "ranking_options": { + "ranker": "auto", + "score_threshold": 0.5 + } + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test018", + "object": "response", + "created_at": 1700000018, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "fs_01test018", + "type": "file_search_call", + "status": "completed", + "queries": ["机器学习"], + "results": [ + { + "file_id": "file-xyz789", + "filename": "ml_intro.pdf", + "score": 0.92, + "attributes": {}, + "text": "机器学习是人工智能的一个分支,它使计算机能够从数据中学习..." + } + ] + }, + { + "id": "msg_01test018", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "我在你的文档中找到了关于机器学习的内容:\n\n**机器学习**是人工智能的一个分支,它使计算机能够从数据中学习模式和规律,而无需显式编程。主要类型包括监督学习、无监督学习和强化学习。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "tools": [ + { + "type": "file_search", + "vector_store_ids": ["vs_abc123"], + "max_num_results": 10, + "ranking_options": { + "ranker": "auto", + "score_threshold": 0.5 + } + } + ], + "usage": { + "input_tokens": 250, + "output_tokens": 85, + "total_tokens": 335 + }, + "metadata": {} +} +``` + +--- + +### 用例 19:code_interpreter 工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "帮我计算斐波那契数列的前10项", + "tools": [ + { + "type": "code_interpreter", + "container": "auto" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test019", + "object": "response", + "created_at": 1700000019, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "ci_01test019", + "type": "code_interpreter_call", + "status": "completed", + "code": "fib = [0, 1]\nfor i in range(2, 10):\n fib.append(fib[i-1] + fib[i-2])\nprint(fib)", + "container_id": "container_abc123", + "outputs": [ + { + "type": "logs", + "logs": "[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]" + } + ] + }, + { + "id": "msg_01test019", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "斐波那契数列的前10项是:\n\n**0, 1, 1, 2, 3, 5, 8, 13, 21, 34**\n\n这个数列的规律是:每一项等于前两项之和。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "tools": [ + { + "type": "code_interpreter", + "container": "auto" + } + ], + "usage": { + "input_tokens": 120, + "output_tokens": 95, + "total_tokens": 215 + }, + "metadata": {} +} +``` + +--- + +### 用例 20:image_generation 工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "生成一张日出的图片", + "tools": [ + { + "type": "image_generation", + "action": "generate", + "size": "1024x1024", + "quality": "high", + "output_format": "png" + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test020", + "object": "response", + "created_at": 1700000020, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "ig_01test020", + "type": "image_generation_call", + "status": "completed", + "result": { + "type": "image", + "image": { + "file_id": "file-img001", + "url": "https://example.com/generated/sunrise.png" + } + } + }, + { + "id": "msg_01test020", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "我已经为你生成了一张日出的图片!画面中金色的阳光洒满天际,云彩被染成了橙红色,远处的山峦在晨光中若隐若现。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "tools": [ + { + "type": "image_generation", + "action": "generate", + "size": "1024x1024", + "quality": "high", + "output_format": "png" + } + ], + "usage": { + "input_tokens": 85, + "output_tokens": 75, + "total_tokens": 160 + }, + "metadata": {} +} +``` + +--- + +### 用例 21:computer_use 工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "帮我打开浏览器访问 Google", + "tools": [ + { + "type": "computer_use_preview", + "environment": "browser", + "display_width": 1024, + "display_height": 768 + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test021", + "object": "response", + "created_at": 1700000021, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "cc_01test021", + "type": "computer_call", + "status": "in_progress", + "call_id": "call_01test021", + "pending_safety_checks": [], + "action": { + "type": "click", + "x": 400, + "y": 50, + "button": "left" + } + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "tools": [ + { + "type": "computer_use_preview", + "environment": "browser", + "display_width": 1024, + "display_height": 768 + } + ], + "usage": { + "input_tokens": 150, + "output_tokens": 55, + "total_tokens": 205 + }, + "metadata": {} +} +``` + +--- + +### 用例 22:mcp 工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "查看我的日历安排", + "tools": [ + { + "type": "mcp", + "server_label": "google_calendar", + "connector_id": "conn_calendar_001", + "allowed_tools": ["list_events", "create_event"] + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test022", + "object": "response", + "created_at": 1700000022, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "mcp_list_022", + "type": "mcp_list_tools", + "status": "completed", + "tools": [ + { + "name": "list_events", + "description": "列出指定时间范围内的日历事件" + }, + { + "name": "create_event", + "description": "创建新的日历事件" + } + ] + }, + { + "id": "mcp_call_022", + "type": "mcp_call", + "status": "completed", + "call_id": "call_mcp_022", + "name": "list_events", + "arguments": "{\"date\": \"today\"}", + "result": { + "content": [ + { + "type": "text", + "text": "今天你有3个日程安排:\n1. 10:00 - 团队站会\n2. 14:00 - 产品评审会议\n3. 16:00 - 1:1 与经理" + } + ] + } + }, + { + "id": "msg_01test022", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "你今天有以下日程安排:\n\n1. **10:00** - 团队站会\n2. **14:00** - 产品评审会议\n3. **16:00** - 1:1 与经理" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "tools": [ + { + "type": "mcp", + "server_label": "google_calendar", + "connector_id": "conn_calendar_001", + "allowed_tools": ["list_events", "create_event"] + } + ], + "usage": { + "input_tokens": 200, + "output_tokens": 120, + "total_tokens": 320 + }, + "metadata": {} +} +``` + +--- + +### 用例 23:custom 工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "执行自定义操作", + "tools": [ + { + "type": "custom", + "name": "my_custom_tool", + "description": "一个自定义工具", + "format": { + "type": "text" + } + } + ] +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test023", + "object": "response", + "created_at": 1700000023, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "ct_01test023", + "type": "custom_tool_call", + "status": "completed", + "call_id": "call_custom_023", + "name": "my_custom_tool", + "input": "{\"action\": \"test\"}" + }, + { + "id": "msg_01test023", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "自定义操作已成功执行。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "tools": [ + { + "type": "custom", + "name": "my_custom_tool", + "description": "一个自定义工具", + "format": { + "type": "text" + } + } + ], + "usage": { + "input_tokens": 80, + "output_tokens": 45, + "total_tokens": 125 + }, + "metadata": {} +} +``` + +--- + +### 用例 24:流式文本响应(SSE) + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "你好", + "stream": true +} +``` + +**期望 Mock 响应(200 OK, Content-Type: text/event-stream):** + +``` +event: response.created +data: {"id":"resp_01stream001","object":"response","created_at":1700000024,"model":"gpt-4o","status":"in_progress","output":[],"parallel_tool_calls":true,"temperature":1.0,"tool_choice":"auto","top_p":1.0,"metadata":{}} + +event: response.in_progress +data: {"id":"resp_01stream001","object":"response","created_at":1700000024,"model":"gpt-4o","status":"in_progress"} + +event: response.output_item.added +data: {"id":"resp_01stream001","object":"response","item":{"id":"msg_01stream001","type":"message","role":"assistant","content":[]}} + +event: response.content_part.added +data: {"id":"resp_01stream001","object":"response","part":{"type":"output_text","text":""},"item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"你","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"好","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"!","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"我","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"是","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"AI","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"助手","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":",","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"很高兴","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"为你","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"服务","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_text.delta +data: {"id":"resp_01stream001","object":"response","delta":"。","item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.content_part.done +data: {"id":"resp_01stream001","object":"response","part":{"type":"output_text","text":"你好!我是AI助手,很高兴为你服务。"},"item_id":"msg_01stream001","output_index":0,"content_index":0} + +event: response.output_item.done +data: {"id":"resp_01stream001","object":"response","item":{"id":"msg_01stream001","type":"message","role":"assistant","content":[{"type":"output_text","text":"你好!我是AI助手,很高兴为你服务。"}],"status":"completed"}} + +event: response.completed +data: {"id":"resp_01stream001","object":"response","created_at":1700000024,"model":"gpt-4o","status":"completed","output":[{"id":"msg_01stream001","type":"message","role":"assistant","content":[{"type":"output_text","text":"你好!我是AI助手,很高兴为你服务。"}],"status":"completed"}],"parallel_tool_calls":true,"temperature":1.0,"tool_choice":"auto","top_p":1.0,"usage":{"input_tokens":10,"output_tokens":18,"total_tokens":28},"metadata":{}} +``` + +**验证点:** +- 事件顺序:response.created → response.in_progress → response.output_item.added → response.content_part.added → response.output_text.delta* → response.content_part.done → response.output_item.done → response.completed +- `response.created` 包含初始 response 对象 +- `response.output_text.delta` 包含增量文本 +- `response.completed` 包含完整的 response 对象和 usage + +--- + +### 用例 25:流式 + stream_options + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "你好", + "stream": true, + "stream_options": { + "include_obfuscation": true + } +} +``` + +**期望 Mock 响应(200 OK, Content-Type: text/event-stream):** + +与用例 24 类似,但部分 event 数据中包含随机填充字符以混淆 payload 大小: + +``` +event: response.created +data: {"id":"resp_01stream002","object":"response","created_at":1700000025,"model":"gpt-4o","status":"in_progress","output":[],"parallel_tool_calls":true,"temperature":1.0,"tool_choice":"auto","top_p":1.0,"metadata":{}} + +... (正常 events) ... + +event: response.output_text.delta +data: {"id":"resp_01stream002","object":"response","delta":"你","item_id":"msg_01stream002","output_index":0,"content_index":0,"_obfuscation":"xxxxxxxxxxxxxxxxxxxx"} + +... (更多 events 带有 _obfuscation 字段) ... + +event: response.completed +data: {"id":"resp_01stream002","object":"response","created_at":1700000025,"model":"gpt-4o","status":"completed","output":[...],"usage":{"input_tokens":10,"output_tokens":18,"total_tokens":28},"metadata":{}} +``` + +**验证点:** +- 部分 event 包含 `_obfuscation` 字段 +- 不影响正常数据解析 + +--- + +### 用例 26:流式工具调用 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "北京天气怎么样?", + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取天气", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + } + } + ], + "stream": true +} +``` + +**期望 Mock 响应(200 OK, Content-Type: text/event-stream):** + +``` +event: response.created +data: {"id":"resp_01stream003","object":"response","created_at":1700000026,"model":"gpt-4o","status":"in_progress","output":[],"parallel_tool_calls":true,"temperature":1.0,"tool_choice":"auto","top_p":1.0,"tools":[{"type":"function","name":"get_weather","description":"获取天气","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}],"metadata":{}} + +event: response.in_progress +data: {"id":"resp_01stream003","object":"response","created_at":1700000026,"model":"gpt-4o","status":"in_progress"} + +event: response.output_item.added +data: {"id":"resp_01stream003","object":"response","item":{"id":"fc_01stream003","type":"function_call","call_id":"call_01stream003","name":"get_weather","arguments":""}} + +event: response.function_call_arguments.delta +data: {"id":"resp_01stream003","object":"response","delta":"{\"","item_id":"fc_01stream003","output_index":0} + +event: response.function_call_arguments.delta +data: {"id":"resp_01stream003","object":"response","delta":"city","item_id":"fc_01stream003","output_index":0} + +event: response.function_call_arguments.delta +data: {"id":"resp_01stream003","object":"response","delta":"\":\"","item_id":"fc_01stream003","output_index":0} + +event: response.function_call_arguments.delta +data: {"id":"resp_01stream003","object":"response","delta":"北京","item_id":"fc_01stream003","output_index":0} + +event: response.function_call_arguments.delta +data: {"id":"resp_01stream003","object":"response","delta":"\"}","item_id":"fc_01stream003","output_index":0} + +event: response.output_item.done +data: {"id":"resp_01stream003","object":"response","item":{"id":"fc_01stream003","type":"function_call","call_id":"call_01stream003","name":"get_weather","arguments":"{\"city\": \"北京\"}","status":"completed"}} + +event: response.completed +data: {"id":"resp_01stream003","object":"response","created_at":1700000026,"model":"gpt-4o","status":"completed","output":[{"id":"fc_01stream003","type":"function_call","call_id":"call_01stream003","name":"get_weather","arguments":"{\"city\": \"北京\"}","status":"completed"}],"parallel_tool_calls":true,"temperature":1.0,"tool_choice":"auto","top_p":1.0,"usage":{"input_tokens":180,"output_tokens":42,"total_tokens":222},"metadata":{}} +``` + +**验证点:** +- `response.function_call_arguments.delta` 逐步拼接完整的 JSON 参数 +- 最终 `response.completed` 包含完整的 function_call + +--- + +### 用例 27:truncation 为 "auto" + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "你好", + "truncation": "auto" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test027", + "object": "response", + "created_at": 1700000027, + "model": "gpt-4o", + "status": "completed", + "truncation": "auto", + "output": [ + { + "id": "msg_01test027", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "你好!我是由 OpenAI 开发的 AI 助手。请问有什么我可以帮你的?" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 10, + "output_tokens": 22, + "total_tokens": 32 + }, + "metadata": {} +} +``` + +**验证点:** +- 当输入超过上下文窗口时,自动从开头截断 + +--- + +### 用例 28:context_management 配置 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "你好", + "context_management": { + "compaction": { + "enabled": true, + "trigger_tokens": 100000 + } + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test028", + "object": "response", + "created_at": 1700000028, + "model": "gpt-4o", + "status": "completed", + "context_management": { + "compaction": { + "enabled": true, + "trigger_tokens": 100000 + } + }, + "output": [ + { + "id": "msg_01test028", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "你好!我是由 OpenAI 开发的 AI 助手。请问有什么我可以帮你的?" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 10, + "output_tokens": 22, + "total_tokens": 32 + }, + "metadata": {} +} +``` + +--- + +### 用例 29:prompt_cache_key + prompt_cache_retention + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "你好", + "instructions": "你是一个专业的编程助手。", + "prompt_cache_key": "programming_assistant_v1", + "prompt_cache_retention": "24h" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test029", + "object": "response", + "created_at": 1700000029, + "model": "gpt-4o", + "status": "completed", + "prompt_cache_key": "programming_assistant_v1", + "prompt_cache_retention": "24h", + "output": [ + { + "id": "msg_01test029", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "你好!我是你的专业编程助手,擅长解答各种编程问题、代码审查、架构设计等。请问有什么我可以帮助你的?" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 25, + "output_tokens": 35, + "total_tokens": 60, + "input_tokens_details": { + "cached_tokens": 15 + } + }, + "metadata": {} +} +``` + +**验证点:** +- `input_tokens_details.cached_tokens` 反映缓存命中的 token 数 + +--- + +### 用例 30:store + background 模式 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "请分析这份数据", + "store": true, + "background": true +} +``` + +**期望 Mock 响应(202 Accepted):** +```json +{ + "id": "resp_01test030", + "object": "response", + "created_at": 1700000030, + "model": "gpt-4o", + "status": "queued", + "background": true, + "store": true, + "output": [], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "metadata": {} +} +``` + +**验证点:** +- `status` == `"queued"` +- `background` == `true` +- `store` == `true` + +--- + +### 用例 31:metadata 元数据 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "你好", + "metadata": { + "user_id": "user_12345", + "session_id": "session_abc", + "request_source": "web_app" + } +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test031", + "object": "response", + "created_at": 1700000031, + "model": "gpt-4o", + "status": "completed", + "metadata": { + "user_id": "user_12345", + "session_id": "session_abc", + "request_source": "web_app" + }, + "output": [ + { + "id": "msg_01test031", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "你好!我是由 OpenAI 开发的 AI 助手。请问有什么我可以帮你的?" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 10, + "output_tokens": 22, + "total_tokens": 32 + } +} +``` + +--- + +### 用例 32:max_tool_calls 限制 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "帮我查北京、上海、广州、深圳四个城市的天气", + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取天气", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}} + } + ], + "max_tool_calls": 2 +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test032", + "object": "response", + "created_at": 1700000032, + "model": "gpt-4o", + "status": "incomplete", + "max_tool_calls": 2, + "output": [ + { + "id": "fc_01test032a", + "type": "function_call", + "call_id": "call_01test032a", + "name": "get_weather", + "arguments": "{\"city\": \"北京\"}", + "status": "completed" + }, + { + "id": "fc_01test032b", + "type": "function_call", + "call_id": "call_01test032b", + "name": "get_weather", + "arguments": "{\"city\": \"上海\"}", + "status": "completed" + } + ], + "incomplete_details": { + "reason": "max_tool_calls", + "details": null + }, + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "tools": [ + { + "type": "function", + "name": "get_weather", + "description": "获取天气", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}} + } + ], + "usage": { + "input_tokens": 200, + "output_tokens": 80, + "total_tokens": 280 + }, + "metadata": {} +} +``` + +**验证点:** +- `status` == `"incomplete"` +- `incomplete_details.reason` == `"max_tool_calls"` +- 只调用了 2 个工具(受 max_tool_calls 限制) + +--- + +### 用例 33:service_tier 选择 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "你好", + "service_tier": "flex" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test033", + "object": "response", + "created_at": 1700000033, + "model": "gpt-4o", + "status": "completed", + "service_tier": "flex", + "output": [ + { + "id": "msg_01test033", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "你好!我是由 OpenAI 开发的 AI 助手。请问有什么我可以帮你的?" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 10, + "output_tokens": 22, + "total_tokens": 32 + }, + "metadata": {} +} +``` + +--- + +### 用例 34:无效 model 返回错误 + +**请求:** +```json +{ + "model": "nonexistent-model-xyz", + "input": "你好" +} +``` + +**期望 Mock 响应(400 Bad Request):** +```json +{ + "error": { + "code": "invalid_model", + "message": "The model 'nonexistent-model-xyz' does not exist or you do not have access to it." + } +} +``` + +--- + +### 用例 35:缺少 input 返回错误 + +**请求:** +```json +{ + "model": "gpt-4o" +} +``` + +**期望 Mock 响应(400 Bad Request):** +```json +{ + "error": { + "code": "missing_input", + "message": "The 'input' parameter is required. Please provide text, image, or file input." + } +} +``` + +--- + +### 用例 36:内容安全策略拒绝 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "生成违法内容..." +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "id": "resp_01test036", + "object": "response", + "created_at": 1700000036, + "model": "gpt-4o", + "status": "completed", + "output": [ + { + "id": "msg_01test036", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "refusal", + "refusal": "抱歉,我无法生成此类内容。我的设计原则是提供有益、安全和负责任的帮助。如果你有其他问题或需要帮助的地方,我很乐意为你服务。" + } + ], + "status": "completed" + } + ], + "parallel_tool_calls": true, + "temperature": 1.0, + "tool_choice": "auto", + "top_p": 1.0, + "usage": { + "input_tokens": 15, + "output_tokens": 42, + "total_tokens": 57 + }, + "metadata": {} +} +``` + +**验证点:** +- `content[0].type` == `"refusal"` + +--- + +### 用例 37:/responses/input_tokens 端点 + +**请求:** +```json +{ + "model": "gpt-4o", + "input": "你好,请介绍一下你自己" +} +``` + +**期望 Mock 响应(200 OK):** +```json +{ + "object": "response.input_tokens", + "input_tokens": 12 +} +``` + +**验证点:** +- `object` == `"response.input_tokens"` +- `input_tokens` 返回正确的 token 数量 +- 不生成实际响应内容 + +--- + +## 三、Mock 响应通用结构规范 + +### 非流式响应通用结构 + +```json +{ + "id": "resp_", + "object": "response", + "created_at": , + "model": "", + "status": "completed" | "failed" | "in_progress" | "cancelled" | "queued" | "incomplete", + "output": [ + { + "id": "", + "type": "message" | "function_call" | "function_call_output" | "web_search_call" | "file_search_call" | "code_interpreter_call" | "image_generation_call" | "computer_call" | "computer_call_output" | "reasoning" | "compaction" | "mcp_list_tools" | "mcp_call" | "mcp_approval_request" | "mcp_approval_response" | "custom_tool_call" | "custom_tool_call_output" | "tool_search_call" | "tool_search_output" | "shell_call" | "shell_call_output" | "local_shell_call" | "local_shell_call_output" | "apply_patch_call" | "apply_patch_call_output" | "item_reference", + "role": "assistant" | "user", + "content": [ + { + "type": "output_text" | "refusal", + "text": "" | null, + "refusal": "" | null, + "annotations": [] + } + ], + "status": "completed" | "in_progress" | "incomplete", + "call_id": "" | null, + "name": "" | null, + "arguments": "" | null, + "code": "" | null, + "container_id": "" | null, + "outputs": [] | null, + "result": {} | null, + "action": {} | null, + "pending_safety_checks": [], + "content_text": "" | null + } + ], + "parallel_tool_calls": true | false, + "temperature": , + "tool_choice": "auto" | "none" | "required" | {}, + "tools": [], + "top_p": , + "usage": { + "input_tokens": , + "output_tokens": , + "total_tokens": , + "input_tokens_details": { + "cached_tokens": | null + }, + "output_tokens_details": { + "reasoning_tokens": | null + } + }, + "metadata": {}, + "instructions": "" | null, + "max_output_tokens": | null, + "max_tool_calls": | null, + "previous_response_id": "" | null, + "reasoning": {"effort": "", "summary": ""} | null, + "text": {"format": {}, "verbosity": "low" | "medium" | "high"} | null, + "truncation": "auto" | "disabled" | null, + "incomplete_details": {"reason": "max_output_tokens" | "max_tool_calls" | "content_filter", "details": null} | null, + "service_tier": "auto" | "default" | "flex" | "scale" | "priority" | null, + "prompt_cache_key": "" | null, + "prompt_cache_retention": "in-memory" | "24h" | null, + "store": true | false | null, + "background": true | false | null, + "completed_at": | null, + "error": {"code": "", "message": ""} | null +} +``` + +### 流式 Event 类型 + +| Event Type | Key Fields | +|------------|-----------| +| `response.created` | 完整的 response 对象(空 output) | +| `response.in_progress` | response 对象,status = "in_progress" | +| `response.output_item.added` | 新增 output item | +| `response.content_part.added` | 新增 content part | +| `response.output_text.delta` | 文本增量 | +| `response.output_text.done` | 文本完成 | +| `response.function_call_arguments.delta` | 函数参数增量 | +| `response.content_part.done` | content part 完成 | +| `response.output_item.done` | output item 完成 | +| `response.completed` | 完整的 response 对象(含 usage) | + +### 错误响应通用结构 + +```json +{ + "error": { + "code": "invalid_model" | "missing_input" | "server_error" | "rate_limit_exceeded" | "invalid_prompt" | "invalid_image" | "vector_store_timeout" | "content_filter_violation", + "message": "" + } +} +``` + +### 请求必需参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `model` | string | 模型标识符(如 `gpt-4o`、`o3`) | +| `input` | string/array | 文本、图片或文件输入 | + +### 请求可选参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `instructions` | string | - | 系统/开发者消息 | +| `max_output_tokens` | number | - | 最大生成 token 数 | +| `max_tool_calls` | number | - | 最大工具调用次数 | +| `temperature` | number | 1.0 | 采样温度(0-2) | +| `top_p` | number | 1.0 | 核采样阈值 | +| `tools` | array | - | 工具定义数组 | +| `tool_choice` | string/object | auto | 工具选择策略 | +| `parallel_tool_calls` | boolean | true | 允许并行工具调用 | +| `reasoning` | object | - | 推理配置(effort, summary) | +| `text` | object | - | 文本配置(format, verbosity) | +| `stream` | boolean | false | 是否流式 | +| `previous_response_id` | string | - | 上一轮响应 ID | +| `store` | boolean | false | 存储响应 | +| `background` | boolean | false | 后台执行 | +| `metadata` | object | - | 元数据(最多 16 个键值对) | +| `truncation` | string | - | 截断策略(auto/disabled) | +| `prompt_cache_key` | string | - | 提示词缓存键 | +| `prompt_cache_retention` | string | - | 缓存保留策略 | +| `service_tier` | string | - | 服务层级 | + +### Status 枚举 + +| 值 | 含义 | +|----|------| +| `completed` | 响应已完成 | +| `in_progress` | 响应生成中 | +| `failed` | 响应生成失败 | +| `incomplete` | 响应不完整(达到限制) | +| `cancelled` | 响应已取消 | +| `queued` | 响应已入队(后台模式) | diff --git a/openspec/changes/refactor-conversion-engine/.openspec.yaml b/openspec/changes/refactor-conversion-engine/.openspec.yaml deleted file mode 100644 index c8af3f5..0000000 --- a/openspec/changes/refactor-conversion-engine/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-04-19 diff --git a/openspec/changes/refactor-conversion-engine/design.md b/openspec/changes/refactor-conversion-engine/design.md deleted file mode 100644 index 8085e1e..0000000 --- a/openspec/changes/refactor-conversion-engine/design.md +++ /dev/null @@ -1,288 +0,0 @@ -## Context - -### 现有架构 - -当前后端协议转换以 OpenAI 类型为内部枢纽,整体结构: - -``` -Anthropic Handler ──▶ anthropic.ConvertRequest() ──▶ openai.ChatCompletionRequest - │ -OpenAI Handler ──────────────────────────────────▶ openai.ChatCompletionRequest - │ - ▼ - ProviderClient - (硬编码 OpenAI Adapter) - │ - ▼ - 上游 OpenAI 兼容 API -``` - -关键文件: -- `internal/protocol/openai/types.go` — OpenAI 线路格式类型,兼作内部枢纽格式 -- `internal/protocol/anthropic/converter.go` — Anthropic→OpenAI 单向转换 -- `internal/protocol/anthropic/stream_converter.go` — OpenAI chunk→Anthropic SSE 单向流式转换 -- `internal/handler/openai_handler.go` — OpenAI 请求处理 -- `internal/handler/anthropic_handler.go` — Anthropic 请求处理,内含协议转换编排 -- `internal/provider/client.go` — HTTP 客户端,硬编码 `openai.Adapter` 做序列化/反序列化 - -### 核心限制 - -1. **单向转换**:只有 Anthropic→OpenAI,无反向能力 -2. **OpenAI 绑定**:上游通信只能走 OpenAI 协议 -3. **无透传**:即使 client==provider(同协议),仍走完整序列化/反序列化 -4. **无扩展性**:新增协议需修改多处代码,无统一接入点 -5. **仅 Chat**:只支持 `/v1/chat/completions` 和 `/v1/messages` 两个固定端点,无 Models/Embeddings/Rerank - -### 设计参考 - -三份设计文档已完整定义目标架构和两个协议的适配细节: -- `docs/conversion_design.md` — 整体架构(Hub-and-Spoke、Canonical Model、ProtocolAdapter 接口、ConversionEngine、流式管道、错误处理) -- `docs/conversion_openai.md` — OpenAI 协议适配清单(字段映射、流式状态机、角色合并等) -- `docs/conversion_anthropic.md` — Anthropic 协议适配清单(角色约束、thinking、流式命名事件等) - -## Goals / Non-Goals - -**Goals:** - -- 实现完整的 Hub-and-Spoke 协议转换架构,以 Canonical Model 为枢纽 -- 支持任意协议对的请求/响应双向转换(当前:OpenAI ↔ Anthropic) -- 支持同协议透传(零语义损失、零序列化开销) -- 支持流式 SSE 双向转换(含 Tool Calling、Thinking) -- 支持 Chat 核心层 + Models/Embeddings/Rerank 扩展层 + 未知接口透传 -- ProviderClient 支持多协议上游通信 -- 统一代理入口,URL 路由支持协议前缀 - -**Non-Goals:** - -- 本阶段不实现多模态(Image/Audio/Video),Canonical Model 仅预留扩展点 -- 不实现 Middleware 的具体业务逻辑(仅定义接口和 Chain) -- 不实现新的协议 Adapter(除 OpenAI 和 Anthropic 外) -- 不实现有状态特性(架构预留 StatefulMiddleware 接口) -- 不实现前端管理界面的协议选择功能 -- 不修改前端代码(前端使用管理 API,代理 API 路由变更对前端透明) - -## Decisions - -### D1: Canonical Model 用独立 Go 结构体实现,不使用 `interface{}` 或 `map[string]any` - -**选择**:为 CanonicalRequest、CanonicalResponse、CanonicalStreamEvent 等定义强类型 Go struct,ContentBlock 使用 discriminated union 模式(type 字段 + 各类型嵌入) - -**理由**: -- 编译期类型安全,IDE 自动补全和重构友好 -- 性能优于 `map[string]any`(无反射开销) -- 与 Go 生态的习惯一致 - -**替代方案**: -- `map[string]any` — 灵活但无类型安全,重构时容易遗漏字段 -- 代码生成(如 protobuf)— 引入新依赖和构建步骤,过度工程化 - -**实现细节**: - -```go -type ContentBlock struct { - Type string `json:"type"` - // Text - Text string `json:"text,omitempty"` - // ToolUse - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input json.RawMessage `json:"input,omitempty"` - // ToolResult - ToolUseID string `json:"tool_use_id,omitempty"` - IsError *bool `json:"is_error,omitempty"` - // Thinking - Thinking string `json:"thinking,omitempty"` -} -``` - -使用 `json.RawMessage` 保留 Tool Input 的原始 JSON,避免不必要的 `map` 解析。 - -### D2: ProtocolAdapter 接口集中定义所有方法,不用接口组合 - -**选择**:一个大的 `ProtocolAdapter` 接口包含所有方法(Chat、流式、扩展层、错误编码),不拆分为小接口 - -**理由**: -- 对照 `docs/conversion_design.md` §5.2 的定义,接口集中便于明确所有应实现的内容 -- Adapter 实现者可一目了然看到所有方法 -- 不支持的功能直接返回 false(`supportsInterface`)或空实现 -- `detectInterfaceType` 由各协议 Adapter 实现,因为不同协议有不同的 URL 路径约定 - -**替代方案**: -- 接口组合(ChatAdapter + StreamAdapter + ExtendedAdapter)——增加类型复杂度,Adapter 注册和管理更繁琐 -- 用空接口 + 类型断言——丢失编译期检查 -- `detectInterfaceType` 放在 ConversionEngine 中——违反开闭原则,新增协议需要修改 Engine - -### D3: StreamDecoder 直接解析原始 SSE 字节流 - -**选择**:`ProviderClient.SendStream()` 返回 `<-chan []byte`(原始 SSE 字节流),`StreamDecoder.processChunk()` 负责拆分 SSE event 并解析 JSON - -**理由**: -- SSE 解析与协议语义紧密相关(不同协议的 SSE 格式不同:OpenAI 用 `data:` 无名事件,Anthropic 用命名事件 `event: xxx\ndata: xxx`) -- 减少中间层,降低内存拷贝 -- ProviderClient 保持最简——只做 HTTP 请求和字节流读取 - -**替代方案**: -- ProviderClient 内做 SSE 解析——强制所有上游使用同一 SSE 格式,不符合多协议目标 -- 独立 SSE Parser 层——增加不必要的抽象,SSE 格式本身就是 Adapter 职责的一部分 - -### D4: ProviderClient 接受 `HTTPRequestSpec`,返回 `*HTTPResponseSpec` - -**选择**:ConversionEngine 输出 `HTTPRequestSpec{URL, Method, Headers, Body []byte}`,ProviderClient 接收后发送 HTTP 请求;响应返回 `HTTPResponseSpec{StatusCode, Headers, Body []byte}` - -**理由**: -- ProviderClient 完全不感知协议,只做 HTTP 通信 -- ConversionEngine 统一负责 URL 构建、Header 构建、Body 序列化 -- 同协议透传时,Engine 直接透传 body bytes,Client 不做任何序列化/反序列化 - -**接口定义**: - -```go -type HTTPRequestSpec struct { - URL string - Method string - Headers map[string]string - Body []byte -} - -type HTTPResponseSpec struct { - StatusCode int - Headers map[string]string - Body []byte -} - -type ProviderClient interface { - Send(ctx context.Context, spec HTTPRequestSpec) (*HTTPResponseSpec, error) - SendStream(ctx context.Context, spec HTTPRequestSpec) (<-chan StreamEvent, error) -} -``` - -### D5: 统一代理入口使用 `/{protocol}/v1/...` URL 前缀 - -**选择**:新路由格式 `/{protocol}/v1/{path}`,handler 从 URL 提取 protocol 前缀作为 clientProtocol - -**理由**: -- 符合 `docs/conversion_design.md` §2.2 的设计 -- 调用方通过 URL 前缀明确指定协议,无需额外配置 -- 统一入口简化 handler 数量 - -**兼容路由**:不保留旧路由,客户端需迁移到新路由格式。 - -**替代方案**: -- 保持两个独立 handler——违背统一架构目标 -- 请求体嗅探协议——不可靠,且设计文档明确"协议识别是调用方职责" - -### D6: Provider 新增 `Protocol` 字段,存储在数据库 - -**选择**:Provider 表新增 `protocol TEXT DEFAULT 'openai'` 列,用于标识上游供应商使用的协议 - -**理由**: -- 上游供应商可能是 OpenAI 兼容(大多数)或 Anthropic 原生 -- 路由时需要知道 providerProtocol 以选择正确的 Adapter -- 默认值 `'openai'` 确保现有数据兼容 - -### D7: 删除旧 `internal/protocol/` 包,在 `internal/conversion/` 中全新实现 - -**选择**:直接删除 `internal/protocol/openai/` 和 `internal/protocol/anthropic/`,在 `internal/conversion/` 下对照设计文档全新编写所有代码 - -**理由**: -- 旧代码的设计模式(OpenAI 类型为枢纽)与新架构根本不同,无法复用 -- 保留旧代码容易导致混用两种模式,引入隐蔽 bug -- 旧代码中的类型定义不迁移,直接根据设计文档重新定义,确保与新架构一致 - -### D8: 目标包结构 - -``` -internal/conversion/ - canonical/ - types.go # CanonicalRequest/Response/Message/ContentBlock/Tool/ToolChoice/ThinkingConfig/OutputFormat - stream.go # CanonicalStreamEvent 联合体 + 所有事件类型 - extended.go # CanonicalModelList/ModelInfo/Embedding/Rerank - errors.go # ConversionError + ErrorCode 枚举 - interface.go # InterfaceType 枚举 - provider.go # TargetProvider struct - adapter.go # ProtocolAdapter 接口 + AdapterRegistry 接口和实现 - stream.go # StreamDecoder/StreamEncoder/StreamConverter 接口 + Passthrough/Canonical 实现 - middleware.go # ConversionMiddleware 接口 + MiddlewareChain - engine.go # ConversionEngine 门面 + HTTPRequestSpec/HTTPResponseSpec - - openai/ - types.go # OpenAI 线路格式类型(对照 conversion_openai.md 全新定义) - adapter.go # ProtocolAdapter 实现(detectInterfaceType/buildUrl/buildHeaders/supportsInterface/encodeError) - decoder.go # decodeRequest/decodeResponse/扩展层 decode 方法 - encoder.go # encodeRequest/encodeResponse/扩展层 encode 方法 - stream_decoder.go # OpenAIStreamDecoder(delta chunk 状态机) - stream_encoder.go # OpenAIStreamEncoder(缓冲策略) - - anthropic/ - types.go # Anthropic 线路格式类型(对照 conversion_anthropic.md 全新定义) - adapter.go # ProtocolAdapter 实现(detectInterfaceType/buildUrl/buildHeaders/supportsInterface/encodeError) - decoder.go # decodeRequest/decodeResponse/扩展层 decode 方法 - encoder.go # encodeRequest/encodeResponse/扩展层 encode 方法 - stream_decoder.go # AnthropicStreamDecoder(命名事件 1:1 映射) - stream_encoder.go # AnthropicStreamEncoder(直接映射,无缓冲) -``` - -## Risks / Trade-offs - -### R1: Anthropic 角色约束处理复杂度高 -**风险**:Anthropic 要求 user/assistant 严格交替、首消息必须为 user、tool_result 必须嵌入 user 消息。从 Canonical 编码为 Anthropic 时需要合并/拆分/注入消息,逻辑容易出错。 -**缓解**: -- 编写详尽的测试用例覆盖所有边界情况(连续 tool 消息、首条 assistant 消息、空 user 消息注入等) -- 将角色约束处理封装为独立函数,与内容编码逻辑分离 - -### R2: OpenAI 流式状态机复杂 -**风险**:OpenAI 的 delta chunk 没有显式生命周期(无 start/stop),StreamDecoder 需要状态机推断 block 边界,管理工具调用索引映射和参数累积。 -**缓解**: -- 严格对照 `docs/conversion_openai.md` §6.2-§6.3 的伪代码实现 -- 为每种 delta 类型编写独立测试(text、tool_calls、reasoning_content、refusal、usage chunk) -- UTF-8 跨 chunk 截断使用 `utf8Remainder` 缓冲 - -### R3: 全量重构影响范围大 -**风险**:同时删除旧代码、新建包、改造 handler/provider/domain,可能导致系统长时间不可用。 -**缓解**: -- 旧代码在删除前确认新代码所有测试通过 -- Git 分支隔离开发,完成后再合并 -- 新路由 `/{protocol}/v1/...` 确保协议明确指定 - -### R4: Canonical Model 字段演进 -**风险**:Canonical Model 的字段集反映当前已适配协议的公共语义,未来新增协议时可能需要频繁修改。 -**缓解**: -- 字段晋升规范已在 `docs/conversion_design.md` 附录 C 中定义 -- `json:"-"` 标签控制序列化输出,新增可选字段不影响已有编解码 -- 协议特有字段不纳入 Canonical,通过同协议透传保留 - -### R5: 性能——双重序列化开销 -**风险**:跨协议转换时经过 decode→canonical→encode 两次序列化/反序列化,相比直接转换多一次拷贝。 -**权衡**:接受此开销以换取架构清晰和可扩展性。同协议透传路径零开销补偿。实际 LLM API 延迟(数百毫秒到数秒)远大于 JSON 序列化开销(微秒级)。 - -## Migration Plan - -### 步骤 - -1. **创建 `internal/conversion/` 包**:实现 Layer 1-3(Canonical Model、接口定义、Engine),不改动现有代码 -2. **全新实现 OpenAI Adapter 和 Anthropic Adapter**:Layer 4-5,对照设计文档在 conversion 包内全新编写,不沿用旧 protocol 包代码 -3. **编写全面测试**:覆盖编解码、流式转换、错误处理、同协议透传 -4. **改造 `domain.Provider`**:新增 `Protocol` 字段 -5. **创建数据库迁移**:`ALTER TABLE providers ADD COLUMN protocol TEXT DEFAULT 'openai'` -6. **改造 `ProviderClient`**:简化为接受 `HTTPRequestSpec` 的 HTTP 发送器 -7. **创建 `ProxyHandler`**:统一代理入口,集成 ConversionEngine -8. **更新 `cmd/server/main.go`**:注册 Adapter、创建 Engine、配置新路由 -9. **删除旧 `internal/protocol/` 包**:直接删除,不迁移代码,确认新架构完全替代 -10. **更新 README.md**:项目结构、API 接口、路由说明 - -### 兼容策略 - -- 旧路由 `/v1/chat/completions` 和 `/v1/messages` 不再保留,客户端需迁移 -- 现有 Provider 数据通过 `DEFAULT 'openai'` 自动获得协议标识 -- 前端管理 API 不受影响 - -### 回滚策略 - -- Git 分支隔离:在新分支开发,合并前充分测试 -- 旧 `internal/protocol/` 包在删除前确认新架构所有测试通过,删除后不可恢复旧代码(从 git 历史仍可找回) -- 数据库迁移向下兼容(仅 ADD COLUMN) - -## Open Questions - -- ~~是否需要为兼容路由 `/v1/chat/completions` 和 `/v1/messages` 设置 deprecation 期限?~~ → **决定**:不保留旧路由,客户端直接迁移到 `/{protocol}/v1/...` -- ~~扩展层接口(Models/Embeddings/Rerank)在本阶段是否全部实现,还是先做 Models,其余后续迭代?~~ → **决定**:本阶段全部实现(对照三份文档的字段映射已在 spec 中完整定义),因为扩展层接口编解码逻辑量不大(轻量字段映射),且实现后能完整验证引擎的接口分层分发逻辑 diff --git a/openspec/changes/refactor-conversion-engine/proposal.md b/openspec/changes/refactor-conversion-engine/proposal.md deleted file mode 100644 index d769625..0000000 --- a/openspec/changes/refactor-conversion-engine/proposal.md +++ /dev/null @@ -1,45 +0,0 @@ -## Why - -当前后端协议转换层以 OpenAI 类型作为内部枢纽,Anthropic 请求单向转换为 OpenAI 格式后再发往上游。这种设计导致:无法支持 OpenAI→Anthropic 的反向转换、无法对接 Anthropic 协议的上游供应商、无法实现同协议透传的零开销转发、无法横向扩展新协议。重构为基于协议中立 Canonical Model 的 Hub-and-Spoke 架构(参考 `docs/conversion_design.md`),从根本上解决这些问题。 - -## What Changes - -- **引入 Canonical Model**:定义协议无关的 `CanonicalRequest`、`CanonicalResponse`、`CanonicalStreamEvent` 等规范模型,作为所有协议间转换的统一枢纽 -- **引入 ConversionEngine**:无状态的转换引擎门面,协调 Adapter 注册、接口识别、透传判断、请求/响应转换、流式转换 -- **引入 ProtocolAdapter 接口**:统一适配器契约,每种协议实现完整的编解码(Chat 请求/响应、流式、扩展层接口、错误编码) -- **实现 OpenAI Adapter**:对照 `docs/conversion_openai.md` 全新实现 OpenAI 协议的完整 Adapter(含状态机流式解码器/编码器),不沿用旧 `internal/protocol/openai/` 代码 -- **实现 Anthropic Adapter**:对照 `docs/conversion_anthropic.md` 全新实现 Anthropic 协议的完整 Adapter(含命名事件流式解码器/编码器),不沿用旧 `internal/protocol/anthropic/` 代码 -- **统一代理 Handler**:合并 `OpenAIHandler` 和 `AnthropicHandler` 为统一的 `ProxyHandler`,支持 `/{protocol}/v1/...` URL 前缀路由 -- **同协议透传**:client == provider 时跳过 Canonical 转换,仅重建 Header 后原样转发 -- **接口分层**:核心层(Chat)走 Canonical 深度转换,扩展层(Models/Embeddings/Rerank)走轻量映射,未知接口走透传 -- **ProviderClient 简化**:移除 OpenAI Adapter 硬编码,变为协议无关的 HTTP 发送器 -- **Provider 新增 Protocol 字段**:**BREAKING** — Provider 模型新增 `protocol` 字段标识上游协议类型 -- **删除旧 protocol 包**:移除 `internal/protocol/openai/` 和 `internal/protocol/anthropic/`,在 `internal/conversion/` 中全新实现 -- **URL 路由变更**:**BREAKING** — 代理端点从 `/v1/chat/completions` + `/v1/messages` 变更为 `/{protocol}/v1/...`,不保留旧路由 - -## Capabilities - -### New Capabilities - -- `conversion-engine`: 协议转换引擎核心能力——Canonical Model 定义、ProtocolAdapter 接口与注册表、ConversionEngine 门面(请求/响应转换、流式转换、接口识别、透传判断)、StreamDecoder/Encoder 接口、Middleware 拦截链、ConversionError 错误体系 -- `protocol-adapter-openai`: OpenAI 协议适配器——完整的 ProtocolAdapter 实现(对照 conversion_openai.md),涵盖 Chat 请求/响应编解码、流式状态机解码器(OpenAI delta chunk → CanonicalStreamEvent)和编码器(反向)、扩展层接口编解码(Models/Embeddings/Rerank)、错误编码、同协议透传 -- `protocol-adapter-anthropic`: Anthropic 协议适配器——完整的 ProtocolAdapter 实现(对照 conversion_anthropic.md),涵盖 Chat 请求/响应编解码(含角色约束处理:tool→user 合并、user/assistant 交替保证)、流式解码器(命名 SSE 事件 → CanonicalStreamEvent)和编码器(反向)、扩展层接口编解码(Models)、错误编码、同协议透传 -- `unified-proxy-handler`: 统一代理入口——合并 OpenAI/Anthropic 双 Handler 为统一 ProxyHandler,支持 `/{protocol}/v1/...` URL 前缀路由、协议识别 - -### Modified Capabilities - -- `openai-protocol-proxy`: URL 路由从硬编码 `/v1/chat/completions` 变更为 `/{protocol}/v1/...` 统一入口;请求处理从直接调用 ProviderClient 变更为经 ConversionEngine 转换;新增同协议透传能力;新增扩展层接口(Models/Embeddings/Rerank)代理 -- `anthropic-protocol-proxy`: 从单向 Anthropic→OpenAI 转换变更为双向任意协议互转;从 Handler 内直接调用 converter 变更为经 ConversionEngine;新增 Anthropic 作为上游供应商的能力;新增同协议透传能力;新增扩展层接口代理 -- `provider-management`: Provider 模型新增 `protocol` 字段(标识上游协议类型,默认 "openai");数据库迁移新增 protocol 列 -- `layered-architecture`: 新增 conversion 层(internal/conversion/)位于 handler 和 provider 之间;ProviderClient 接口简化为协议无关的 HTTP 发送器 -- `error-handling`: 新增 ConversionError 错误类型和 ErrorCode 枚举;转换失败时使用客户端协议格式编码错误响应 -- `request-validation`: 请求验证从 handler 层前移到 ProtocolAdapter 的 decodeRequest 中;验证规则按各协议规范独立定义 - -## Impact - -- **代码结构**:新增 `internal/conversion/` 包(约 20+ 文件,全新编写),删除 `internal/protocol/` 包(不迁移,直接删除后重写),改造 `internal/handler/` 和 `internal/provider/` -- **API 兼容性**:**BREAKING** — 代理端点 URL 变更(`/v1/chat/completions` → `/openai/v1/chat/completions`,`/v1/messages` → `/anthropic/v1/messages`),不保留旧路由 -- **数据库**:Provider 表新增 `protocol` 列,需数据库迁移 -- **依赖**:无新增外部依赖,复用现有 Go 标准库和已引入的包 -- **测试**:需为 conversion 包编写全面单元测试,覆盖每个 Adapter 的编解码、流式转换、错误处理、同协议透传 -- **文档**:需更新 README.md 中的项目结构、API 接口说明 diff --git a/openspec/changes/refactor-conversion-engine/specs/anthropic-protocol-proxy/spec.md b/openspec/changes/refactor-conversion-engine/specs/anthropic-protocol-proxy/spec.md deleted file mode 100644 index 601eae7..0000000 --- a/openspec/changes/refactor-conversion-engine/specs/anthropic-protocol-proxy/spec.md +++ /dev/null @@ -1,83 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 支持 Anthropic Messages API 端点 - -网关 SHALL 提供 Anthropic Messages API 端点供外部应用调用。 - -#### Scenario: 成功的非流式请求 - -- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带有效的 Anthropic 请求格式(非流式) -- **THEN** 网关 SHALL 通过 ConversionEngine 将 Anthropic 请求解码为 Canonical 格式 -- **THEN** 网关 SHALL 将 Canonical 请求编码为目标供应商协议格式 -- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 Anthropic 格式返回给应用 - -#### Scenario: 成功的流式请求 - -- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带 `stream: true` -- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter -- **THEN** 网关 SHALL 将上游协议的 SSE 流转换为 Anthropic 命名事件格式 -- **THEN** 网关 SHALL 使用 `event: \ndata: \n\n` 格式流式返回给应用 - -#### Scenario: 同协议透传(Anthropic → Anthropic Provider) - -- **WHEN** 客户端使用 Anthropic 协议且目标供应商也是 Anthropic 协议 -- **THEN** 网关 SHALL 跳过 Canonical 转换,仅重建认证 Header 后原样转发 -- **THEN** 请求和响应 Body SHALL 保持原样 - -### Requirement: 双向协议转换 - -网关 SHALL 支持 Anthropic 协议与任意已注册协议间的双向转换。 - -#### Scenario: Anthropic 客户端 → OpenAI 供应商 - -- **WHEN** 客户端使用 Anthropic 协议且供应商使用 OpenAI 协议 -- **THEN** SHALL 将 Anthropic MessagesRequest 解码为 CanonicalRequest -- **THEN** SHALL 将 CanonicalRequest 编码为 OpenAI ChatCompletionRequest -- **THEN** SHALL 将 OpenAI ChatCompletionResponse 解码为 CanonicalResponse -- **THEN** SHALL 将 CanonicalResponse 编码为 Anthropic MessagesResponse - -#### Scenario: OpenAI 客户端 → Anthropic 供应商 - -- **WHEN** 客户端使用 OpenAI 协议且供应商使用 Anthropic 协议 -- **THEN** SHALL 将 OpenAI ChatCompletionRequest 解码为 CanonicalRequest -- **THEN** SHALL 将 CanonicalRequest 编码为 Anthropic MessagesRequest -- **THEN** SHALL 将 Anthropic MessagesResponse 解码为 CanonicalResponse -- **THEN** SHALL 将 CanonicalResponse 编码为 OpenAI ChatCompletionResponse - -### Requirement: 使用 service 层处理请求 - -Handler SHALL 通过 service 层处理业务逻辑。 - -#### Scenario: 调用 routing service - -- **WHEN** ProxyHandler 收到 Anthropic 协议请求 -- **THEN** SHALL 调用 RoutingService.Route() 获取路由结果 -- **THEN** SHALL 从路由结果获取 Provider(含 protocol 字段) - -#### Scenario: 调用 stats service - -- **WHEN** 请求成功完成 -- **THEN** SHALL 调用 StatsService.Record() 记录统计 -- **THEN** SHALL 异步记录统计(不阻塞响应) - -### Requirement: 使用结构化错误处理 - -ProxyHandler SHALL 使用 ConversionError 和 Anthropic 的 encodeError 处理错误。 - -#### Scenario: 协议转换错误 - -- **WHEN** ConversionEngine 返回 ConversionError -- **THEN** SHALL 使用 Anthropic 的 Adapter.encodeError 编码错误响应 -- **THEN** SHALL 使用 Anthropic 错误格式(`{type: "error", error: {type, message}}`) - -#### Scenario: 路由错误处理 - -- **WHEN** RoutingService 返回错误 -- **THEN** SHALL 转换为 ConversionError -- **THEN** SHALL 使用 Anthropic 错误格式返回 - -#### Scenario: 供应商错误处理 - -- **WHEN** ProviderClient 返回错误 -- **THEN** SHALL 包装为 ConversionError -- **THEN** SHALL 使用 Anthropic 错误格式返回 diff --git a/openspec/changes/refactor-conversion-engine/specs/error-handling/spec.md b/openspec/changes/refactor-conversion-engine/specs/error-handling/spec.md deleted file mode 100644 index cccc44b..0000000 --- a/openspec/changes/refactor-conversion-engine/specs/error-handling/spec.md +++ /dev/null @@ -1,53 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 统一错误响应 - -系统 SHALL 统一错误响应格式,新增 ConversionError 支持。 - -#### Scenario: OpenAI 协议错误响应 - -- **WHEN** OpenAI 协议发生错误 -- **THEN** SHALL 返回标准 OpenAI 错误响应格式 -- **THEN** SHALL 包含 error.message、error.type、error.code 字段 - -#### Scenario: Anthropic 协议错误响应 - -- **WHEN** Anthropic 协议发生错误 -- **THEN** SHALL 返回标准 Anthropic 错误响应格式 -- **THEN** SHALL 包含 type、error.type、error.message 字段 - -#### Scenario: 转换错误响应 - -- **WHEN** ConversionEngine 在协议转换过程中产生 ConversionError -- **THEN** SHALL 使用客户端协议的 Adapter.encodeError 编码错误响应 -- **THEN** 错误响应 SHALL 使用客户端可理解的协议格式 - -#### Scenario: 管理 API 错误响应 - -- **WHEN** 管理 API 发生错误 -- **THEN** SHALL 返回统一的错误响应格式 -- **THEN** SHALL 包含 code、message 字段 -- **THEN** SHALL 可选包含 details 字段(验证错误详情) - -## ADDED Requirements - -### Requirement: 定义 ConversionError 错误类型 - -系统 SHALL 定义 ConversionError 结构体和 ErrorCode 枚举。 - -#### Scenario: ConversionError 结构 - -- **WHEN** 定义转换错误 -- **THEN** SHALL 包含 Code(ErrorCode 枚举)、Message 字段 -- **THEN** SHALL 可选包含 ClientProtocol、ProviderProtocol、InterfaceType、Details、Cause 字段 - -#### Scenario: ErrorCode 枚举 - -- **WHEN** 定义错误码 -- **THEN** SHALL 包含 INVALID_INPUT、MISSING_REQUIRED_FIELD、INCOMPATIBLE_FEATURE、FIELD_MAPPING_FAILURE、TOOL_CALL_PARSE_ERROR、JSON_PARSE_ERROR、STREAM_STATE_ERROR、UTF8_DECODE_ERROR、PROTOCOL_CONSTRAINT_VIOLATION、ENCODING_FAILURE、INTERFACE_NOT_SUPPORTED - -#### Scenario: 错误码到协议错误类型的映射 - -- **WHEN** 使用 encodeError 编码错误 -- **THEN** ErrorCode SHALL 映射为各协议的错误类型字符串 -- **THEN** 例如 INVALID_INPUT → OpenAI "invalid_request_error",Anthropic "invalid_request_error" diff --git a/openspec/changes/refactor-conversion-engine/specs/layered-architecture/spec.md b/openspec/changes/refactor-conversion-engine/specs/layered-architecture/spec.md deleted file mode 100644 index 3812fc8..0000000 --- a/openspec/changes/refactor-conversion-engine/specs/layered-architecture/spec.md +++ /dev/null @@ -1,118 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 实现三层架构 - -系统 SHALL 实现 handler → service → repository 三层架构,并在 handler 和 provider 之间新增 conversion 层。 - -#### Scenario: Handler 层职责 - -- **WHEN** 处理 HTTP 请求 -- **THEN** handler 层 SHALL 仅负责 HTTP 请求解析、URL 路由和响应写入 -- **THEN** handler 层 SHALL 调用 ConversionEngine 处理协议转换 -- **THEN** handler 层 SHALL 调用 service 层处理业务逻辑 -- **THEN** handler 层 SHALL NOT 直接访问数据库或执行协议转换逻辑 - -#### Scenario: Conversion 层职责 - -- **WHEN** 处理协议转换 -- **THEN** conversion 层 SHALL 包含 Canonical Model 定义 -- **THEN** conversion 层 SHALL 包含各协议的 ProtocolAdapter 实现 -- **THEN** conversion 层 SHALL 包含 ConversionEngine 门面 -- **THEN** conversion 层 SHALL NOT 依赖 handler 或 service 层 - -#### Scenario: Service 层职责 - -- **WHEN** 处理业务逻辑 -- **THEN** service 层 SHALL 包含业务规则和验证 -- **THEN** service 层 SHALL 调用 repository 层访问数据 -- **THEN** service 层 SHALL NOT 包含协议转换逻辑 - -#### Scenario: Repository 层职责 - -- **WHEN** 访问数据 -- **THEN** repository 层 SHALL 仅负责数据访问 -- **THEN** repository 层 SHALL 封装数据库操作 -- **THEN** repository 层 SHALL NOT 包含业务逻辑或协议转换逻辑 - -### Requirement: 定义核心接口 - -系统 SHALL 定义清晰的接口边界。 - -#### Scenario: Service 接口定义 - -- **WHEN** 定义 service 接口 -- **THEN** SHALL 定义 ProviderService、ModelService、RoutingService、StatsService 接口 -- **THEN** SHALL 定义清晰的业务方法签名 -- **THEN** SHALL 使用 domain 类型作为参数和返回值 - -#### Scenario: Repository 接口定义 - -- **WHEN** 定义 repository 接口 -- **THEN** SHALL 定义 ProviderRepository、ModelRepository、StatsRepository 接口 -- **THEN** SHALL 定义清晰的数据访问方法签名 -- **THEN** SHALL 使用 domain 类型作为参数和返回值 - -#### Scenario: Provider Client 接口定义 - -- **WHEN** 定义 provider client 接口 -- **THEN** SHALL 定义 ProviderClient 接口 -- **THEN** SHALL 包含 Send(非流式)和 SendStream(流式)方法 -- **THEN** SHALL 接受 HTTPRequestSpec 作为参数,不绑定特定协议 -- **THEN** SHALL 支持接口 Mock - -#### Scenario: Conversion 层接口定义 - -- **WHEN** 定义 conversion 层接口 -- **THEN** SHALL 定义 ProtocolAdapter、StreamDecoder、StreamEncoder、StreamConverter、ConversionMiddleware 接口 -- **THEN** SHALL 定义 AdapterRegistry 用于 Adapter 注册和查询 -- **THEN** SHALL 定义 ConversionEngine 作为统一门面 - -### Requirement: 实现依赖注入 - -系统 SHALL 使用手动依赖注入。 - -#### Scenario: Repository 注入 - -- **WHEN** 初始化 service -- **THEN** SHALL 通过构造函数注入 repository 依赖 -- **THEN** SHALL 使用接口类型而非具体类型 - -#### Scenario: Service 注入 - -- **WHEN** 初始化 handler -- **THEN** SHALL 通过构造函数注入 service 依赖、ConversionEngine、ProviderClient -- **THEN** SHALL 使用接口类型而非具体类型 - -#### Scenario: Conversion 组装 - -- **WHEN** 应用启动 -- **THEN** SHALL 创建 AdapterRegistry 并注册所有 ProtocolAdapter -- **THEN** SHALL 创建 ConversionEngine(注入 registry 和 middleware chain) -- **THEN** SHALL 将 ConversionEngine 注入到 ProxyHandler - -#### Scenario: 主函数组装 - -- **WHEN** 应用启动 -- **THEN** main.go SHALL 按顺序构造所有依赖 -- **THEN** SHALL 先构造基础设施(logger、database) -- **THEN** SHALL 再构造 repository、service -- **THEN** SHALL 再构造 conversion 层(registry → engine) -- **THEN** SHALL 最后构造 handler - -### Requirement: 定义 Domain 模型 - -系统 SHALL 定义独立的 domain 模型。 - -#### Scenario: Domain 模型定义 - -- **WHEN** 定义领域模型 -- **THEN** SHALL 在 internal/domain/ 包中定义 -- **THEN** SHALL 包含 Provider、Model、UsageStats 等模型 -- **THEN** Provider SHALL 包含 Protocol 字段 -- **THEN** SHALL 与数据库模型分离 - -#### Scenario: Domain 模型使用 - -- **WHEN** service 和 repository 处理数据 -- **THEN** SHALL 使用 domain 模型 -- **THEN** SHALL NOT 使用数据库模型(GORM 模型) diff --git a/openspec/changes/refactor-conversion-engine/specs/openai-protocol-proxy/spec.md b/openspec/changes/refactor-conversion-engine/specs/openai-protocol-proxy/spec.md deleted file mode 100644 index aaab771..0000000 --- a/openspec/changes/refactor-conversion-engine/specs/openai-protocol-proxy/spec.md +++ /dev/null @@ -1,99 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 支持 OpenAI Chat Completions API 端点 - -网关 SHALL 提供 OpenAI Chat Completions API 端点供外部应用调用。 - -#### Scenario: 成功的非流式请求 - -- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带有效的 OpenAI 请求格式(非流式) -- **THEN** 网关 SHALL 通过 ConversionEngine 转换请求 -- **THEN** 网关 SHALL 将转换后的请求转发到配置的供应商 -- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 OpenAI 格式返回给应用 - -#### Scenario: 成功的流式请求 - -- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带 `stream: true` -- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter -- **THEN** 网关 SHALL 使用 SSE 格式将转换后的响应流式返回给应用 -- **THEN** 网关 SHALL 在流完成时发送 `data: [DONE]` - -#### Scenario: 同协议透传(OpenAI → OpenAI Provider) - -- **WHEN** 客户端使用 OpenAI 协议且目标供应商也是 OpenAI 协议 -- **THEN** 网关 SHALL 跳过 Canonical 转换,仅重建认证 Header 后原样转发 -- **THEN** 请求和响应 Body SHALL 保持原样 - -### Requirement: 根据模型名称路由请求 - -网关 SHALL 根据请求中的 `model` 字段将请求路由到相应的供应商。 - -#### Scenario: 有效模型路由 - -- **WHEN** 请求包含存在于配置模型中的 `model` 字段 -- **AND** 该模型已启用 -- **THEN** 网关 SHALL 将请求路由到该模型关联的供应商 -- **THEN** 网关 SHALL 从供应商的 `protocol` 字段获取 providerProtocol - -#### Scenario: 模型未找到 - -- **WHEN** 请求包含不存在于配置模型中的 `model` 字段 -- **THEN** 网关 SHALL 使用 OpenAI 格式返回错误响应 - -#### Scenario: 模型已禁用 - -- **WHEN** 请求包含已禁用模型的 `model` 字段 -- **THEN** 网关 SHALL 使用 OpenAI 格式返回错误响应 - -### Requirement: 对 OpenAI 兼容供应商透明代理 - -网关 SHALL 对 OpenAI 兼容供应商的请求和响应通过 ConversionEngine 进行转换处理。 - -#### Scenario: 跨协议请求转发 - -- **WHEN** 网关收到 OpenAI 协议请求且目标供应商使用不同协议 -- **THEN** 网关 SHALL 通过 ConversionEngine 将请求转换为目标协议格式 -- **THEN** 网关 SHALL 使用目标协议的 Adapter 构建 URL 和 Header - -#### Scenario: 扩展层接口代理 - -- **WHEN** 网关收到 `/openai/v1/models` 等 GET 请求 -- **THEN** 网关 SHALL 通过 ConversionEngine 转换扩展层接口的响应格式 - -### Requirement: 使用 service 层处理请求 - -Handler SHALL 通过 service 层处理业务逻辑。 - -#### Scenario: 调用 routing service - -- **WHEN** ProxyHandler 收到请求 -- **THEN** SHALL 调用 RoutingService.Route() 获取路由结果 -- **THEN** SHALL 从路由结果获取 Provider(含 protocol 字段) - -#### Scenario: 调用 stats service - -- **WHEN** 请求成功完成 -- **THEN** SHALL 调用 StatsService.Record() 记录统计 -- **THEN** SHALL 异步记录统计(不阻塞响应) - -### Requirement: 使用结构化错误处理 - -ProxyHandler SHALL 使用 ConversionError 和协议对应的 encodeError 处理错误。 - -#### Scenario: 转换错误 - -- **WHEN** ConversionEngine 返回 ConversionError -- **THEN** SHALL 使用 clientProtocol 的 Adapter.encodeError 编码错误响应 -- **THEN** SHALL 使用 OpenAI 错误格式(`{error: {message, type, code}}`) - -#### Scenario: 路由错误处理 - -- **WHEN** RoutingService 返回错误 -- **THEN** SHALL 转换为 ConversionError -- **THEN** SHALL 使用 OpenAI 错误格式返回 - -#### Scenario: 供应商错误处理 - -- **WHEN** ProviderClient 返回错误 -- **THEN** SHALL 包装为 ConversionError -- **THEN** SHALL 使用 OpenAI 错误格式返回 diff --git a/openspec/changes/refactor-conversion-engine/specs/provider-management/spec.md b/openspec/changes/refactor-conversion-engine/specs/provider-management/spec.md deleted file mode 100644 index 9788ba7..0000000 --- a/openspec/changes/refactor-conversion-engine/specs/provider-management/spec.md +++ /dev/null @@ -1,73 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 创建供应商配置 - -网关 SHALL 允许通过管理 API 创建新的供应商配置。 - -#### Scenario: 使用有效数据创建供应商 - -- **WHEN** 向 `/api/providers` 发送 POST 请求,携带有效的供应商数据(id, name, api_key, base_url, protocol) -- **THEN** 网关 SHALL 在数据库中创建新的供应商记录 -- **THEN** 网关 SHALL 返回创建的供应商,状态码为 201 -- **THEN** 供应商 SHALL 默认启用 -- **THEN** protocol 字段 SHALL 默认为 "openai" - -#### Scenario: 使用重复 ID 创建供应商 - -- **WHEN** 向 `/api/providers` 发送 POST 请求,携带已存在的 ID -- **THEN** 网关 SHALL 返回错误,状态码为 409 (Conflict) - -#### Scenario: 创建供应商时缺少必需字段 - -- **WHEN** 向 `/api/providers` 发送 POST 请求,缺少必需字段(id, name, api_key 或 base_url) -- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request) -- **THEN** 错误 SHALL 指示缺少哪些字段 - -### Requirement: 列出所有供应商 - -网关 SHALL 允许获取所有供应商配置。 - -#### Scenario: 成功列出供应商 - -- **WHEN** 向 `/api/providers` 发送 GET 请求 -- **THEN** 网关 SHALL 返回所有供应商的列表 -- **THEN** 每个供应商 SHALL 包含 id, name, api_key(已掩码), base_url, protocol, enabled, created_at, updated_at -- **THEN** api_key SHALL 被掩码(仅显示最后 4 个字符) - -### Requirement: 获取特定供应商 - -网关 SHALL 允许通过 ID 获取特定供应商。 - -#### Scenario: 获取存在的供应商 - -- **WHEN** 向 `/api/providers/:id` 发送 GET 请求,携带有效的供应商 ID -- **THEN** 网关 SHALL 返回供应商详情 -- **THEN** SHALL 包含 protocol 字段 -- **THEN** api_key SHALL 被掩码 - -#### Scenario: 获取不存在的供应商 - -- **WHEN** 向 `/api/providers/:id` 发送 GET 请求,携带不存在的 ID -- **THEN** 网关 SHALL 返回错误,状态码为 404 (Not Found) - -### Requirement: 更新供应商配置 - -网关 SHALL 允许更新现有供应商配置。 - -#### Scenario: 使用有效数据更新供应商 - -- **WHEN** 向 `/api/providers/:id` 发送 PUT 请求,携带有效的供应商数据 -- **THEN** 网关 SHALL 更新数据库中的供应商记录 -- **THEN** 网关 SHALL 返回更新后的供应商 -- **THEN** 更新 SHALL 支持修改 protocol 字段 - -### Requirement: 删除供应商配置 - -网关 SHALL 允许删除供应商配置。 - -#### Scenario: 删除存在的供应商 - -- **WHEN** 向 `/api/providers/:id` 发送 DELETE 请求,携带有效的供应商 ID -- **THEN** 网关 SHALL 删除供应商记录 -- **THEN** 网关 SHALL 删除所有关联的模型(CASCADE) -- **THEN** 网关 SHALL 返回状态码 204 (No Content) diff --git a/openspec/changes/refactor-conversion-engine/specs/request-validation/spec.md b/openspec/changes/refactor-conversion-engine/specs/request-validation/spec.md deleted file mode 100644 index 42c7fee..0000000 --- a/openspec/changes/refactor-conversion-engine/specs/request-validation/spec.md +++ /dev/null @@ -1,64 +0,0 @@ -## MODIFIED Requirements - -### Requirement: 验证 OpenAI 请求 - -系统 SHALL 验证 OpenAI ChatCompletionRequest,验证逻辑位于 ProtocolAdapter 的 decodeRequest 内。 - -#### Scenario: 必需字段验证 - -- **WHEN** OpenAI Adapter 的 decodeRequest 解析请求 -- **THEN** SHALL 验证 model 字段不为空 -- **THEN** SHALL 验证 messages 字段不为空且至少有一条消息 -- **THEN** 验证失败 SHALL 返回 INVALID_INPUT 类型的 ConversionError - -#### Scenario: 参数范围验证 - -- **WHEN** OpenAI Adapter 的 decodeRequest 解析参数 -- **THEN** SHALL 验证 temperature 范围在 [0, 2] -- **THEN** SHALL 验证 max_tokens 大于 0 -- **THEN** SHALL 验证 top_p 范围在 (0, 1] - -#### Scenario: 消息内容验证 - -- **WHEN** 验证 messages 字段 -- **THEN** SHALL 验证每条消息的 role 有效(system、developer、user、assistant、tool) -- **THEN** SHALL 验证 content 不为空 - -### Requirement: 验证 Anthropic 请求 - -系统 SHALL 验证 Anthropic MessagesRequest,验证逻辑位于 ProtocolAdapter 的 decodeRequest 内。 - -#### Scenario: 必需字段验证 - -- **WHEN** Anthropic Adapter 的 decodeRequest 解析请求 -- **THEN** SHALL 验证 model 字段不为空 -- **THEN** SHALL 验证 messages 字段不为空且至少有一条消息 -- **THEN** SHALL 验证 max_tokens 大于 0(或使用默认值) - -#### Scenario: 参数范围验证 - -- **WHEN** Anthropic Adapter 的 decodeRequest 解析参数 -- **THEN** SHALL 验证 temperature 范围在 [0, 1] -- **THEN** SHALL 验证 top_p 范围在 (0, 1] - -#### Scenario: 消息内容验证 - -- **WHEN** 验证 messages 字段 -- **THEN** SHALL 验证每条消息的 role 有效(user、assistant) -- **THEN** SHALL 验证 content 数组不为空 - -### Requirement: 返回友好的验证错误 - -系统 SHALL 返回友好的验证错误响应。 - -#### Scenario: 转换错误格式 - -- **WHEN** decodeRequest 验证失败返回 ConversionError -- **THEN** ProxyHandler SHALL 使用 clientAdapter.encodeError 编码错误响应 -- **THEN** 错误 SHALL 使用客户端协议的格式 - -#### Scenario: 多字段错误 - -- **WHEN** 多个字段验证失败 -- **THEN** ConversionError.details SHALL 包含所有验证错误 -- **THEN** 错误响应 SHALL 包含完整的验证错误信息 diff --git a/openspec/changes/refactor-conversion-engine/tasks.md b/openspec/changes/refactor-conversion-engine/tasks.md deleted file mode 100644 index 60fdf12..0000000 --- a/openspec/changes/refactor-conversion-engine/tasks.md +++ /dev/null @@ -1,49 +0,0 @@ -## 1. 基础类型层 — Canonical Model 和核心类型定义 - -- [x] 1.1 创建 `internal/conversion/errors.go`:定义 ConversionError 结构体(Code, Message, ClientProtocol, ProviderProtocol, InterfaceType, Details, Cause)和 ErrorCode 枚举(INVALID_INPUT, MISSING_REQUIRED_FIELD, INCOMPATIBLE_FEATURE, FIELD_MAPPING_FAILURE, TOOL_CALL_PARSE_ERROR, JSON_PARSE_ERROR, STREAM_STATE_ERROR, UTF8_DECODE_ERROR, PROTOCOL_CONSTRAINT_VIOLATION, ENCODING_FAILURE, INTERFACE_NOT_SUPPORTED),实现 error 接口 -- [x] 1.2 创建 `internal/conversion/interface.go`:定义 InterfaceType 枚举(CHAT, MODELS, MODEL_INFO, EMBEDDINGS, RERANK) -- [x] 1.3 创建 `internal/conversion/provider.go`:定义 TargetProvider 结构体(BaseURL, APIKey, ModelName, AdapterConfig map[string]any);编写测试 -- [x] 1.4 创建 `internal/conversion/canonical/types.go`:定义 CanonicalRequest(model, system, messages, tools, tool_choice, parameters, thinking, stream, user_id, output_format, parallel_tool_use)、CanonicalMessage(role 枚举: system/user/assistant/tool, content []ContentBlock)、ContentBlock(使用 type 字段的 discriminated union:text/tool_use/tool_result/thinking,ToolInput 使用 json.RawMessage)、CanonicalTool(name, description, input_schema)、ToolChoice 联合体(auto/none/any/tool+name)、RequestParameters(max_tokens, temperature, top_p, top_k, frequency_penalty, presence_penalty, stop_sequences)、ThinkingConfig(type: enabled/disabled/adaptive, budget_tokens, effort)、OutputFormat(json_object/json_schema+schema/text)、CanonicalResponse(id, model, content, stop_reason 枚举, usage)、CanonicalUsage(input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, reasoning_tokens)、SystemBlock(text);编写构造和序列化测试 -- [x] 1.5 创建 `internal/conversion/canonical/stream.go`:定义 CanonicalStreamEvent 联合体(message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop, error, ping)及各事件的具体结构(MessageStartEvent 含 message{id,model,usage}、ContentBlockStartEvent 含 index 和 content_block、ContentBlockDeltaEvent 含 index 和 delta、ContentBlockStopEvent 含 index、MessageDeltaEvent 含 delta{stop_reason} 和 usage、MessageStopEvent、ErrorEvent、PingEvent),delta 联合体(text_delta, input_json_delta, thinking_delta),content_block 联合体(text, tool_use, thinking);编写测试 -- [x] 1.6 创建 `internal/conversion/canonical/extended.go`:定义扩展层 Canonical Models(CanonicalModelList, CanonicalModel, CanonicalModelInfo, CanonicalEmbeddingRequest, CanonicalEmbeddingResponse, CanonicalRerankRequest, CanonicalRerankResponse);编写测试 - -## 2. 接口定义层 — Adapter、Stream、Middleware 接口 - -- [x] 2.1 创建 `internal/conversion/adapter.go`:定义 ProtocolAdapter 接口(protocolName, protocolVersion, supportsPassthrough, detectInterfaceType, buildUrl, buildHeaders, supportsInterface, decodeRequest, encodeRequest, decodeResponse, encodeResponse, createStreamDecoder, createStreamEncoder, encodeError, 扩展层编解码方法:decodeModelsResponse/encodeModelsResponse/decodeModelInfoResponse/encodeModelInfoResponse/decodeEmbeddingRequest/encodeEmbeddingRequest/decodeEmbeddingResponse/encodeEmbeddingResponse/decodeRerankRequest/encodeRerankRequest/decodeRerankResponse/encodeRerankResponse),定义 AdapterRegistry 接口(register, get, listProtocols)和 memoryRegistry 实现(sync.RWMutex 保护的 map);编写 Registry 注册/查询/重复注册测试 -- [x] 2.2 创建 `internal/conversion/stream.go`:定义 StreamDecoder 接口(processChunk(rawChunk []byte) []CanonicalStreamEvent, flush() []CanonicalStreamEvent)、StreamEncoder 接口(encodeEvent(event CanonicalStreamEvent) [][]byte, flush() [][]byte)、StreamConverter 接口(processChunk(rawChunk []byte) [][]byte, flush() [][]byte)、PassthroughStreamConverter 实现(直接传递原始字节)、CanonicalStreamConverter 实现(组合 StreamDecoder + MiddlewareChain + StreamEncoder,processChunk 内部调用 decoder → middleware → encoder 管道);编写 PassthroughStreamConverter 测试 -- [x] 2.3 创建 `internal/conversion/middleware.go`:定义 ConversionMiddleware 接口(intercept(canonical, clientProtocol, providerProtocol, context) (CanonicalRequest, error) 和可选的 interceptStreamEvent(event, clientProtocol, providerProtocol, context) (CanonicalStreamEvent, error))、ConversionContext 结构体(conversionId, interfaceType, timestamp, metadata)、MiddlewareChain 结构体(按注册顺序链式执行,任一返回错误则中断后续);编写链式执行和中断测试 - -## 3. 引擎层 — ConversionEngine 门面 - -- [x] 3.1 创建 `internal/conversion/engine.go`:定义 HTTPRequestSpec(URL, Method string, Headers map[string]string, Body []byte)、HTTPResponseSpec(StatusCode int, Headers map[string]string, Body []byte)、ConversionEngine struct(registry, middlewareChain);实现 registerAdapter、use、isPassthrough、convertHttpRequest(接口识别 → 透传判断 → clientAdapter.decode → middleware → providerAdapter.encode → providerAdapter.buildUrl + buildHeaders)、convertHttpResponse(透传判断 → providerAdapter.decodeResponse → clientAdapter.encodeResponse)、createStreamConverter(透传 → PassthroughStreamConverter,否则 → CanonicalStreamConverter)、内部 convertBody 分发(CHAT 走深度转换,扩展层走轻量映射,默认透传);编写集成测试:使用 mock adapter 测试跨协议转换、同协议透传、未知接口透传 - -## 4. OpenAI Adapter 实现 - -- [x] 4.1 创建 `internal/conversion/openai/types.go`:对照 `docs/conversion_openai.md` 全新定义 OpenAI 线路格式类型(不沿用旧 `internal/protocol/openai/types.go`),包含完整字段(developer role, custom tools, reasoning_effort, reasoning_content, max_completion_tokens, parallel_tool_calls, response_format 的 json_schema 类型, stream_options, 废弃的 functions/function_call);编写序列化测试 -- [x] 4.2 创建 `internal/conversion/openai/decoder.go`:实现 decodeRequest(对照 conversion_openai.md §4.1:decodeSystemPrompt 提取 system+developer 消息、decodeMessage 含 tool_calls/refusal/reasoning_content 解码、tool 消息 tool_call_id→tool_use_id、decodeTools 含 function+custom 类型、decodeToolChoice 含 required→any/allowed_tools 降级、decodeParameters 含 max_completion_tokens 优先、decodeOutputFormat、decodeThinking 含 reasoning_effort→ThinkingConfig、废弃字段 functions→tools 兼容)、decodeResponse(§5.2:content/refusal/reasoning_content/tool_calls 解码、finish_reason 映射表、usage 映射含 cached_tokens/reasoning_tokens)、扩展层 decode(decodeModelsResponse、decodeEmbeddingRequest/Response、decodeRerankRequest/Response);编写完整测试覆盖每类消息和字段映射 -- [x] 4.3 创建 `internal/conversion/openai/encoder.go`:实现 encodeRequest(对照 conversion_openai.md §4.2:provider.model_name 覆盖、system 注入到 messages[0]、encodeMessage 含 tool_calls 编码到 message 顶层、角色交替合并、encodeTools 含 function 包装、encodeToolChoice 含 any→required、encodeParameters 含 max_completion_tokens、encodeOutputFormat、encodeThinking 含 disabled→"none")、encodeResponse(§5.3:text→content、tool_use→tool_calls、thinking→reasoning_content、finish_reason 反向映射、usage 编码含 prompt_tokens_details)、扩展层 encode(encodeModelsResponse、encodeEmbeddingRequest/Response、encodeRerankRequest/Response);编写完整测试 -- [x] 4.4 创建 `internal/conversion/openai/adapter.go`:实现 OpenAI ProtocolAdapter(protocolName→"openai"、supportsPassthrough→true、detectInterfaceType 根据正则匹配识别 /v1/chat/completions→CHAT、/v1/models→MODELS 等、buildHeaders 含 Authorization+Content-Type+OpenAI-Organization、buildUrl 按接口类型映射、supportsInterface 对 CHAT/MODELS/MODEL_INFO/EMBEDDINGS/RERANK 返回 true、encodeError 含 ErrorCode→OpenAI 错误类型映射),组合 decoder 和 encoder 方法;编写测试覆盖所有路径模式和边界情况 -- [x] 4.5 创建 `internal/conversion/openai/stream_decoder.go`:实现 OpenAIStreamDecoder(对照 conversion_openai.md §6.2-§6.3:processChunk 解析 SSE data 行,维护状态机 messageStarted/openBlocks/toolCallIdMap/toolCallNameMap/toolCallArguments/textBlockStarted/thinkingBlockStarted/utf8Remainder/accumulatedUsage,首个 chunk→MessageStartEvent,delta.content→text block 生命周期,delta.tool_calls→tool_use block 生命周期含索引映射和参数累积,delta.reasoning_content→thinking block(非标准),delta.refusal→text block,finish_reason→关闭所有 open blocks + MessageDeltaEvent + MessageStopEvent,usage chunk→MessageDeltaEvent,[DONE]→flush 关闭);编写测试覆盖每种 delta 类型和边界情况(空 chunk、多 tool_calls、UTF-8 截断) -- [x] 4.6 创建 `internal/conversion/openai/stream_encoder.go`:实现 OpenAIStreamEncoder(对照 conversion_openai.md §6.4:encodeEvent,ContentBlockStart 缓冲策略等待首次 ContentBlockDelta 合并输出,tool_use id/name 在首次 delta 时合并编码,text_delta 直接输出 data: {choices:[{delta:{content}}]},input_json_delta 含 tool_calls 数组编码,thinking_delta 含 reasoning_content 字段,MessageStartEvent→{choices:[{delta:{role:"assistant"}}]},MessageDeltaEvent→{choices:[{delta:{},finish_reason}]},MessageStopEvent→[DONE],PingEvent/ErrorEvent 丢弃,flush 输出缓冲区);编写测试 - -## 5. Anthropic Adapter 实现(与 Layer 4 并行) - -- [x] 5.1 创建 `internal/conversion/anthropic/types.go`:对照 `docs/conversion_anthropic.md` 全新定义 Anthropic 线路格式类型(不沿用旧 `internal/protocol/anthropic/types.go`),包含完整字段(thinking.type 含 adaptive、output_config.format/effort、disable_parallel_tool_use、metadata.user_id、redacted_thinking、pause_turn/refusal stop_reason、stop_details、container、cache_control);编写序列化测试 -- [x] 5.2 创建 `internal/conversion/anthropic/decoder.go`:实现 decodeRequest(对照 conversion_anthropic.md §4.1:decodeSystem 从顶层 system 提取、decodeMessage 含 tool_result 从 user 消息拆分为独立 tool 角色消息、参数直接映射含 top_k、decodeThinking 含 enabled/disabled/adaptive 三种类型、decodeOutputFormat 仅支持 json_schema、公共字段提取含 metadata.user_id/disable_parallel_tool_use 反转/output_config.effort、协议特有字段 redacted_thinking 丢弃/cache_control 忽略)、decodeResponse(§5.2:text/tool_use/thinking 块解码、redacted_thinking 丢弃、stop_reason 映射含 pause_turn/refusal、usage 映射含 cache_read_input_tokens/cache_creation_input_tokens)、扩展层 decode(decodeModelsResponse 含 RFC3339→Unix 时间戳转换、decodeModelInfoResponse);编写完整测试覆盖角色拆分、thinking 三种类型、时间戳转换 -- [x] 5.3 创建 `internal/conversion/anthropic/encoder.go`:实现 encodeRequest(对照 conversion_anthropic.md §4.2:provider.model_name 覆盖、system 注入为顶层字段、encodeMessages 含 tool→user 合并(优先合并到相邻 user 消息)、首消息 user 保证(自动注入空 user)、角色交替合并、encodeThinkingConfig 含 enabled/disabled/adaptive、encodeOutputFormat 含 json_object→空 schema 降级/text 丢弃、公共字段编码含 metadata.user_id/disable_parallel_tool_use 反转/output_config、参数编码含 max_tokens 必填/top_k 直接映射)、encodeResponse(§5.3:text/tool_use/thinking 块直接编码、stop_reason 映射含 content_filter→end_turn 降级、usage 编码含 cache_read_input_tokens/cache_creation_input_tokens)、扩展层 encode(encodeModelsResponse 含 Unix→RFC3339 转换和 has_more/first_id/last_id 字段、encodeModelInfoResponse);编写完整测试覆盖角色合并、首消息注入、降级处理 -- [x] 5.4 创建 `internal/conversion/anthropic/adapter.go`:实现 Anthropic ProtocolAdapter(protocolName→"anthropic"、supportsPassthrough→true、detectInterfaceType 根据正则匹配识别 /v1/messages→CHAT、/v1/models→MODELS 等、buildHeaders 含 x-api-key + anthropic-version + anthropic-beta + Content-Type、buildUrl 按接口类型映射、supportsInterface 对 CHAT/MODELS/MODEL_INFO 返回 true 对 EMBEDDINGS/RERANK 返回 false、encodeError 返回 {type:"error",error:{type,message}});编写测试覆盖所有路径模式和边界情况 -- [x] 5.5 创建 `internal/conversion/anthropic/stream_decoder.go`:实现 AnthropicStreamDecoder(对照 conversion_anthropic.md §6.2-§6.3:解析命名 SSE 事件 event: message_start/data: {...},1:1 映射到 CanonicalStreamEvent,维护状态 messageStarted/redactedBlocks/utf8Remainder/accumulatedUsage,redacted_thinking 检测后加入 redactedBlocks 并丢弃后续 delta/stop,citations_delta/signature_delta 直接丢弃,server_tool_use 等服务端工具块丢弃,UTF-8 跨 chunk 安全处理);编写测试覆盖所有事件类型和 redacted_thinking 丢弃 -- [x] 5.6 创建 `internal/conversion/anthropic/stream_encoder.go`:实现 AnthropicStreamEncoder(对照 conversion_anthropic.md §6.4:直接映射无缓冲,每个 CanonicalStreamEvent 直接编码为对应的 Anthropic 命名 SSE 事件,格式 event: ``\ndata: ``\n\n,delta 编码 text_delta/input_json_delta/thinking_delta 直接映射);编写测试 - -## 6. 基础设施改造 — Provider、Handler、Domain - -- [x] 6.1 修改 `internal/domain/provider.go`:Provider 结构体新增 Protocol string 字段;修改 `internal/config/models.go`:GORM Provider 模型同步新增 Protocol 字段(gorm:"column:protocol;default:'openai'");修改 `internal/repository/` 中 toDomainProvider 和 toConfigProvider 转换函数同步 Protocol 字段;修改 `internal/handler/provider_handler.go`:CreateProvider 和 UpdateProvider 的请求结构体新增 Protocol 字段(可选,默认 "openai"),创建/更新 Provider 时赋值 Protocol 字段,List/Get 响应中包含 Protocol 字段;更新 `internal/service/service_test.go` 中所有创建测试 Provider 的地方补充 Protocol 字段;更新 `internal/handler/handler_test.go` 中 Provider CRUD 测试的请求体补充 Protocol 字段;创建数据库迁移文件 `backend/migrations/YYYYMMDDHHMMSS_add_provider_protocol.sql`:ALTER TABLE providers ADD COLUMN protocol TEXT DEFAULT 'openai' -- [x] 6.2 重写 `internal/provider/client.go`:定义 HTTPRequestSpec 和 HTTPResponseSpec(或引用 conversion 包的定义),简化 ProviderClient 接口为 Send(ctx, HTTPRequestSpec) → (*HTTPResponseSpec, error) 和 SendStream(ctx, HTTPRequestSpec) → (<-chan StreamEvent, error),移除所有旧协议硬编码依赖,Send 方法直接使用 http.NewRequest + spec.URL/Headers/Body,SendStream 保留现有 readStream goroutine 逻辑但输入改为 HTTPRequestSpec;重写 `provider/client_test.go`:删除所有基于旧协议类型的测试用例,基于 HTTPRequestSpec 重写成功/失败/流式测试用例,使用 httptest.Server 验证请求构建和响应解析 -- [x] 6.3 创建 `internal/handler/proxy_handler.go`:实现 ProxyHandler struct(依赖 ConversionEngine、ProviderClient、RoutingService、StatsService),实现 HandleProxy(w, r) 方法:从 URL 提取 clientProtocol(仅支持 `/{protocol}/v1/...` 前缀路由,不支持旧路由)、解析请求体 JSON、调用 RoutingService.Route(modelName) 获取路由结果(含 Provider.Protocol 作为 providerProtocol)、构建 TargetProvider、调用 engine.convertHttpRequest、调用 providerClient.Send/SendStream、调用 engine.convertHttpResponse、设置响应 Content-Type 和状态码、流式处理设置 text/event-stream 并用 StreamConverter 逐块转换写入、错误处理使用 clientAdapter.encodeError、异步调用 StatsService.Record;编写测试使用 httptest + mock engine/client/service -- [x] 6.4 修改 `cmd/server/main.go`:创建 AdapterRegistry 并注册 OpenAI 和 Anthropic Adapter、创建 ConversionEngine(注入 registry)、创建 ProxyHandler(注入 engine + providerClient + routingService + statsService)、配置 Gin 路由:新增 `/{protocol}/v1/{path:*}` → ProxyHandler.HandleProxy,删除旧路由 `/v1/chat/completions` 和 `/v1/messages`,移除旧的 OpenAIHandler 和 AnthropicHandler 的路由注册,删除旧 Adapter 创建代码 - -## 7. 清理和文档 - -- [x] 7.1 删除旧代码:删除 `internal/protocol/openai/` 目录(types.go, adapter.go, adapter_test.go)、删除 `internal/protocol/anthropic/` 目录(types.go, converter.go, converter_test.go, stream_converter.go, stream_converter_test.go)、删除 `internal/handler/openai_handler.go` 和 `internal/handler/anthropic_handler.go`、删除 `internal/handler/handler_test.go` 中旧 OpenAI/Anthropic handler 测试用例和旧 `mockProviderClient`(基于旧协议类型的签名)、重写 `handler_test.go` 为 ProxyHandler 测试(基于新 ProviderClient 接口和 ConversionEngine mock)、删除 `internal/protocol/` 空目录、确认所有编译通过且无残留 import -- [x] 7.2 更新 `README.md`:更新项目结构说明(新增 internal/conversion/、删除 internal/protocol/)、更新 API 接口说明(代理接口变更:`/{protocol}/v1/...`,移除旧路由 `/v1/chat/completions` 和 `/v1/messages`)、更新配置说明(Provider 新增 protocol 字段) -- [x] 7.3 端到端测试:在 `backend/tests/integration/` 中新增 `conversion_test.go`,使用 httptest mock 上游服务器验证完整请求流:OpenAI→OpenAI 同协议透传、Anthropic→Anthropic 同协议透传、OpenAI→Anthropic 跨协议非流式、Anthropic→OpenAI 跨协议非流式、4 种方向的流式转换(含 tool_calls 和 thinking)、Models 接口跨协议转换、错误响应格式验证(各协议格式)、旧路由 `/v1/chat/completions` 和 `/v1/messages` 返回 404;复用 `tests/helpers.go` 中的测试数据库和 Provider/Model 创建辅助函数 diff --git a/openspec/specs/anthropic-protocol-proxy/spec.md b/openspec/specs/anthropic-protocol-proxy/spec.md index 5c98762..25a9bb1 100644 --- a/openspec/specs/anthropic-protocol-proxy/spec.md +++ b/openspec/specs/anthropic-protocol-proxy/spec.md @@ -4,42 +4,47 @@ ### Requirement: 支持 Anthropic Messages API 端点 -网关 SHALL 提供 Anthropic Messages API 端点 `POST /v1/messages` 供外部应用调用。 +网关 SHALL 提供 Anthropic Messages API 端点供外部应用调用。 #### Scenario: 成功的非流式请求 -- **WHEN** 应用发送 POST 请求到 `/v1/messages`,携带有效的 Anthropic 请求格式(非流式) -- **THEN** 网关 SHALL 将 Anthropic 请求转换为 OpenAI 格式 -- **THEN** 网关 SHALL 将转换后的请求转发到配置的供应商 -- **THEN** 网关 SHALL 将 OpenAI 响应转换回 Anthropic 格式 -- **THEN** 网关 SHALL 将转换后的响应返回给应用 +- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带有效的 Anthropic 请求格式(非流式) +- **THEN** 网关 SHALL 通过 ConversionEngine 将 Anthropic 请求解码为 Canonical 格式 +- **THEN** 网关 SHALL 将 Canonical 请求编码为目标供应商协议格式 +- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 Anthropic 格式返回给应用 #### Scenario: 成功的流式请求 -- **WHEN** 应用发送 POST 请求到 `/v1/messages`,携带 `stream: true` -- **THEN** 网关 SHALL 将 Anthropic 请求转换为 OpenAI 格式 -- **THEN** 网关 SHALL 将转换后的请求转发给供应商 -- **THEN** 网关 SHALL 将 OpenAI 流事件转换为 Anthropic 流事件 -- **THEN** 网关 SHALL 使用 SSE 格式将转换后的事件流式返回给应用 +- **WHEN** 应用发送 POST 请求到 `/anthropic/v1/messages`,携带 `stream: true` +- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter +- **THEN** 网关 SHALL 将上游协议的 SSE 流转换为 Anthropic 命名事件格式 +- **THEN** 网关 SHALL 使用 `event: \ndata: \n\n` 格式流式返回给应用 -**变更说明:** handler 通过 service 层调用,而非直接调用 config 和 provider 包。API 接口保持不变。 +#### Scenario: 同协议透传(Anthropic → Anthropic Provider) -### Requirement: 将 Anthropic 请求转换为 OpenAI 格式 +- **WHEN** 客户端使用 Anthropic 协议且目标供应商也是 Anthropic 协议 +- **THEN** 网关 SHALL 跳过 Canonical 转换,仅重建认证 Header 后原样转发 +- **THEN** 请求和响应 Body SHALL 保持原样 -网关 SHALL 将 Anthropic Messages API 请求转换为 OpenAI Chat Completions API 格式。 +### Requirement: 双向协议转换 -#### Scenario: System 消息转换 +网关 SHALL 支持 Anthropic 协议与任意已注册协议间的双向转换。 -- **WHEN** Anthropic 请求包含 `system` 字段 -- **THEN** 网关 SHALL 将其转换为 `messages` 数组中 `role: "system"` 的消息 +#### Scenario: Anthropic 客户端 → OpenAI 供应商 -#### Scenario: Messages 转换 +- **WHEN** 客户端使用 Anthropic 协议且供应商使用 OpenAI 协议 +- **THEN** SHALL 将 Anthropic MessagesRequest 解码为 CanonicalRequest +- **THEN** SHALL 将 CanonicalRequest 编码为 OpenAI ChatCompletionRequest +- **THEN** SHALL 将 OpenAI ChatCompletionResponse 解码为 CanonicalResponse +- **THEN** SHALL 将 CanonicalResponse 编码为 Anthropic MessagesResponse -- **WHEN** Anthropic 请求包含 `messages` 数组 -- **THEN** 网关 SHALL 在转换后的 OpenAI 请求中保留这些消息 -- **THEN** 网关 SHALL 保留每条消息的 role 和 content +#### Scenario: OpenAI 客户端 → Anthropic 供应�� -**变更说明:** 协议转换逻辑保持不变,仅调用方式改为通过 service 层。 +- **WHEN** 客户端使用 OpenAI 协议且供应商使用 Anthropic 协议 +- **THEN** SHALL 将 OpenAI ChatCompletionRequest 解码为 CanonicalRequest +- **THEN** SHALL 将 CanonicalRequest 编码为 Anthropic MessagesRequest +- **THEN** SHALL 将 Anthropic MessagesResponse 解码为 CanonicalResponse +- **THEN** SHALL 将 CanonicalResponse 编码为 OpenAI ChatCompletionResponse ## ADDED Requirements @@ -49,9 +54,9 @@ Handler SHALL 通过 service 层处理业务逻辑。 #### Scenario: 调用 routing service -- **WHEN** handler 收到请求并转换为 OpenAI 格式 +- **WHEN** ProxyHandler 收到 Anthropic 协议请求 - **THEN** SHALL 调用 RoutingService.Route() 获取路由结果 -- **THEN** SHALL 使用路由结果中的供应商信息 +- **THEN** SHALL 从路由结果获取 Provider(含 protocol 字段) #### Scenario: 调用 stats service @@ -61,16 +66,22 @@ Handler SHALL 通过 service 层处理业务逻辑。 ### Requirement: 使用结构化错误处理 -Handler SHALL 使用结构化错误处理。 +ProxyHandler SHALL 使用 ConversionError 和 Anthropic 的 encodeError 处理错误。 #### Scenario: 协议转换错误 -- **WHEN** 协议转换失败 -- **THEN** SHALL 返回结构化错误响应 -- **THEN** SHALL 包含详细的错误信息 +- **WHEN** ConversionEngine 返回 ConversionError +- **THEN** SHALL 使用 Anthropic 的 Adapter.encodeError 编码错误响应 +- **THEN** SHALL 使用 Anthropic 错误格式(`{type: "error", error: {type, message}}`) #### Scenario: 路由错误处理 - **WHEN** RoutingService 返回错误 -- **THEN** SHALL 转换为对应的 AppError -- **THEN** SHALL 返回统一的错误响应 +- **THEN** SHALL 转换为 ConversionError +- **THEN** SHALL 使用 Anthropic 错误格式返回 + +#### Scenario: 供应商错误处理 + +- **WHEN** ProviderClient 返回错误 +- **THEN** SHALL 包装为 ConversionError +- **THEN** SHALL 使用 Anthropic 错误格式返回 diff --git a/openspec/changes/refactor-conversion-engine/specs/conversion-engine/spec.md b/openspec/specs/conversion-engine/spec.md similarity index 99% rename from openspec/changes/refactor-conversion-engine/specs/conversion-engine/spec.md rename to openspec/specs/conversion-engine/spec.md index ae46c9d..aefda24 100644 --- a/openspec/changes/refactor-conversion-engine/specs/conversion-engine/spec.md +++ b/openspec/specs/conversion-engine/spec.md @@ -1,3 +1,5 @@ +# Conversion Engine + ## ADDED Requirements ### Requirement: 定义 CanonicalRequest 规范模型 @@ -264,7 +266,7 @@ ErrorCode SHALL 包含:INVALID_INPUT、MISSING_REQUIRED_FIELD、INCOMPATIBLE_F - **THEN** SHALL 使用对应扩展层 Canonical Model 做轻量字段映射 - **THEN** 双方都不支持时 SHALL 走透传逻辑 -### Requirement: 定义 TargetProvider 结构体 +### Requirement: ��义 TargetProvider 结构体 系统 SHALL 定义 `TargetProvider` 结构体,包含 `base_url`、`api_key`、`model_name`、`adapter_config`。 @@ -273,4 +275,4 @@ ErrorCode SHALL 包含:INVALID_INPUT、MISSING_REQUIRED_FIELD、INCOMPATIBLE_F - **WHEN** Adapter 调用 buildHeaders(provider) - **THEN** SHALL 从 provider.api_key 提取认证信息 - **THEN** SHALL 从 provider.adapter_config 提取协议专属配置 -- **THEN** SHALL 使用 provider.model_name 覆盖请求中的 model 字段 +- **THEN** SHALL 使用 provider.model_name 覆盖请求中的 model 字段 \ No newline at end of file diff --git a/openspec/specs/error-handling/spec.md b/openspec/specs/error-handling/spec.md index b569e77..abb1156 100644 --- a/openspec/specs/error-handling/spec.md +++ b/openspec/specs/error-handling/spec.md @@ -29,6 +29,12 @@ - **THEN** SHALL 使用 ErrModelNotFound、ErrProviderNotFound 等预定义错误 - **THEN** SHALL 设置 HTTP 状态码为 404 +#### Scenario: 转换错误响应 + +- **WHEN** ConversionEngine 在协议转换过程中产生 ConversionError +- **THEN** SHALL 使用客户端协议的 Adapter.encodeError 编码错误响应 +- **THEN** 错误响应 SHALL 使用客户端可理解的协议格式 + #### Scenario: 验证错误 - **WHEN** 请求验证失败 @@ -120,3 +126,26 @@ - **WHEN** repository 层发生错误 - **THEN** SHALL 包装数据库错误 - **THEN** SHALL 转换为应用错误 + +## ADDED Requirements + +### Requirement: 定义 ConversionError 错误类型 + +系统 SHALL 定义 ConversionError 结构体和 ErrorCode 枚举。 + +#### Scenario: ConversionError 结构 + +- **WHEN** 定义转换错误 +- **THEN** SHALL 包含 Code(ErrorCode 枚举)、Message 字段 +- **THEN** SHALL 可选包含 ClientProtocol、ProviderProtocol、InterfaceType、Details、Cause 字段 + +#### Scenario: ErrorCode 枚举 + +- **WHEN** 定义错误码 +- **THEN** SHALL 包含 INVALID_INPUT、MISSING_REQUIRED_FIELD、INCOMPATIBLE_FEATURE、FIELD_MAPPING_FAILURE、TOOL_CALL_PARSE_ERROR、JSON_PARSE_ERROR、STREAM_STATE_ERROR、UTF8_DECODE_ERROR、PROTOCOL_CONSTRAINT_VIOLATION、ENCODING_FAILURE、INTERFACE_NOT_SUPPORTED + +#### Scenario: 错误码到协议错误类型的映射 + +- **WHEN** 使用 encodeError 编码错误 +- **THEN** ErrorCode SHALL 映射为各协议的错误类型字符串 +- **THEN** 例如 INVALID_INPUT → OpenAI "invalid_request_error",Anthropic "invalid_request_error" diff --git a/openspec/specs/layered-architecture/spec.md b/openspec/specs/layered-architecture/spec.md index 3cea38f..017388a 100644 --- a/openspec/specs/layered-architecture/spec.md +++ b/openspec/specs/layered-architecture/spec.md @@ -4,28 +4,37 @@ ### Requirement: 实现三层架构 -系统 SHALL 实现 handler → service → repository 三层架构。 +系统 SHALL 实现 handler → service → repository 三层架构,并在 handler 和 provider 之间新增 conversion 层。 #### Scenario: Handler 层职责 - **WHEN** 处理 HTTP 请求 -- **THEN** handler 层 SHALL 仅负责 HTTP 请求解析和响应 +- **THEN** handler 层 SHALL 仅负责 HTTP 请求解析、URL 路由和响应写入 +- **THEN** handler 层 SHALL 调用 ConversionEngine 处理协议转换 - **THEN** handler 层 SHALL 调用 service 层处理业务逻辑 -- **THEN** handler 层 SHALL NOT 直接访问数据库 +- **THEN** handler 层 SHALL NOT 直接访问数据库或执行协议转换逻辑 + +#### Scenario: Conversion 层职责 + +- **WHEN** 处理协议转换 +- **THEN** conversion 层 SHALL 包含 Canonical Model 定义 +- **THEN** conversion 层 SHALL 包含各协议的 ProtocolAdapter 实现 +- **THEN** conversion 层 SHALL 包含 ConversionEngine 门面 +- **THEN** conversion 层 SHALL NOT 依赖 handler 或 service 层 #### Scenario: Service 层职责 - **WHEN** 处理业务逻辑 - **THEN** service 层 SHALL 包含业务规则和验证 - **THEN** service 层 SHALL 调用 repository 层访问数据 -- **THEN** service 层 SHALL 协调多个 repository 的操作 +- **THEN** service 层 SHALL NOT 包含协议转换逻辑 #### Scenario: Repository 层职责 - **WHEN** 访问数据 - **THEN** repository 层 SHALL 仅负责数据访问 - **THEN** repository 层 SHALL 封装数据库操作 -- **THEN** repository 层 SHALL NOT 包含业务逻辑 +- **THEN** repository 层 SHALL NOT 包含业务逻辑或协议转换逻辑 ### Requirement: 定义核心接口 @@ -49,9 +58,17 @@ - **WHEN** 定义 provider client 接口 - **THEN** SHALL 定义 ProviderClient 接口 -- **THEN** SHALL 包含 SendRequest 和 SendStreamRequest 方法 +- **THEN** SHALL 包含 Send(非流式)和 SendStream(流式)方法 +- **THEN** SHALL 接受 HTTPRequestSpec 作为参数,不绑定特定协议 - **THEN** SHALL 支持接口 Mock +#### Scenario: Conversion 层接口定义 + +- **WHEN** 定义 conversion 层接口 +- **THEN** SHALL 定义 ProtocolAdapter、StreamDecoder、StreamEncoder、StreamConverter、ConversionMiddleware 接口 +- **THEN** SHALL 定义 AdapterRegistry 用于 Adapter 注册和查询 +- **THEN** SHALL 定义 ConversionEngine 作为统一门面 + ### Requirement: 实现依赖注入 系统 SHALL 使用手动依赖注入。 @@ -65,15 +82,24 @@ #### Scenario: Service 注入 - **WHEN** 初始化 handler -- **THEN** SHALL 通过构造函数注入 service 依赖 +- **THEN** SHALL 通过构造函数注入 service 依赖、ConversionEngine、ProviderClient - **THEN** SHALL 使用接口类型而非具体类型 +#### Scenario: Conversion 组装 + +- **WHEN** 应用启动 +- **THEN** SHALL 创建 AdapterRegistry 并注册所有 ProtocolAdapter +- **THEN** SHALL 创建 ConversionEngine(注入 registry 和 middleware chain) +- **THEN** SHALL 将 ConversionEngine 注入到 ProxyHandler + #### Scenario: 主函数组装 - **WHEN** 应用启动 - **THEN** main.go SHALL 按顺序构造所有依赖 - **THEN** SHALL 先构造基础设施(logger、database) -- **THEN** SHALL 再构造 repository、service、handler +- **THEN** SHALL 再构造 repository、service +- **THEN** SHALL 再构造 conversion 层(registry → engine) +- **THEN** SHALL 最后构造 handler ### Requirement: 定义 Domain 模型 @@ -84,6 +110,7 @@ - **WHEN** 定义领域模型 - **THEN** SHALL 在 internal/domain/ 包中定义 - **THEN** SHALL 包含 Provider、Model、UsageStats 等模型 +- **THEN** Provider SHALL 包含 Protocol 字段 - **THEN** SHALL 与数据库模型分离 #### Scenario: Domain 模型使用 diff --git a/openspec/specs/openai-protocol-proxy/spec.md b/openspec/specs/openai-protocol-proxy/spec.md index 141eecc..96a01ba 100644 --- a/openspec/specs/openai-protocol-proxy/spec.md +++ b/openspec/specs/openai-protocol-proxy/spec.md @@ -4,22 +4,27 @@ ### Requirement: 支持 OpenAI Chat Completions API 端点 -网关 SHALL 提供 OpenAI Chat Completions API 端点 `POST /v1/chat/completions` 供外部应用调用。 +网关 SHALL 提供 OpenAI Chat Completions API 端点供外部应用调用。 #### Scenario: 成功的非流式请求 -- **WHEN** 应用发送 POST 请求到 `/v1/chat/completions`,携带有效的 OpenAI 请求格式(非流式) -- **THEN** 网关 SHALL 将请求转发到配置的供应商 -- **THEN** 网关 SHALL 将供应商的响应以 OpenAI 格式返回给应用 +- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带有效的 OpenAI 请求格式(非流式) +- **THEN** 网关 SHALL 通过 ConversionEngine 转换请求 +- **THEN** 网关 SHALL 将转换后的请求转发到配置的供应商 +- **THEN** 网关 SHALL 将供应商的响应通过 ConversionEngine 转换为 OpenAI 格式返回给应用 #### Scenario: 成功的流式请求 -- **WHEN** 应用发送 POST 请求到 `/v1/chat/completions`,携带 `stream: true` -- **THEN** 网关 SHALL 将请求转发到配置的供应商 -- **THEN** 网关 SHALL 使用 SSE 格式将响应流式返回给应用 +- **WHEN** 应用发送 POST 请求到 `/openai/v1/chat/completions`,携带 `stream: true` +- **THEN** 网关 SHALL 通过 ConversionEngine 创建 StreamConverter +- **THEN** 网关 SHALL 使用 SSE 格式将转换后的响应流式返回给应用 - **THEN** 网关 SHALL 在流完成时发送 `data: [DONE]` -**变更说明:** handler 通过 service 层调用,而非直接调用 config 和 provider 包。API 接口保持不变。 +#### Scenario: 同协议透传(OpenAI → OpenAI Provider) + +- **WHEN** 客户端使用 OpenAI 协议且目标供应商也是 OpenAI 协议 +- **THEN** 网关 SHALL 跳过 Canonical 转换,仅重建认证 Header 后原样转发 +- **THEN** 请求和响应 Body SHALL 保持原样 ### Requirement: 根据模型名称路由请求 @@ -30,38 +35,32 @@ - **WHEN** 请求包含存在于配置模型中的 `model` 字段 - **AND** 该模型已启用 - **THEN** 网关 SHALL 将请求路由到该模型关联的供应商 +- **THEN** 网关 SHALL 从供应商的 `protocol` 字段获取 providerProtocol #### Scenario: 模型未找到 - **WHEN** 请求包含不存在于配置模型中的 `model` 字段 -- **THEN** 网关 SHALL 返回带有适当错误消息的错误响应 +- **THEN** 网关 SHALL 使用 OpenAI 格式返回错误响应 #### Scenario: 模型已禁用 - **WHEN** 请求包含已禁用模型的 `model` 字段 -- **THEN** 网关 SHALL 返回错误响应,指示模型不可用 - -**变更说明:** 路由逻辑从 router 包迁移到 RoutingService,通过 service 层调用。API 接口保持不变。 +- **THEN** 网关 SHALL 使用 OpenAI 格式返回错误响应 ### Requirement: 对 OpenAI 兼容供应商透明代理 -网关 SHALL 对 OpenAI 兼容供应商的请求和响应进行透明转发,不做修改。 +网关 SHALL 对 OpenAI 兼容供应商的请求和响应通过 ConversionEngine 进行转换处理。 -#### Scenario: 请求转发 +#### Scenario: 跨协议请求转发 -- **WHEN** 网关收到 OpenAI 协议请求 -- **AND** 目标供应商是 OpenAI 兼容的 -- **THEN** 网关 SHALL 将请求体原样转发给供应商 -- **THEN** 网关 SHALL 在 Authorization 头中设置供应商的 API Key -- **THEN** 网关 SHALL 使用供应商的 base URL +- **WHEN** 网关收到 OpenAI 协议请求且目标供应商使用不同协议 +- **THEN** 网关 SHALL 通过 ConversionEngine 将请求转换为目标协议格式 +- **THEN** 网关 SHALL 使用目标协议的 Adapter 构建 URL 和 Header -#### Scenario: 响应转发 +#### Scenario: 扩展层接口代理 -- **WHEN** 供应商返回响应 -- **THEN** 网关 SHALL 将响应体原样返回给应用 -- **THEN** 网关 SHALL 保留所有响应头和状态码 - -**变更说明:** provider client 通过接口注入到 handler,便于测试和替换实现。API 接口保持不变。 +- **WHEN** 网关收到 `/openai/v1/models` 等 GET 请求 +- **THEN** 网关 SHALL 通过 ConversionEngine 转换扩展层接口的响应格式 ## ADDED Requirements @@ -81,18 +80,40 @@ Handler SHALL 通过 service 层处理业务逻辑。 - **THEN** SHALL 调用 StatsService.Record() 记录统计 - **THEN** SHALL 异步记录统计(不阻塞响应) +### Requirement: 使用 service 层处理请求 + +Handler SHALL 通过 service 层处理业务逻辑。 + +#### Scenario: 调用 routing service + +- **WHEN** ProxyHandler 收到请求 +- **THEN** SHALL 调用 RoutingService.Route() 获取路由结果 +- **THEN** SHALL 从路由结果获取 Provider(含 protocol 字段) + +#### Scenario: 调用 stats service + +- **WHEN** 请求成功完成 +- **THEN** SHALL 调用 StatsService.Record() 记录统计 +- **THEN** SHALL 异步记录统计(不阻塞响应) + ### Requirement: 使用结构化错误处理 -Handler SHALL 使用结构化错误处理。 +ProxyHandler SHALL 使用 ConversionError 和协议对应的 encodeError 处理错误。 + +#### Scenario: 转换错误 + +- **WHEN** ConversionEngine 返回 ConversionError +- **THEN** SHALL 使用 clientProtocol 的 Adapter.encodeError 编码错误响应 +- **THEN** SHALL 使用 OpenAI 错误格式(`{error: {message, type, code}}`) #### Scenario: 路由错误处理 - **WHEN** RoutingService 返回错误 -- **THEN** SHALL 转换为对应的 AppError -- **THEN** SHALL 返回统一的错误响应 +- **THEN** SHALL 转换为 ConversionError +- **THEN** SHALL 使用 OpenAI 错误格式返回 #### Scenario: 供应商错误处理 - **WHEN** ProviderClient 返回错误 -- **THEN** SHALL 包装为 AppError -- **THEN** SHALL 包含请求上下文信息 +- **THEN** SHALL 包装为 ConversionError +- **THEN** SHALL 使用 OpenAI 错误格式返回 diff --git a/openspec/changes/refactor-conversion-engine/specs/protocol-adapter-anthropic/spec.md b/openspec/specs/protocol-adapter-anthropic/spec.md similarity index 99% rename from openspec/changes/refactor-conversion-engine/specs/protocol-adapter-anthropic/spec.md rename to openspec/specs/protocol-adapter-anthropic/spec.md index a7d1316..68e775d 100644 --- a/openspec/changes/refactor-conversion-engine/specs/protocol-adapter-anthropic/spec.md +++ b/openspec/specs/protocol-adapter-anthropic/spec.md @@ -1,3 +1,5 @@ +# Protocol Adapter - Anthropic + ## ADDED Requirements ### Requirement: 实现 Anthropic ProtocolAdapter @@ -266,4 +268,4 @@ Decoder 几乎 1:1 映射,维护最小状态机: - **WHEN** interfaceType 为 EMBEDDINGS 或 RERANK - **THEN** supportsInterface SHALL 返回 false -- **THEN** 引擎 SHALL 走透传或返回空响应 +- **THEN** 引擎 SHALL 走透传或返回空响应 \ No newline at end of file diff --git a/openspec/changes/refactor-conversion-engine/specs/protocol-adapter-openai/spec.md b/openspec/specs/protocol-adapter-openai/spec.md similarity index 99% rename from openspec/changes/refactor-conversion-engine/specs/protocol-adapter-openai/spec.md rename to openspec/specs/protocol-adapter-openai/spec.md index 3a4fac8..f2035f1 100644 --- a/openspec/changes/refactor-conversion-engine/specs/protocol-adapter-openai/spec.md +++ b/openspec/specs/protocol-adapter-openai/spec.md @@ -1,3 +1,5 @@ +# Protocol Adapter - OpenAI + ## ADDED Requirements ### Requirement: 实现 OpenAI ProtocolAdapter @@ -85,7 +87,7 @@ - **WHEN** canonical.system 不为空 - **THEN** SHALL 编码为 messages 数组头部的 role="system" 消息 -#### Scenario: Assistant 消息中 tool_calls 编码 +#### Scenario: Assistant ��息中 tool_calls 编码 - **WHEN** CanonicalMessage{role: "assistant"} 包含 tool_use 类型 ContentBlock - **THEN** SHALL 提取到 message.tool_calls 数组({id, type: "function", function: {name, arguments}}) @@ -265,4 +267,4 @@ Encoder SHALL 维护状态: #### Scenario: /rerank 接口 - **WHEN** 解码/编码 rerank 请求和响应 -- **THEN** SHALL 使用 CanonicalRerankRequest/Response 做字段映射 +- **THEN** SHALL 使用 CanonicalRerankRequest/Response 做字段映射 \ No newline at end of file diff --git a/openspec/specs/provider-management/spec.md b/openspec/specs/provider-management/spec.md index f46def7..0008445 100644 --- a/openspec/specs/provider-management/spec.md +++ b/openspec/specs/provider-management/spec.md @@ -8,10 +8,11 @@ #### Scenario: 使用有效数据创建供应商 -- **WHEN** 向 `/api/providers` 发送 POST 请求,携带有效的供应商数据(id, name, api_key, base_url) +- **WHEN** 向 `/api/providers` 发送 POST 请求,携带有效的供应商数据(id, name, api_key, base_url, protocol) - **THEN** 网关 SHALL 在数据库中创建新的供应商记录 - **THEN** 网关 SHALL 返回创建的供应商,状态码为 201 - **THEN** 供应商 SHALL 默认启用 +- **THEN** protocol 字段 SHALL 默认为 "openai" #### Scenario: 使用重复 ID 创建供应商 @@ -34,7 +35,7 @@ - **WHEN** 向 `/api/providers` 发送 GET 请求 - **THEN** 网关 SHALL 返回所有供应商的列表 -- **THEN** 每个供应商 SHALL 包含 id, name, api_key(已掩码), base_url, enabled, created_at, updated_at +- **THEN** 每个供应商 SHALL 包含 id, name, api_key(已掩码), base_url, protocol, enabled, created_at, updated_at - **THEN** api_key SHALL 被掩码(仅显示最后 4 个字符) **变更说明:** 数据访问从 config 包迁移到 ProviderRepository。API 接口保持不变。 @@ -47,6 +48,7 @@ - **WHEN** 向 `/api/providers/:id` 发送 GET 请求,携带有效的供应商 ID - **THEN** 网关 SHALL 返回供应商详情 +- **THEN** SHALL 包含 protocol 字段 - **THEN** api_key SHALL 被掩码 #### Scenario: 获取不存在的供应商 @@ -65,7 +67,7 @@ - **WHEN** 向 `/api/providers/:id` 发送 PUT 请求,携带有效的供应商数据 - **THEN** 网关 SHALL 更新数据库中的供应商记录 - **THEN** 网关 SHALL 返回更新后的供应商 -- **THEN** updated_at 时间戳 SHALL 被更新 +- **THEN** 更新 SHALL 支持修改 protocol 字段 **变更说明:** 通过 ProviderService 和 ProviderRepository 实现。API 接口保持不变。 diff --git a/openspec/specs/request-validation/spec.md b/openspec/specs/request-validation/spec.md index 444f911..a55e0f2 100644 --- a/openspec/specs/request-validation/spec.md +++ b/openspec/specs/request-validation/spec.md @@ -20,43 +20,42 @@ ### Requirement: 验证 OpenAI 请求 -系统 SHALL 验证 OpenAI ChatCompletionRequest。 +系统 SHALL 验证 OpenAI ChatCompletionRequest,验证逻辑位于 ProtocolAdapter 的 decodeRequest 内。 #### Scenario: 必需字段验证 -- **WHEN** 收到 OpenAI 请求 +- **WHEN** OpenAI Adapter 的 decodeRequest 解析请求 - **THEN** SHALL 验证 model 字段不为空 - **THEN** SHALL 验证 messages 字段不为空且至少有一条消息 +- **THEN** 验证失败 SHALL 返回 INVALID_INPUT 类型的 ConversionError #### Scenario: 参数范围验证 -- **WHEN** 收到 OpenAI 请求 +- **WHEN** OpenAI Adapter 的 decodeRequest 解析参数 - **THEN** SHALL 验证 temperature 范围在 [0, 2] - **THEN** SHALL 验证 max_tokens 大于 0 - **THEN** SHALL 验证 top_p 范围在 (0, 1] -- **THEN** SHALL 验证 frequency_penalty 范围在 [-2, 2] -- **THEN** SHALL 验证 presence_penalty 范围在 [-2, 2] #### Scenario: 消息内容验证 - **WHEN** 验证 messages 字段 -- **THEN** SHALL 验证每条消息的 role 有效(system、user、assistant、tool) +- **THEN** SHALL 验证每条消息的 role 有效(system、developer、user、assistant、tool) - **THEN** SHALL 验证 content 不为空 ### Requirement: 验证 Anthropic 请求 -系统 SHALL 验证 Anthropic MessagesRequest。 +系统 SHALL 验证 Anthropic MessagesRequest,验证逻辑位于 ProtocolAdapter 的 decodeRequest 内。 #### Scenario: 必需字段验证 -- **WHEN** 收到 Anthropic 请求 +- **WHEN** Anthropic Adapter 的 decodeRequest 解析请求 - **THEN** SHALL 验证 model 字段不为空 - **THEN** SHALL 验证 messages 字段不为空且至少有一条消息 - **THEN** SHALL 验证 max_tokens 大于 0(或使用默认值) #### Scenario: 参数范围验证 -- **WHEN** 收到 Anthropic 请求 +- **WHEN** Anthropic Adapter 的 decodeRequest 解析参数 - **THEN** SHALL 验证 temperature 范围在 [0, 1] - **THEN** SHALL 验证 top_p 范围在 (0, 1] @@ -93,26 +92,17 @@ 系统 SHALL 返回友好的验证错误响应。 -#### Scenario: 错误消息格式 +#### Scenario: 转换错误格式 -- **WHEN** 验证失败 -- **THEN** SHALL 返回 400 状态码 -- **THEN** SHALL 返回详细的错误消息 -- **THEN** SHALL 指示哪些字段验证失败 +- **WHEN** decodeRequest 验证失败返回 ConversionError +- **THEN** ProxyHandler SHALL 使用 clientAdapter.encodeError 编码错误响应 +- **THEN** 错误 SHALL 使用客户端协议的格式 #### Scenario: 多字段错误 - **WHEN** 多个字段验证失败 -- **THEN** SHALL 返回所有验证错误 -- **THEN** SHALL 使用结构化格式(字段名 → 错误消息) - -#### Scenario: 国际化支持 - -- **WHEN** 返回验证错误(未来) -- **THEN** SHALL 支持错误消息国际化 -- **THEN** SHALL 使用错误码作为国际化 key - -注:当前版本使用中文错误消息。 +- **THEN** ConversionError.details SHALL 包含所有验证错误 +- **THEN** 错误响应 SHALL 包含完整的验证错误信息 ### Requirement: 在 handler 中应用验证 diff --git a/openspec/changes/refactor-conversion-engine/specs/unified-proxy-handler/spec.md b/openspec/specs/unified-proxy-handler/spec.md similarity index 97% rename from openspec/changes/refactor-conversion-engine/specs/unified-proxy-handler/spec.md rename to openspec/specs/unified-proxy-handler/spec.md index dafc71c..84d30d5 100644 --- a/openspec/changes/refactor-conversion-engine/specs/unified-proxy-handler/spec.md +++ b/openspec/specs/unified-proxy-handler/spec.md @@ -1,3 +1,5 @@ +# Unified Proxy Handler + ## ADDED Requirements ### Requirement: 实现统一代理 Handler @@ -92,11 +94,11 @@ ProxyHandler SHALL 记录请求统计。 ProxyHandler SHALL 支持 GET 请求的扩展层接口代理。 -#### Scenario: Models 接口代理 +#### Scenario: Models 接口��理 - **WHEN** 收到 GET /{protocol}/v1/models 请求 - **THEN** SHALL 执行路由和协议识别 - **THEN** SHALL 调用 engine.convertHttpRequest(GET 请求 body 为空) - **THEN** SHALL 调用 providerClient.Send 发送请求 - **THEN** SHALL 调用 engine.convertHttpResponse 转换响应格式 -- **THEN** SHALL 返回转换后的响应 +- **THEN** SHALL 返回转换后的响应 \ No newline at end of file