OpenCode의 일반 Chat 요청을 GPT provider가 거부한 뒤 재시도 가능한 오류로 왜곡해 벤치가 장시간 정체됐다. 선택된 protocol profile에 맞춰 출력 토큰 필드를 정규화하고 upstream 400을 비재시도 validation 오류로 유지한다.
269 lines
13 KiB
Go
269 lines
13 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 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)
|
|
}
|
|
}
|