OpenAI Chat ingress의 내부 wire가 Responses로 전환돼도 decoder가 caller protocol만 보고 Chat shape를 요구해 502를 만들었다. selected operation을 우선해 공통 stage로 복원한다.
361 lines
17 KiB
Go
361 lines
17 KiB
Go
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
func TestGeminiChatProviderThoughtSignatureRoundTrip(t *testing.T) {
|
|
response := []byte(`{"id":"chat-1","model":"served","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call-1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"},"extra_content":{"google":{"thought_signature":"opaque-signature"}}}]},"finish_reason":"tool_calls"}]}`)
|
|
rewritten := rewriteProviderJSONResponse(response, "public-gemini", config.ProtocolToolCallWireGeminiChat)
|
|
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(rewritten, &decoded); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
choice := anySlice(decoded["choices"])[0].(map[string]any)
|
|
message := choice["message"].(map[string]any)
|
|
call := anySlice(message["tool_calls"])[0].(map[string]any)
|
|
encodedID := call["id"].(string)
|
|
if !strings.HasPrefix(encodedID, geminiThoughtSignatureToolIDPrefix) {
|
|
t.Fatalf("thought signature was not encoded in tool id: %+v", call)
|
|
}
|
|
if _, exists := call["extra_content"]; exists {
|
|
t.Fatalf("Gemini extension leaked after normalization: %+v", call)
|
|
}
|
|
if decoded["model"] != "public-gemini" {
|
|
t.Fatalf("model echo was not preserved through normalization: %+v", decoded)
|
|
}
|
|
|
|
request, err := normalizeGeminiChatProviderRequest([]byte(`{"model":"served","messages":[{"role":"assistant","content":null,"tool_calls":[{"id":"` + encodedID + `","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"}}]},{"role":"tool","tool_call_id":"` + encodedID + `","content":"[]"}]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var replay map[string]any
|
|
if err := json.Unmarshal(request, &replay); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
messages := anySlice(replay["messages"])
|
|
replayedCall := anySlice(messages[0].(map[string]any)["tool_calls"])[0].(map[string]any)
|
|
if replayedCall["id"] != "call-1" || messages[1].(map[string]any)["tool_call_id"] != "call-1" {
|
|
t.Fatalf("original tool ids were not restored: %+v", messages)
|
|
}
|
|
extra := replayedCall["extra_content"].(map[string]any)
|
|
google := extra["google"].(map[string]any)
|
|
if google["thought_signature"] != "opaque-signature" {
|
|
t.Fatalf("thought signature was not restored: %+v", replayedCall)
|
|
}
|
|
}
|
|
|
|
func TestProviderThoughtSignatureNormalizationIsGeminiProfileOnly(t *testing.T) {
|
|
response := []byte(`{"model":"served","choices":[{"message":{"tool_calls":[{"id":"call-1","extra_content":{"google":{"thought_signature":"opaque"}}}]}}]}`)
|
|
if got := rewriteProviderJSONResponse(response, "public", ""); !strings.Contains(string(got), `"id":"call-1"`) || !strings.Contains(string(got), `"thought_signature":"opaque"`) {
|
|
t.Fatalf("non-Gemini response was normalized: %s", got)
|
|
}
|
|
|
|
profile, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
baseBody := []byte(`{"model":"served","messages":[{"role":"user","content":"hi"}]}`)
|
|
tunnel := edgeservice.SubmitProviderTunnelRequest{BuildBody: func(string) ([]byte, error) { return baseBody, nil }}
|
|
prepared, err := prepareProviderChatToolCallNormalization(tunnel, edgeservice.ProviderPoolCandidate{ProtocolProfile: &profile})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := prepared.BuildBody("served")
|
|
if err != nil || string(got) != string(baseBody) {
|
|
t.Fatalf("OpenAI profile body changed: %s err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestProviderChatTokenLimitNormalizationUsesSelectedProfile(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
profileID string
|
|
body string
|
|
wantField string
|
|
wantAbsent string
|
|
want float64
|
|
}{
|
|
{name: "generic max tokens to OpenAI completion field", profileID: "openai", body: `{"model":"served","max_tokens":32000,"future":{"keep":true}}`, wantField: "max_completion_tokens", wantAbsent: "max_tokens", want: 32000},
|
|
{name: "OpenAI native field wins", profileID: "openai", body: `{"model":"served","max_tokens":8,"max_completion_tokens":16}`, wantField: "max_completion_tokens", wantAbsent: "max_tokens", want: 16},
|
|
{name: "completion field to Gemini legacy field", profileID: "gemini", body: `{"model":"served","max_completion_tokens":2048}`, wantField: "max_tokens", wantAbsent: "max_completion_tokens", want: 2048},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
profile, err := config.ResolveProtocolProfile(tc.profileID, "", config.BuiltInProtocolProfileCatalog())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tunnel := edgeservice.SubmitProviderTunnelRequest{
|
|
Operation: string(config.OperationChatCompletions),
|
|
BuildBody: func(string) ([]byte, error) { return []byte(tc.body), nil },
|
|
}
|
|
prepared, err := prepareProviderChatRequestNormalization(tunnel, edgeservice.ProviderPoolCandidate{ProtocolProfile: &profile})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := prepared.BuildBody("served")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(body, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got[tc.wantField] != tc.want {
|
|
t.Fatalf("%s=%v, want %v; body=%s", tc.wantField, got[tc.wantField], tc.want, body)
|
|
}
|
|
if _, ok := got[tc.wantAbsent]; ok {
|
|
t.Fatalf("%s survived normalization: %s", tc.wantAbsent, body)
|
|
}
|
|
if strings.Contains(tc.body, `"future"`) && !strings.Contains(string(body), `"future":{"keep":true}`) {
|
|
t.Fatalf("unknown provider field changed: %s", body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGeminiChatProviderStreamingThoughtSignatureNormalization(t *testing.T) {
|
|
line := []byte("data: {\"model\":\"served\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":\"glob\",\"arguments\":\"{}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"opaque\"}}}]}}]}\n\n")
|
|
rewriter := newProviderModelRewriterWithToolCallWire(true, "public", config.ProtocolToolCallWireGeminiChat)
|
|
got := append(rewriter.AppendStream(line), rewriter.FlushStream()...)
|
|
if strings.Contains(string(got), "thought_signature") || !strings.Contains(string(got), geminiThoughtSignatureToolIDPrefix) {
|
|
t.Fatalf("streaming signature was not normalized: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestGeminiChatProviderRejectsMalformedOpaqueToolID(t *testing.T) {
|
|
_, err := normalizeGeminiChatProviderRequest([]byte(`{"messages":[{"role":"assistant","tool_calls":[{"id":"iop_gts_not-base64"}]}]}`))
|
|
if err == nil {
|
|
t.Fatal("malformed IOP Gemini tool id must fail closed")
|
|
}
|
|
}
|
|
|
|
func TestOpenAIChatProviderHTTPNormalizesGenericTokenLimit(t *testing.T) {
|
|
var providerRequest map[string]any
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
if err := json.Unmarshal(body, &providerRequest); err != nil {
|
|
t.Errorf("decode provider request: %v", err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"chat-openai","model":"served-openai","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`))
|
|
}))
|
|
defer provider.Close()
|
|
|
|
profile, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
tunnelProviderURL: provider.URL,
|
|
tunnelServedTarget: "served-openai",
|
|
poolSelectedCandidate: edgeservice.ProviderPoolCandidate{
|
|
ActualModel: "served-openai", ProviderID: "openai-provider",
|
|
ExecutionPath: string(edgeservice.ProviderPoolPathTunnel), ProfileID: profile.ID,
|
|
ProfileDriver: string(profile.Driver), ProfileCapabilities: append([]string(nil), profile.Capabilities...),
|
|
ProtocolProfile: &profile,
|
|
},
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "openai-route", Providers: map[string]string{"openai-provider": "served-openai"}}})
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"openai-route","messages":[{"role":"user","content":"hello"}],"max_tokens":32000,"future":{"keep":true}}`))
|
|
response := httptest.NewRecorder()
|
|
srv.handleChatCompletions(response, request)
|
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"content":"done"`) {
|
|
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
if providerRequest["max_completion_tokens"] != float64(32000) {
|
|
t.Fatalf("max_completion_tokens=%v, request=%+v", providerRequest["max_completion_tokens"], providerRequest)
|
|
}
|
|
if _, ok := providerRequest["max_tokens"]; ok {
|
|
t.Fatalf("legacy max_tokens reached OpenAI provider: %+v", providerRequest)
|
|
}
|
|
if future, ok := providerRequest["future"].(map[string]any); !ok || future["keep"] != true {
|
|
t.Fatalf("unknown provider field changed: %+v", providerRequest)
|
|
}
|
|
}
|
|
|
|
func TestHotPathOpenAIToolsAndEffortUseResponsesOperation(t *testing.T) {
|
|
profile, err := config.ResolveProtocolProfile("openai", "", config.BuiltInProtocolProfileCatalog())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tunnel := edgeservice.SubmitProviderTunnelRequest{
|
|
Operation: string(config.OperationChatCompletions),
|
|
Path: "/v1/chat/completions",
|
|
BuildBody: func(target string) ([]byte, error) {
|
|
return []byte(`{"model":"` + target + `","messages":[{"role":"system","content":"inspect"},{"role":"user","content":"work"}],"tools":[{"type":"function","function":{"name":"bash","parameters":{"type":"object"}}}],"max_tokens":32000,"reasoning_effort":"high","temperature":0,"top_p":1,"stream":true}`), nil
|
|
},
|
|
}
|
|
prepared, err := prepareHotPathChatProviderOperation(tunnel, edgeservice.ProviderPoolCandidate{ProtocolProfile: &profile}, providerRequestRequirements{
|
|
HasTools: true, Stream: true, Effort: "high",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if prepared.Operation != string(config.OperationResponses) || prepared.Path != "/v1/responses" {
|
|
t.Fatalf("operation=%q path=%q", prepared.Operation, prepared.Path)
|
|
}
|
|
if prepared.Stream {
|
|
t.Fatal("buffered internal Responses operation retained streaming tunnel metadata")
|
|
}
|
|
body, err := prepared.BuildBody("gpt-5.6-terra")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var request map[string]any
|
|
if err := json.Unmarshal(body, &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if request["max_output_tokens"] != float64(32000) || request["messages"] != nil || request["max_tokens"] != nil {
|
|
t.Fatalf("Responses token/message conversion mismatch: %+v", request)
|
|
}
|
|
reasoning, _ := request["reasoning"].(map[string]any)
|
|
if reasoning["effort"] != "high" || len(anySlice(request["tools"])) != 1 {
|
|
t.Fatalf("Responses effort/tools conversion mismatch: %+v", request)
|
|
}
|
|
if request["temperature"] != nil || request["top_p"] != nil {
|
|
t.Fatalf("reasoning Responses request retained incompatible sampling controls: %+v", request)
|
|
}
|
|
}
|
|
|
|
func TestChatProviderRequirementsAllowPresetOwnedEffort(t *testing.T) {
|
|
requirements := chatProviderRequirements(chatCompletionRequest{Tools: []any{map[string]any{"type": "function"}}})
|
|
if requirements.Effort != "" || !requirements.HasTools {
|
|
t.Fatalf("caller requirements=%+v", requirements)
|
|
}
|
|
presetOptions := map[string]any{"reasoning_effort": "high"}
|
|
if effort, ok := presetOptions["reasoning_effort"].(string); ok {
|
|
requirements.Effort = strings.TrimSpace(effort)
|
|
}
|
|
if requirements.Effort != "high" || !requirements.HasTools {
|
|
t.Fatalf("effective selector requirements=%+v", requirements)
|
|
}
|
|
}
|
|
|
|
func TestHotPathSelectorOutputLimitIsBounded(t *testing.T) {
|
|
tunnel := edgeservice.SubmitProviderTunnelRequest{
|
|
BuildBody: func(string) ([]byte, error) {
|
|
return []byte(`{"model":"selector","max_completion_tokens":32000,"future":true}`), nil
|
|
},
|
|
}
|
|
prepared, err := prepareHotPathSelectorOutputLimit(tunnel)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := prepared.BuildBody("selector")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var request map[string]any
|
|
if err := json.Unmarshal(body, &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if request["max_tokens"] != float64(maxHotPathSelectorOutputTokens) || request["max_completion_tokens"] != nil || request["future"] != true {
|
|
t.Fatalf("bounded selector request=%+v", request)
|
|
}
|
|
}
|
|
|
|
func TestOpenAIHotPathResponsesOperationUsesResponsesDecoder(t *testing.T) {
|
|
body := []byte(`{"id":"resp-selector","model":"gpt-5.6-terra","status":"completed","output":[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"bash","arguments":"{\"command\":\"true\"}"}],"usage":{"input_tokens":10,"output_tokens":3}}`)
|
|
stage, err := decodePresetTunnelBody(body, "application/json", "openai", string(config.OperationResponses), string(config.ProtocolDriverOpenAIChat))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stage.ResponseID != "resp-selector" || len(stage.ToolCalls) != 1 || stage.ToolCalls[0].Name != "bash" {
|
|
t.Fatalf("Responses stage=%+v", stage)
|
|
}
|
|
}
|
|
|
|
func TestGeminiChatProviderHTTPToolContinuationRoundTrip(t *testing.T) {
|
|
var providerRequests []map[string]any
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
var request map[string]any
|
|
if err := json.Unmarshal(body, &request); err != nil {
|
|
t.Errorf("decode provider request: %v", err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
providerRequests = append(providerRequests, request)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if len(providerRequests) == 1 {
|
|
_, _ = w.Write([]byte(`{"id":"chat-1","model":"served-gemini","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call-1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"},"extra_content":{"google":{"thought_signature":"opaque-signature"}}}]},"finish_reason":"tool_calls"}]}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"id":"chat-2","model":"served-gemini","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`))
|
|
}))
|
|
defer provider.Close()
|
|
|
|
profile, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfileCatalog())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
tunnelProviderURL: provider.URL,
|
|
tunnelServedTarget: "served-gemini",
|
|
poolSelectedCandidate: edgeservice.ProviderPoolCandidate{
|
|
ActualModel: "served-gemini", ProviderID: "gemini-provider",
|
|
ExecutionPath: string(edgeservice.ProviderPoolPathTunnel), ProfileID: profile.ID,
|
|
ProfileDriver: string(profile.Driver), ProfileCapabilities: append([]string(nil), profile.Capabilities...),
|
|
ProtocolProfile: &profile,
|
|
},
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "gemini-route", Providers: map[string]string{"gemini-provider": "served-gemini"}}})
|
|
|
|
first := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gemini-route","messages":[{"role":"user","content":"list files"}],"tools":[{"type":"function","function":{"name":"glob","parameters":{"type":"object"}}}]}`))
|
|
firstResponse := httptest.NewRecorder()
|
|
srv.handleChatCompletions(firstResponse, first)
|
|
if firstResponse.Code != http.StatusOK {
|
|
t.Fatalf("first status=%d body=%s", firstResponse.Code, firstResponse.Body.String())
|
|
}
|
|
if got := fake.lastTunnelHandle.Dispatch().ProfileToolCallWire; got != config.ProtocolToolCallWireGeminiChat {
|
|
t.Fatalf("selected dispatch tool-call wire=%q", got)
|
|
}
|
|
var firstBody map[string]any
|
|
if err := json.Unmarshal(firstResponse.Body.Bytes(), &firstBody); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
firstChoice := anySlice(firstBody["choices"])[0].(map[string]any)
|
|
firstMessage := firstChoice["message"].(map[string]any)
|
|
encodedID := anySlice(firstMessage["tool_calls"])[0].(map[string]any)["id"].(string)
|
|
if !strings.HasPrefix(encodedID, geminiThoughtSignatureToolIDPrefix) {
|
|
t.Fatalf("caller did not receive opaque normalized id: %s", firstResponse.Body.String())
|
|
}
|
|
|
|
secondBody := `{"model":"gemini-route","messages":[{"role":"user","content":"list files"},{"role":"assistant","content":null,"tool_calls":[{"id":"` + encodedID + `","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*\"}"}}]},{"role":"tool","tool_call_id":"` + encodedID + `","content":"[]"}]}`
|
|
second := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(secondBody))
|
|
secondResponse := httptest.NewRecorder()
|
|
srv.handleChatCompletions(secondResponse, second)
|
|
if secondResponse.Code != http.StatusOK || !strings.Contains(secondResponse.Body.String(), `"content":"done"`) {
|
|
t.Fatalf("second status=%d body=%s", secondResponse.Code, secondResponse.Body.String())
|
|
}
|
|
if len(providerRequests) != 2 {
|
|
t.Fatalf("provider requests=%d", len(providerRequests))
|
|
}
|
|
messages := anySlice(providerRequests[1]["messages"])
|
|
replayedCall := anySlice(messages[1].(map[string]any)["tool_calls"])[0].(map[string]any)
|
|
if replayedCall["id"] != "call-1" || messages[2].(map[string]any)["tool_call_id"] != "call-1" {
|
|
t.Fatalf("provider did not receive original tool ids: %+v", messages)
|
|
}
|
|
google := replayedCall["extra_content"].(map[string]any)["google"].(map[string]any)
|
|
if google["thought_signature"] != "opaque-signature" {
|
|
t.Fatalf("provider did not receive restored signature: %+v", replayedCall)
|
|
}
|
|
}
|