* feat(responses): 实现响应状态续接路由 * fix(responses): 限制响应 ID 长度并兼容缺失字段删除 * fix(responses): 按协议存储能力匹配续接路由
446 lines
18 KiB
Go
446 lines
18 KiB
Go
package dialect
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
|
|
"gpt-load/internal/execution"
|
|
)
|
|
|
|
func TestInspectRequestIdentifiesOperationAndNativeRouteRequirement(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
dialect Dialect
|
|
request *ParsedRequest
|
|
operation execution.Operation
|
|
requirement execution.RouteRequirement
|
|
}{
|
|
{
|
|
name: "OpenAI Chat",
|
|
dialect: NewOpenAI(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/chat/completions", Body: []byte(`{
|
|
"model":"gpt-4o","stream":true,"reasoning_effort":"high",
|
|
"tools":[{"type":"function","function":{"name":"lookup"}}],
|
|
"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.test/image.png"}}]}],
|
|
"response_format":{"type":"json_schema","json_schema":{"name":"answer","schema":{"type":"object"}}}
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
},
|
|
{
|
|
name: "Anthropic Messages",
|
|
dialect: NewAnthropic(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/messages", Body: []byte(`{
|
|
"model":"claude-sonnet-4","stream":true,
|
|
"thinking":{"type":"enabled","budget_tokens":1024},
|
|
"tools":[{"name":"lookup","input_schema":{"type":"object"}}],
|
|
"messages":[{"role":"user","content":[{"type":"document","source":{"type":"base64","media_type":"application/pdf","data":"AA=="}}]}],
|
|
"output_config":{"format":{"type":"json_schema","schema":{"type":"object"}}}
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
},
|
|
{
|
|
name: "Anthropic count tokens",
|
|
dialect: NewAnthropic(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/messages/count_tokens", Body: []byte(`{
|
|
"model":"claude-sonnet-4","messages":[{"role":"user","content":"hello"}]
|
|
}`)},
|
|
operation: execution.OperationCountTokens,
|
|
},
|
|
{
|
|
name: "Gemini generateContent",
|
|
dialect: NewGemini(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1beta/models/gemini-2.5-pro:streamGenerateContent", Body: []byte(`{
|
|
"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"AA=="}}]}],
|
|
"tools":[{"functionDeclarations":[{"name":"lookup"}]}],
|
|
"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"},"responseSchema":{"type":"OBJECT"}}
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
},
|
|
{
|
|
name: "Gemini count tokens",
|
|
dialect: NewGemini(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1beta/models/gemini-2.5-pro:countTokens", Body: []byte(`{
|
|
"contents":[{"role":"user","parts":[{"text":"hello"}]}]
|
|
}`)},
|
|
operation: execution.OperationCountTokens,
|
|
},
|
|
{
|
|
name: "Responses create stored",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
|
|
"model":"gpt-5","stream":true,"reasoning":{"effort":"high"},
|
|
"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],
|
|
"input":[{"role":"user","content":[{"type":"input_file","file_id":"file_123"}]}],
|
|
"text":{"format":{"type":"json_schema","name":"answer","schema":{"type":"object"}}}
|
|
}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "Responses create transient",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{"model":"gpt-5","store":false}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
},
|
|
{
|
|
name: "Responses reasoning none stays convertible",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{"model":"gpt-5","reasoning":{"effort":"none"},"store":false}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
},
|
|
{
|
|
name: "OpenAI Chat tool history",
|
|
dialect: NewOpenAI(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/chat/completions", Body: []byte(`{
|
|
"model":"gpt-4o",
|
|
"messages":[
|
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]},
|
|
{"role":"tool","tool_call_id":"call_1","content":"ok"}
|
|
]
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
},
|
|
{
|
|
name: "OpenAI Chat file ID requires native semantics",
|
|
dialect: NewOpenAI(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/chat/completions", Body: []byte(`{
|
|
"model":"gpt-4o","messages":[{"role":"user","content":[{"type":"file","file":{"file_id":"file_123"}}]}]
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "OpenAI Chat inline file stays convertible",
|
|
dialect: NewOpenAI(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/chat/completions", Body: []byte(`{
|
|
"model":"gpt-4o","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"input.txt","file_data":"data:text/plain;base64,SGVsbG8="}}]}]
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
},
|
|
{
|
|
name: "Anthropic tool and reasoning history",
|
|
dialect: NewAnthropic(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/messages", Body: []byte(`{
|
|
"model":"claude-sonnet-4",
|
|
"messages":[{"role":"user","content":[
|
|
{"type":"tool_result","tool_use_id":"tool_1","content":"ok"},
|
|
{"type":"redacted_thinking","data":"opaque"}
|
|
]}]
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
},
|
|
{
|
|
name: "Gemini function history",
|
|
dialect: NewGemini(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1beta/models/gemini-2.5-pro:generateContent", Body: []byte(`{
|
|
"contents":[{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"result":"ok"}}}]}]
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
},
|
|
{
|
|
name: "Responses tool and reasoning history",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
|
|
"model":"gpt-5","store":false,
|
|
"input":[
|
|
{"type":"reasoning","encrypted_content":"opaque"},
|
|
{"type":"function_call_output","call_id":"call_1","output":"ok"}
|
|
]
|
|
}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
},
|
|
{
|
|
name: "Responses previous response requires native semantics",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
|
|
"model":"gpt-5","store":false,"previous_response_id":"resp_123"
|
|
}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "Responses conversation requires native semantics",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
|
|
"model":"gpt-5","store":false,"conversation":"conv_123"
|
|
}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "Responses background requires native semantics",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
|
|
"model":"gpt-5","store":false,"background":true
|
|
}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "Responses item reference requires native semantics",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
|
|
"model":"gpt-5","store":false,"input":[{"type":"item_reference","id":"item_123"}]
|
|
}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "Responses input file reference requires native semantics",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
|
|
"model":"gpt-5","store":false,"input":[{"role":"user","content":[{"type":"input_file","file_id":"file_123"}]}]
|
|
}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "Responses file search reference requires native semantics",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
|
|
"model":"gpt-5","store":false,"tools":[{"type":"file_search","vector_store_ids":["vs_123"]}]
|
|
}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "Responses code interpreter container requires native semantics",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
|
|
"model":"gpt-5","store":false,"tools":[{"type":"code_interpreter","container":"cntr_123"}]
|
|
}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "Responses unrelated resource-like metadata stays convertible",
|
|
dialect: NewOpenAIResponses(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/responses", Body: []byte(`{
|
|
"model":"gpt-5","store":false,"metadata":{"file_id":"business-file","container_id":"business-container"}
|
|
}`)},
|
|
operation: execution.OperationResponsesCreate,
|
|
},
|
|
{
|
|
name: "Gemini cached content requires native semantics",
|
|
dialect: NewGemini(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1beta/models/gemini-2.5-pro:generateContent", Body: []byte(`{
|
|
"contents":[{"role":"user","parts":[{"text":"hello"}]}],"cachedContent":"cachedContents/abc"
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "Gemini Files API URI requires native semantics",
|
|
dialect: NewGemini(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1beta/models/gemini-2.5-pro:generateContent", Body: []byte(`{
|
|
"contents":[{"role":"user","parts":[{"fileData":{"mimeType":"application/pdf","fileUri":"https://generativelanguage.googleapis.com/v1beta/files/abc123"}}]}]
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "Gemini public file URL stays convertible",
|
|
dialect: NewGemini(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1beta/models/gemini-2.5-pro:generateContent", Body: []byte(`{
|
|
"contents":[{"role":"user","parts":[{"fileData":{"mimeType":"application/pdf","fileUri":"https://cdn.example.test/input.pdf"}}]}]
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
},
|
|
{
|
|
name: "Anthropic container requires native semantics",
|
|
dialect: NewAnthropic(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/messages", Body: []byte(`{
|
|
"model":"claude-sonnet-4","container":{"id":"container_123"},"messages":[{"role":"user","content":"hello"}]
|
|
}`)},
|
|
operation: execution.OperationChatCompletion,
|
|
requirement: execution.RouteRequirementNative,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
metadata, err := test.dialect.InspectRequest(test.request)
|
|
if err != nil {
|
|
t.Fatalf("InspectRequest() error = %v", err)
|
|
}
|
|
if metadata.Operation != test.operation {
|
|
t.Fatalf("Operation = %q, want %q", metadata.Operation, test.operation)
|
|
}
|
|
wantRequirement := test.requirement.Normalize()
|
|
if metadata.RouteRequirement != wantRequirement {
|
|
t.Errorf("RouteRequirement = %q, want %q", metadata.RouteRequirement, wantRequirement)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCountTokensRequestsAreNonStreamingUtilities(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
dialect Dialect
|
|
request *ParsedRequest
|
|
}{
|
|
{
|
|
name: "Anthropic",
|
|
dialect: NewAnthropic(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1/messages/count_tokens", Body: []byte(`{"model":"claude-sonnet-4","stream":true,"messages":[]}`)},
|
|
},
|
|
{
|
|
name: "Gemini",
|
|
dialect: NewGemini(),
|
|
request: &ParsedRequest{Method: http.MethodPost, Path: "/v1beta/models/gemini-2.5-pro:countTokens", Body: []byte(`{"contents":[]}`)},
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
metadata, err := test.dialect.InspectRequest(test.request)
|
|
if err != nil {
|
|
t.Fatalf("InspectRequest() error = %v", err)
|
|
}
|
|
if metadata.Operation != execution.OperationCountTokens {
|
|
t.Fatalf("Operation = %q, want count_tokens", metadata.Operation)
|
|
}
|
|
if metadata.Stream {
|
|
t.Fatal("CountTokens request was classified as streaming")
|
|
}
|
|
if metadata.ObserveUsage {
|
|
t.Fatal("CountTokens request was classified as generation usage")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAIResponsesInspectRequestIdentifiesLifecycleOperations(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
selected := NewOpenAIResponses()
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
operation execution.Operation
|
|
model string
|
|
requirement execution.RouteRequirement
|
|
}{
|
|
{name: "create", method: http.MethodPost, path: "/v1/responses", body: `{"model":"gpt-5"}`, operation: execution.OperationResponsesCreate, model: "gpt-5"},
|
|
{name: "compact", method: http.MethodPost, path: "/v1/responses/compact", body: `{"model":"gpt-5","input":[]}`, operation: execution.OperationResponsesCompact, model: "gpt-5"},
|
|
{name: "input tokens", method: http.MethodPost, path: "/v1/responses/input_tokens", body: `{"model":"gpt-5","input":[]}`, operation: execution.OperationResponsesInputTokens, model: "gpt-5"},
|
|
{name: "retrieve", method: http.MethodGet, path: "/v1/responses/resp_123", operation: execution.OperationResponsesRetrieve, requirement: execution.RouteRequirementNative},
|
|
{name: "delete", method: http.MethodDelete, path: "/v1/responses/resp_123", operation: execution.OperationResponsesDelete, requirement: execution.RouteRequirementNative},
|
|
{name: "cancel", method: http.MethodPost, path: "/v1/responses/resp_123/cancel", operation: execution.OperationResponsesCancel, requirement: execution.RouteRequirementNative},
|
|
{name: "input items", method: http.MethodGet, path: "/v1/responses/resp_123/input_items", operation: execution.OperationResponsesInputItems, requirement: execution.RouteRequirementNative},
|
|
{name: "vendor extension", method: http.MethodPatch, path: "/v1/responses/vendor-extension/nested", body: `{"vendor":true}`, operation: execution.OperationResponsesPassthrough, requirement: execution.RouteRequirementNative},
|
|
{name: "resource head", method: http.MethodHead, path: "/v1/responses/resp_123", operation: execution.OperationResponsesPassthrough, requirement: execution.RouteRequirementNative},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
metadata, err := selected.InspectRequest(&ParsedRequest{
|
|
Method: test.method,
|
|
Path: test.path,
|
|
Body: []byte(test.body),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InspectRequest() error = %v", err)
|
|
}
|
|
if metadata.Operation != test.operation {
|
|
t.Fatalf("Operation = %q, want %q", metadata.Operation, test.operation)
|
|
}
|
|
gotModel := ""
|
|
if metadata.Model != nil {
|
|
gotModel = *metadata.Model
|
|
}
|
|
if got := gotModel; got != test.model {
|
|
t.Fatalf("Model = %q, want %q", got, test.model)
|
|
}
|
|
wantRequirement := test.requirement.Normalize()
|
|
if metadata.RouteRequirement != wantRequirement {
|
|
t.Fatalf("RouteRequirement = %q, want %q", metadata.RouteRequirement, wantRequirement)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResponsesCreateClassifiesStorePreferenceWithoutWeakeningNativeResources(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
routeRequirement execution.RouteRequirement
|
|
storePreference execution.ResponsesStorePreference
|
|
}{
|
|
{
|
|
name: "store omitted prefers stored",
|
|
body: `{"model":"gpt-5","input":"hello"}`,
|
|
storePreference: execution.ResponsesStorePreferencePreferStored,
|
|
},
|
|
{
|
|
name: "store true prefers stored",
|
|
body: `{"model":"gpt-5","input":"hello","store":true}`,
|
|
storePreference: execution.ResponsesStorePreferencePreferStored,
|
|
},
|
|
{
|
|
name: "store false keeps current routing",
|
|
body: `{"model":"gpt-5","input":"hello","store":false}`,
|
|
},
|
|
{
|
|
name: "store null remains native only",
|
|
body: `{"model":"gpt-5","input":"hello","store":null}`,
|
|
routeRequirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "invalid store remains native only",
|
|
body: `{"model":"gpt-5","input":"hello","store":"false"}`,
|
|
routeRequirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "previous response remains native only",
|
|
body: `{"model":"gpt-5","previous_response_id":"resp_123","store":false}`,
|
|
routeRequirement: execution.RouteRequirementNative,
|
|
storePreference: execution.ResponsesStorePreferenceRequireStored,
|
|
},
|
|
{
|
|
name: "provider resource outranks omitted store",
|
|
body: `{"model":"gpt-5","input":[{"type":"item_reference","id":"item_123"}]}`,
|
|
routeRequirement: execution.RouteRequirementNative,
|
|
},
|
|
{
|
|
name: "stored prompt outranks omitted store",
|
|
body: `{"model":"gpt-5","prompt":{"id":"pmpt_123"}}`,
|
|
routeRequirement: execution.RouteRequirementNative,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
metadata, err := NewOpenAIResponses().InspectRequest(&ParsedRequest{
|
|
Method: http.MethodPost,
|
|
Path: "/v1/responses",
|
|
Body: []byte(test.body),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InspectRequest() error = %v", err)
|
|
}
|
|
if metadata.RouteRequirement != test.routeRequirement.Normalize() ||
|
|
metadata.ResponsesStorePreference != test.storePreference {
|
|
t.Fatalf(
|
|
"requirements = %q/%q, want %q/%q",
|
|
metadata.RouteRequirement,
|
|
metadata.ResponsesStorePreference,
|
|
test.routeRequirement.Normalize(),
|
|
test.storePreference,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|